Files
dev-updater/server/src/routes.rs
T
iris aa8e2b97a5 Render the escapes in an error, and drop the manifest field nobody read
Two loose ends from the per-component work.

The card's error is the tail of what the build actually printed, and a
compiler marks its own errors in colour -- so it arrived as `[1;31merror`,
with punctuation welded onto the one line somebody is trying to read. It now
goes through the same `ansiAnnotated` the log dialog uses: the colour is
drawn, and the sequences with no meaning on a phone are consumed rather than
printed, so a cursor movement cannot arrive looking like corruption either.
One composable covers it because every failure already went through
OutputText. Selection copies AnnotatedString.text, which is the message with
every escape already gone, so what lands on the clipboard is what was on
screen rather than what was on the wire.

And `/manifest` carried a `build` object that nothing on the phone ever
parsed. It was going to be how a running build survived leaving the app;
that is not built, so it is gone rather than left looking finished.
`RunningBuild` went with it -- it was split out of `BuildStatus` for that
one reader and had become an indirection with one user, so `status()` fills
one flat struct again. `/status` is byte-identical either way, since the
split was flattened on the wire; checked against the running server rather
than assumed.

Looked at on the emulator against a failing build: bold red `error`, blue
`-->`, no escape text anywhere, and long-pressing it still raises the
handles and the Copy toolbar.
2026-09-01 03:48:23 -04:00

1579 lines
63 KiB
Rust

//! The HTTP surface. Everything except the bootstrap route below is served
//! over the pinned-TLS listener, because every one of these either is, or
//! decides, the bytes that get handed to `REQUEST_INSTALL_PACKAGES` next.
//!
//! ```text
//! GET /manifest[?recheck=false] every app, its newest build, its variants
//! GET /apps/{key} one of them, for a card that just
//! acted and wants only itself back
//! GET /suggestions projects found under the configured roots
//! POST /apps {path} add the project at that path
//! DELETE /apps/{key} remove it again
//! PUT /roots {roots} set the directories /suggestions scans
//! GET /apps/{key}/apk[?variant=] the payload itself (ranged)
//! POST /apps/{key}/pull fetch, fast-forward, and build
//! ?force=true resets onto the upstream
//! POST /apps/{key}/prepare run the on-demand build step, if any
//! ?component= restricts it to one
//! POST /apps/{key}/build run it whether or not it looks stale
//! ?component= restricts it to one
//! POST /apps/{key}/approve accept the build step it asks for
//! POST /apps/{key}/recheck ask that one project's remote and
//! services again
//! PUT /apps/{key}/settings {gitIpv4}
//! this machine's preferences for it
//! GET /apps/{key}/components/{name}/logs[?lines=&generation=]
//! what that component wrote
//! POST /apps/{key}/components/{name}/{action}
//! install|uninstall|start|stop|restart
//! a server component, on the *build
//! machine*
//! GET /apps/{key}/status poll that build
//! GET /self is there a newer build of the app
//! itself -- the frozen rescue contract
//! GET /self/apk that build's bytes (ranged)
//! ```
//!
//! # The rescue contract
//!
//! `/self` and `/self/apk` are how the app updates *itself*, and they are
//! deliberately the smallest thing that can do it. Everything else here
//! is free to change shape; these two are not.
//!
//! The reason is that every other route is reachable only by an app new
//! enough to understand it. When this server changes what `/manifest`
//! says, an older app can fail to read the list -- and the list is where
//! the button that would replace that app lives. The way out is then a
//! reinstall over the plain-HTTP bootstrap port, by hand, on the machine.
//! So the path that fetches a newer app must not depend on anything this
//! server is likely to change: two fields, no nesting, no variants, no
//! query parameters.
//!
//! What it does not survive is a change to the CA, the port, or the
//! bearer token, since those break the connection before any route is
//! reached. Those remain one-way doors and the bootstrap port remains
//! their answer.
//!
//! **So: add fields here at your peril, and never remove or rename one.**
//! Anything richer belongs on a new route that an old app never calls.
//!
//! Apps are addressed by `{key}` rather than each getting its own route,
//! because the set is now mutable at runtime -- there is no fixed table to
//! register at startup.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Context;
use axum::body::Body;
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream;
use crate::build_state::BuildStatus;
use crate::discover;
use crate::registry::{AppEntry, AppState};
use crate::strip::resolve_serveable_path;
pub fn tls_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/manifest", get(manifest))
.route("/suggestions", get(suggestions))
.route("/apps", post(add_app))
.route("/apps/{key}", get(app).delete(remove_app))
.route("/apps/{key}/apk", get(serve_apk))
.route("/apps/{key}/pull", post(build_pull))
.route("/apps/{key}/prepare", post(build_prepare))
.route("/apps/{key}/build", post(build_now))
.route("/apps/{key}/approve", post(approve_declaration))
.route("/apps/{key}/recheck", post(recheck_app))
.route("/apps/{key}/settings", put(set_settings))
// Before the {action} route, so `logs` is not swallowed as an
// action -- axum matches a literal segment ahead of a capture,
// but declaring it first says so to a reader too.
.route("/apps/{key}/components/{name}/logs", get(component_logs))
.route(
"/apps/{key}/components/{name}/{action}",
post(service_action),
)
.route("/apps/{key}/status", get(build_status))
// The rescue contract; see this module's own note. `serve_apk`
// with no key already means the built-in entry -- the same
// handler the bootstrap port uses -- so the download half needs
// nothing of its own, including its ranges and the guard that
// keeps a restart from cutting it off.
.route("/self", get(self_build))
.route("/self/apk", get(serve_apk))
.route("/roots", put(set_roots))
.with_state(state)
}
/// The one thing a human ever hits on the plain-HTTP bootstrap port: the
/// updater app's own APK, at a bare `/`. Deliberately not the whole API --
/// nothing unauthenticated and unpinned should be able to change what this
/// server serves. Having no `{key}` in its path is what makes `serve_apk`
/// resolve it to the self entry; see `lookup`.
pub fn bootstrap_router(state: Arc<AppState>) -> Router {
Router::new().route("/", get(serve_apk)).with_state(state)
}
#[derive(Debug, thiserror::Error)]
enum ApiError {
#[error("no app named {0}")]
UnknownApp(String),
#[error("{0} has no build yet")]
NotBuilt(String),
/// A project that produces more than one APK, asked for "the" APK.
/// Refused rather than answered with the first: which of two clients
/// somebody meant is not something to guess, and a guess here would
/// install the wrong app while looking like it worked.
#[error("{0} builds more than one app -- say which with ?component=")]
AmbiguousApk(String),
#[error("{0} has no component named {1}")]
UnknownComponent(String, String),
#[error("{0} has no on-demand build step configured")]
NoBuildStep(String),
#[error("{0}")]
BadRequest(String),
#[error("range not satisfiable")]
RangeNotSatisfiable,
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
Self::UnknownApp(_)
| Self::NotBuilt(_)
| Self::NoBuildStep(_)
| Self::UnknownComponent(..) => StatusCode::NOT_FOUND,
Self::AmbiguousApk(_) => StatusCode::BAD_REQUEST,
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::RangeNotSatisfiable => StatusCode::RANGE_NOT_SATISFIABLE,
Self::Internal(err) => {
// The only variant whose real cause isn't safe to hand back
// verbatim, and the only one worth a log line.
tracing::error!("{err:#}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
(status, self.to_string()).into_response()
}
}
/// An `anyhow` error from a user-driven action (adding an app, picking a
/// variant) is a message written *for* the phone -- "no built APK found
/// under ...", "already added" -- not an internal fault, so it comes back
/// as a 400 with that message rather than a 500 and a log line.
fn bad_request(err: anyhow::Error) -> ApiError {
ApiError::BadRequest(format!("{err:#}"))
}
/// The installable half of a component, absent for a `Server`.
///
/// Nested rather than flattened onto the component with every field
/// optional, because "this component has an APK" is one fact rather than
/// six: a server has no build to install, no package to replace and no
/// variants to choose between, and saying that once is what stops the
/// phone having to work it out from a size of zero.
///
/// Per component and not per project. Two clients built from one checkout
/// install over different packages and are worth stripping to different
/// degrees, so a project-level answer would be the first component's,
/// presented as the project's.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ManifestApk {
/// What the download is saved as on the device.
filename: String,
/// Absent until a build has been read for it -- a project can be added
/// before it has ever been built, and claiming a package before then
/// would be inventing one.
#[serde(skip_serializing_if = "Option::is_none")]
package: Option<String>,
/// What this component used to install over, when it has been renamed
/// since this server started.
///
/// Android treats a renamed `applicationId` as an unrelated app, so
/// the old one is still installed and nothing will ever replace it.
/// The phone offers to remove it -- and knows whether it is still
/// there, which is why nothing here has to be cleared.
#[serde(skip_serializing_if = "Option::is_none")]
previous_package: Option<String>,
/// False when this component has no APK yet (never built, or cleaned).
/// It is still drawn -- it was declared deliberately, and a row saying
/// so is a better answer than one that silently vanished -- with
/// `mtime`/`size` at zero and nothing to download.
built: bool,
/// Epoch seconds of the raw build's mtime. Always the *raw* build's,
/// even when a stripped copy is what's served: that's the number that
/// actually moves when something is rebuilt, which is what the app
/// compares against the installed copy.
mtime: f64,
/// Of the file that would be served as things stand -- the slim copy
/// where one has already been produced. Close to the bytes about to be
/// downloaded rather than exactly them, because finding out exactly
/// would mean running the strip pipeline here; see
/// `strip::serveable_now`.
size: u64,
variants: Vec<ManifestVariant>,
}
/// One component, as the card needs it: what it is called and which kind
/// it is. Nothing else -- what it *does* is the build step, which is not
/// the phone's business, and how far along it is arrives on the status.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ManifestComponent {
name: String,
kind: &'static str,
/// For a server component, what its script last said it was doing.
/// Absent for an APK, and absent for a server nobody has managed to
/// ask yet.
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<crate::service::ServiceState>,
/// Its script is being asked right now, so `state` is the previous
/// answer.
#[serde(skip_serializing_if = "std::ops::Not::not")]
checking: bool,
/// Why the last ask produced no answer, when it produced none. The
/// script's own words: it knows why it could not say, and this does
/// not.
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// There is at least one log of either kind, so the card can show the
/// button that opens them. The OR of the two below, sent rather than
/// left for the phone to work out because it is the one question the
/// card itself asks.
has_logs: bool,
/// This server wrote a build log for this component. A component that
/// has never been built here has none, which is not the same as a
/// build that produced no output.
has_build_logs: bool,
/// This component's script reports a runtime log. False for an APK,
/// which does not run here, and false for a script that does not
/// implement `logs` -- which every script written before it existed
/// does not, and which is a first-class answer rather than a failure.
/// The phone shows the tab either way and says which of the two it
/// is, since "no log yet" and "this service offers none" are
/// different things to be told.
has_runtime_logs: bool,
/// The last build stopped at this component, so its build log is the
/// one worth opening first. False on every component of a build that
/// succeeded, which is what makes the runtime log the ordinary
/// default.
build_failed: bool,
/// Whether what is built is current with the checkout.
///
/// Sent for every component, including the unknown case, because
/// unknown is an answer the card has to draw differently from either
/// of the others rather than fall back to.
freshness: crate::build_state::Freshness,
/// Where Uninstall's "remove data" and "remove config" would delete,
/// from what the project said about itself, and whether anything is
/// there.
///
/// Sent so the dialog can show the path beside each toggle. That
/// display is not decoration: a path is removed wherever it points,
/// with no containment check, so showing it before the button can be
/// pressed is the whole of what stands between an accepted
/// declaration and the wrong directory.
///
/// `None` has three causes and the phone is told which, because they
/// are different things to do about it: still being read
/// ([`Self::resources_checking`]), could not be read
/// ([`Self::resources_error`]), or the project simply does not say --
/// which is the ordinary case and not a fault. Nothing is filled in
/// from the checkout's directory name or the config key; that guess
/// is what this replaced.
#[serde(skip_serializing_if = "Option::is_none")]
data_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
config_path: Option<String>,
/// Whether that path exists right now. A `stat`, which the manifest
/// path can afford where a spawn would not be -- and the answer is
/// what greys the toggle out rather than hiding it, so "there is
/// nothing to remove" is something the dialog says rather than
/// something it leaves the reader to infer from a missing control.
data_present: bool,
config_present: bool,
/// The project's resources are being read right now.
resources_checking: bool,
/// Why they could not be read. A failed read must not look like a
/// project that keeps nothing.
#[serde(skip_serializing_if = "Option::is_none")]
resources_error: Option<String>,
/// What there is to install, for a component that produces an APK.
#[serde(skip_serializing_if = "Option::is_none")]
apk: Option<ManifestApk>,
}
impl ManifestComponent {
/// Async only for the APK's size, which is a `stat` of whatever is on
/// disk -- never a strip run to find out what the slim copy would
/// weigh, because this path is fetched on every open, resume and
/// Refresh.
async fn read(
state: &AppState,
key: &str,
entry: &AppEntry,
component: &crate::config::Component,
) -> Result<Self, ApiError> {
let name = component.name().to_string();
let is_server = matches!(component, crate::config::Component::Server { .. });
// A build log is a file this server wrote, so its existence is a
// stat rather than a spawn and can be checked here. The runtime
// half comes from the cached background answer, because asking a
// script costs a process and this path is fetched on every open,
// resume and Refresh.
let has_build_logs = !crate::logs::build_logs(key, &name).is_empty();
let has_runtime_logs = is_server && !state.service_checks.logs(key, &name).is_empty();
// Only a server keeps anything on this machine, and only a
// project that says where. Both halves have to be true before
// there is a path to show.
let state_paths = is_server
.then(|| state.resource_checks.facts(key))
.flatten()
.map(|facts| crate::purge::paths(&facts, &entry.project_path));
Ok(Self {
kind: if is_server { "server" } else { "apk" },
state: is_server
.then(|| state.service_checks.state(key, &name))
.flatten(),
// Read after the state, so a check landing between the two
// reports its answer rather than that one is still coming.
checking: is_server && state.service_checks.is_checking(key, &name),
error: is_server
.then(|| state.service_checks.error(key, &name))
.flatten(),
// Unknown for a project with no build machinery at all:
// nothing could have recorded a commit, which is not the same
// as being up to date.
has_logs: has_build_logs || has_runtime_logs,
has_build_logs,
has_runtime_logs,
build_failed: entry
.build
.as_ref()
.is_some_and(|build| build.build_failed(&name)),
freshness: entry
.build
.as_ref()
.map(|build| build.freshness(component))
.unwrap_or(crate::build_state::Freshness::Unknown),
data_path: state_paths
.as_ref()
.and_then(|paths| paths.data.as_deref())
.map(|path| path.display().to_string()),
config_path: state_paths
.as_ref()
.and_then(|paths| paths.config.as_deref())
.map(|path| path.display().to_string()),
data_present: state_paths
.as_ref()
.and_then(|paths| paths.data.as_deref())
.is_some_and(std::path::Path::exists),
config_present: state_paths
.as_ref()
.and_then(|paths| paths.config.as_deref())
.is_some_and(std::path::Path::exists),
resources_checking: is_server && state.resource_checks.is_checking(key),
resources_error: is_server
.then(|| state.resource_checks.error(key))
.flatten(),
apk: match is_server {
true => None,
false => Some(ManifestApk::read(state, key, entry, component).await?),
},
name,
})
}
}
impl ManifestApk {
async fn read(
state: &AppState,
key: &str,
entry: &AppEntry,
component: &crate::config::Component,
) -> Result<Self, ApiError> {
let name = component.name();
let newest = entry.resolve_apk(component, None);
let size = match &newest {
Some(apk) => {
tokio::fs::metadata(crate::strip::serveable_now(&apk.path, component.strip()))
.await
.context("stat the apk to be served")?
.len()
}
None => 0,
};
Ok(Self {
filename: newest
.as_ref()
.map(|apk| entry.filename(&apk.path))
.unwrap_or_else(|| format!("{key}-{name}.apk")),
package: component.package().map(str::to_string),
previous_package: state.previous_package(key, name),
built: newest.is_some(),
mtime: newest
.as_ref()
.map(|apk| epoch_secs(apk.modified))
.unwrap_or(0.0),
size,
variants: entry
.variants(component)
.into_iter()
.map(|candidate| ManifestVariant {
path: candidate.path.to_string_lossy().into_owned(),
variant: candidate.variant,
mtime: epoch_secs(candidate.modified),
})
.collect(),
})
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ManifestResponse {
apps: Vec<ManifestApp>,
/// Echoed back so the app's "add" screen can show and edit them without
/// a second request.
repo_roots: Vec<String>,
/// A check is still running somewhere in this list -- a remote, or a
/// service being asked what it is doing -- so `newCommits` or a
/// component's `state` may change shortly. The phone's cue to look
/// again, rather than this response having waited for the answer.
checks_pending: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ManifestApp {
key: String,
label: String,
/// Where this project's APKs are fetched from. Which of them is said
/// with the request (`?component=`), so this stays one route per
/// project rather than one string per component that differs only in
/// its query.
route: String,
project_path: String,
needs_build: bool,
/// This machine's preferences for the project, so the card's settings
/// can show what is currently set rather than a guess at it.
git_ipv4: bool,
/// True for this server's own app, which has no Remove button.
built_in: bool,
/// Whether *anything* this project produces has been built. Per
/// component is on the component (`ManifestApk::built`); this is what
/// the card's own "nothing here yet" line reads, and what keeps a
/// project with one built client out of it.
built: bool,
/// Present when the project is in a git repository at all: the branch,
/// how far behind it is as of the last fetch, and whether the tree is
/// dirty. Absent for a project nobody keeps in git.
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<crate::git::GitStatus>,
/// Whether this app offers a Pull button (`gitPull`).
can_pull: bool,
/// Whether the remote has something this checkout doesn't, as of the
/// last check. Not a count: answering "how many" needs the objects,
/// and downloading them is what Pull is for.
new_commits: bool,
/// This checkout's remote is being asked right now, so `newCommits`
/// above is the previous answer and may change. Per app rather than
/// one flag for the list, so a card says whether *it* is the one still
/// being worked out.
check_pending: bool,
/// What this project produces, in the order it is built. One entry is
/// the ordinary case and the card shows it inline; more than one is
/// what the phone draws as a nested list.
components: Vec<ManifestComponent>,
/// Why the last check produced no answer, when it produced none.
/// Absent in the ordinary case, including before anything has been
/// asked.
///
/// Sent rather than only logged because `newCommits: false` is
/// indistinguishable from "asked, nothing there" on the phone, and the
/// phone is where this is being read -- whoever is looking at the card
/// has no access to this server's log.
#[serde(skip_serializing_if = "Option::is_none")]
check_error: Option<String>,
/// The build step this project asks for that nobody has accepted yet,
/// as RON for the phone to show verbatim -- the same form it was
/// written in. Present only while something is waiting to be read, and
/// while it is, this app runs no build step at all.
///
/// Re-serialized from what would actually be stored rather than passed
/// through as the file's own bytes, so what a person reads is what
/// would run -- a stray key this server ignores can't sit in the
/// display looking meaningful, and neither can a comment claiming
/// something the keys don't say.
#[serde(skip_serializing_if = "Option::is_none")]
pending_declaration: Option<String>,
}
/// One build found under a project. Which of them a device wants is that
/// device's business, so nothing here says which is chosen -- the phone
/// marks its own and sends it with the download.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ManifestVariant {
path: String,
variant: String,
mtime: f64,
}
fn epoch_secs(time: SystemTime) -> f64 {
time.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
#[derive(Deserialize)]
struct ManifestQuery {
/// Ask the remotes again before answering. On by default, so a plain
/// `GET /manifest` -- a person opening, resuming or refreshing the
/// list -- gets a fresh answer. The phone passes `false` when it is
/// only looking to see whether an outstanding answer has landed; see
/// [`refresh_checkouts`].
///
/// That flag is what stops the looking-again from being endless: a
/// poll that started a fresh check would find one outstanding every
/// time, forever.
recheck: Option<bool>,
}
async fn manifest(
State(state): State<Arc<AppState>>,
Query(query): Query<ManifestQuery>,
) -> Result<Json<ManifestResponse>, ApiError> {
if query.recheck.unwrap_or(true) {
refresh_checkouts(&state, &state.entries());
}
let mut apps = Vec::new();
for entry in state.entries() {
apps.push(describe(&state, &entry).await?);
}
Ok(Json(ManifestResponse {
// Derived from the apps rather than tracked alongside them, so the
// list-level flag and the per-card ones cannot disagree about
// whether anything is still outstanding.
//
// Services count as well as remotes. This is what the phone looks
// again on, and a service whose answer lands after the phone has
// stopped looking leaves that component with no state -- which is
// drawn as a row with no buttons at all, since which buttons to
// offer is exactly what the answer decides. Remote checks
// ordinarily outlast service ones and hid this; a list where
// nothing can be pulled has no remote check to hide behind.
checks_pending: apps.iter().any(|app| {
app.check_pending
|| app
.components
.iter()
.any(|c| c.checking || c.resources_checking)
}),
apps,
repo_roots: state
.repo_roots()
.iter()
.map(|root| crate::config::contract_tilde(root))
.collect(),
}))
}
/// One app, for a phone that acted on one card and wants only that card
/// back.
///
/// Never rechecks. Asking a remote is what `/apps/{key}/recheck` is for,
/// and a card that just had something done to it is asking what happened,
/// not for its checkout to be looked at again.
async fn app(
State(state): State<Arc<AppState>>,
UrlPath(key): UrlPath<String>,
) -> Result<Json<ManifestApp>, ApiError> {
let entry = state.entry(&key).ok_or(ApiError::UnknownApp(key))?;
Ok(Json(describe(&state, &entry).await?))
}
/// One entry as the phone needs it. The only place a card is built, so the
/// whole list and a single card cannot come to describe the same app
/// differently.
async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp, ApiError> {
// A loop rather than a map because each component's APK is a `stat`,
// and they are described in declaration order -- which is build order,
// and the order the card draws them in.
let mut components = Vec::with_capacity(entry.components.len());
for component in &entry.components {
components.push(ManifestComponent::read(state, &entry.key, entry, component).await?);
}
let git = crate::git::status(&entry.project_path);
// An upstream is part of it: a branch that tracks nothing has nothing
// to be pulled from, so `pull` refuses and asking a remote about it is
// asking about a remote it doesn't have. Without this the card said so
// twice -- "no upstream" on the branch line, and the check's own
// complaint about the same thing underneath it.
let can_pull = entry.git_pull && git.as_ref().is_some_and(|git| git.upstream.is_some());
let pending = entry.pending_declaration();
Ok(ManifestApp {
// Read from the cache a refresh populated, rather than asking here.
new_commits: can_pull && state.remote_checks.new_commits(&entry.project_path),
// Read after `new_commits`, so a check that lands between the
// two reports its answer rather than that it is still coming.
check_pending: can_pull && state.remote_checks.is_checking(&entry.project_path),
built: components
.iter()
.any(|component| component.apk.as_ref().is_some_and(|apk| apk.built)),
components,
check_error: can_pull
.then(|| state.remote_checks.error(&entry.project_path))
.flatten(),
can_pull,
git,
route: format!("/apps/{}/apk", entry.key),
key: entry.key.clone(),
label: entry.label.clone(),
// Shown on a phone, where the home prefix is the least
// interesting part of a long path. expand_tilde accepts this
// form back, so it stays copy-pasteable into "add by path".
project_path: crate::config::contract_tilde(&entry.project_path),
// False while something is waiting to be accepted: the command
// won't run until it is, so offering to build would be a
// button that does nothing.
needs_build: pending.is_none()
&& entry
.build
.as_ref()
.is_some_and(|build| build.has_command()),
git_ipv4: entry.git_ipv4,
built_in: entry.built_in,
pending_declaration: pending
.as_ref()
.and_then(|components| crate::config::render_request(components).ok()),
})
}
/// Starts the off-request checks for `entries`: the remote of every app
/// that offers a Pull button, and every server component's script.
///
/// Returns at once -- the answers land in the background and each app's
/// `checkPending`, or each component's `checking`, says whose is still
/// outstanding. Never waits: the manifest is about which apps have builds
/// and whether this phone has them, all of it local and fast, and making
/// it wait on a round trip to a git host meant every reopen of the app
/// stalled behind one.
///
/// Takes the entries rather than reading them from `state`, so that one
/// card's Refresh and the whole list's are the same code with a different
/// slice -- there is no second path that could drift from this one, or
/// start a check the other doesn't.
pub(crate) fn refresh_checkouts(state: &Arc<AppState>, entries: &[Arc<AppEntry>]) {
let projects: Vec<crate::git::Checkout> = entries
.iter()
.filter(|entry| entry.git_pull)
.map(|entry| crate::git::Checkout {
path: entry.project_path.clone(),
ipv4: entry.git_ipv4,
})
.collect();
state.remote_checks.refresh(&projects);
// Same trip, same reasoning: asking a service manager costs a process
// spawn, so it happens here off the request rather than while the
// manifest is being built.
let services: Vec<crate::service::Target> = entries
.iter()
.flat_map(|entry| entry.service_targets())
.collect();
state.service_checks.refresh(services);
// And the same for what each project says about itself: a `Script`
// resources declaration is a process spawn, which is the thing this
// path exists to keep out of the manifest.
let resources: Vec<crate::resources::Target> = entries
.iter()
.filter_map(|entry| entry.resource_target())
.collect();
state.resource_checks.refresh(resources);
}
/// Re-check one project, for a card's own Refresh.
///
/// Answers as soon as the checks are *started*, like the manifest's own
/// refresh does -- they run off the request path, and the phone collects
/// what they found through a later `/manifest`.
async fn recheck_app(
State(state): State<Arc<AppState>>,
UrlPath(key): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
let entries = state.entries();
let entry = entries
.iter()
.find(|entry| entry.key == key)
.ok_or(ApiError::UnknownApp(key))?;
refresh_checkouts(&state, std::slice::from_ref(entry));
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SuggestionsResponse {
roots: Vec<String>,
suggestions: Vec<Suggestion>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Suggestion {
path: String,
name: String,
apk_count: usize,
mtime: f64,
/// True when this project is already in the app list, so the add screen
/// can show it as such instead of offering a duplicate.
added: bool,
}
/// Scans the configured roots fresh on every call rather than caching:
/// it's a few milliseconds (see `crate::discover`), and a cache would be
/// wrong precisely when it matters -- right after a first build of the
/// project someone is trying to add.
async fn suggestions(
State(state): State<Arc<AppState>>,
) -> Result<Json<SuggestionsResponse>, ApiError> {
let roots = state.repo_roots();
let added: Vec<PathBuf> = state
.entries()
.iter()
.map(|entry| entry.project_path.clone())
.collect();
let scanned = roots.clone();
let found = tokio::task::spawn_blocking(move || discover::scan_roots(&scanned))
.await
.context("suggestion scan panicked")?;
Ok(Json(SuggestionsResponse {
roots: roots
.iter()
.map(|root| crate::config::contract_tilde(root))
.collect(),
suggestions: found
.into_iter()
.map(|item| Suggestion {
added: added.contains(&item.path),
path: crate::config::contract_tilde(&item.path),
name: item.name,
apk_count: item.apk_count,
mtime: epoch_secs(item.newest),
})
.collect(),
}))
}
#[derive(Deserialize)]
struct PathBody {
path: String,
}
#[derive(Serialize)]
struct AddedResponse {
key: String,
label: String,
}
/// Every config mutation runs here rather than inline: each one writes the
/// config file, and adding or re-pointing an app also spawns `aapt2`, none
/// of which belongs on an async worker thread.
async fn mutate<T: Send + 'static>(
state: Arc<AppState>,
action: impl FnOnce(&AppState) -> anyhow::Result<T> + Send + 'static,
) -> Result<T, ApiError> {
tokio::task::spawn_blocking(move || action(&state))
.await
.context("config mutation panicked")?
.map_err(bad_request)
}
async fn add_app(
State(state): State<Arc<AppState>>,
Json(body): Json<PathBody>,
) -> Result<Json<AddedResponse>, ApiError> {
let path = crate::config::expand_tilde(&body.path);
let entry = mutate(state, move |state| state.add_app(&path)).await?;
tracing::info!(
"added {} ({}) from {}",
entry.key,
entry
.apk_component(None)
.and_then(crate::config::Component::package)
.unwrap_or("package not read yet"),
entry.project_path.display()
);
Ok(Json(AddedResponse {
key: entry.key.clone(),
label: entry.label.clone(),
}))
}
async fn remove_app(
State(state): State<Arc<AppState>>,
UrlPath(key): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
mutate(state, move |state| {
state.remove_app(&key)?;
tracing::info!("removed {key}");
Ok(())
})
.await?;
Ok(StatusCode::NO_CONTENT)
}
/// The actions a server component's script is driven through.
///
/// A closed set, checked here rather than passed through: the subcommand
/// becomes an argument to a script running on the build machine, so the
/// phone naming one of its own is exactly what must not be possible.
const ACTIONS: [&str; 5] = ["install", "uninstall", "start", "stop", "restart"];
/// Runs one of them, and reports what the component is doing afterwards.
///
/// Synchronous, unlike a build: these are quick, and the answer the phone
/// wants is the state that resulted -- which is worth having in the reply
/// rather than after the next background check.
async fn service_action(
State(state): State<Arc<AppState>>,
UrlPath((key, name, action)): UrlPath<(String, String, String)>,
Query(purge): Query<PurgeQuery>,
) -> Result<Json<ServiceActionResponse>, ApiError> {
let entry = state
.entry(&key)
.ok_or_else(|| ApiError::UnknownApp(key.clone()))?;
if !ACTIONS.contains(&action.as_str()) {
return Err(ApiError::BadRequest(format!(
"{action} is not something a service can be asked to do"
)));
}
// Nothing runs for a project whose declaration is still waiting to be
// read: `service` is a command from that same file, and the gate is
// about commands rather than about which field they sit in.
if entry.pending_declaration().is_some() {
return Err(ApiError::BadRequest(format!(
"{}'s build step hasn't been accepted yet",
entry.label
)));
}
let component = entry
.component(&name)
.ok_or_else(|| ApiError::BadRequest(format!("{} has no component {name}", entry.label)))?;
let script = crate::service::driver(&key, component)
.ok_or_else(|| ApiError::BadRequest(format!("{name} is not a server")))?;
let project = entry.project_path.clone();
let cwd = component.cwd().map(std::path::Path::to_path_buf);
let wanted = crate::purge::Wanted {
logs: purge.logs,
data: purge.data,
config: purge.config,
};
if !wanted.nothing() && action != "uninstall" {
return Err(ApiError::BadRequest(format!(
"{action} removes nothing; only uninstall does"
)));
}
// From the same answer the dialog was drawn from, so what is removed
// is what was shown. A project whose resources could not be read has
// no paths, and the two toggles that need them are not offered.
let purging = (!wanted.nothing())
.then(|| state.resource_checks.facts(&key))
.flatten()
.map(|facts| crate::purge::paths(&facts, &entry.project_path))
// Logs are this server's own files and need nothing from the
// project, so a project that says nothing can still have them
// removed.
.or_else(|| (!wanted.nothing()).then(crate::purge::StatePaths::default));
// Restarting *this* server is deferred rather than run inline: the
// script would kill this process before it could reply, so a restart
// that worked would reach the phone as a failed request. Answered
// first, restarted after -- the same order, and by way of the same
// function, the build path uses. Which of the two restarts it turns
// into is `restart::deferred`'s to decide, so the button and a
// self-build cannot come to behave differently.
//
// The state reported is still the script's: whether a service manager
// has this server is its question, not this process's.
if entry.built_in && action == "restart" {
let asked = (script.clone(), project.clone(), cwd.clone());
let resulting = tokio::task::spawn_blocking(move || {
let (script, project, cwd) = asked;
crate::service::status(&script, &project, cwd.as_deref())
})
.await
.context("service status task")?
.map_err(ApiError::BadRequest)?;
state.service_checks.mark(&key, &name, resulting);
crate::restart::deferred(
Some(crate::restart::Handover {
script,
project,
cwd,
}),
Arc::clone(&state.downloads),
);
return Ok(Json(ServiceActionResponse {
state: resulting,
left_behind: Vec::new(),
}));
}
let asked = (key.clone(), name.clone());
let ran = tokio::task::spawn_blocking(move || {
let (key, name) = asked;
// Asked before the uninstall, because the script that reports
// where a service's log lives is the thing about to be removed.
// The build log is this server's own file and would survive
// either way, but both kinds are "the logs" to whoever ticked the
// box, so they are collected as one list.
let log_files = purging
.as_ref()
.map(|_| {
let mut files = crate::logs::build_logs(&key, &name);
files.extend(crate::service::logs(&script, &project, cwd.as_deref()));
files
})
.unwrap_or_default();
crate::service::run(&script, &project, cwd.as_deref(), &action)?;
let left_behind = match &purging {
Some(paths) => crate::purge::remove(paths, &log_files, wanted),
None => Vec::new(),
};
crate::service::status(&script, &project, cwd.as_deref())
.map(|resulting| (resulting, left_behind))
})
.await
.context("service action task")?;
match ran {
Ok((resulting, left_behind)) => {
state.service_checks.mark(&key, &name, resulting);
for problem in &left_behind {
tracing::warn!("uninstalling {key}'s {name}: {problem}");
}
Ok(Json(ServiceActionResponse {
state: resulting,
left_behind,
}))
}
// The script's own words, which is what the card shows.
Err(message) => Err(ApiError::BadRequest(message)),
}
}
/// Which log, and how much of it.
#[derive(Deserialize)]
struct LogQuery {
/// Lines from the end. Absent is 100; zero means as much as the
/// server is willing to read, which it says when it stops.
lines: Option<usize>,
/// 0 is the current log, 1 the one before it. An index rather than a
/// "previous" flag, because the script reports a list and the phone
/// should not have to assume how long it is. Counted within a kind:
/// the build log's previous generation is not the runtime log's.
generation: Option<usize>,
/// Which log. Absent means the build log, which is what the one
/// combined list used to answer with at generation 0 -- so a phone
/// built before this existed keeps getting exactly what it got
/// before rather than something new.
kind: Option<crate::logs::LogKind>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LogResponse {
/// Where this came from, so a person at the build machine can open
/// the whole thing rather than the tail.
path: String,
text: String,
/// There is more than what is here. Said rather than implied: a
/// silently shortened log reads as a complete one that simply does
/// not explain the crash.
truncated: bool,
/// How many generations exist, so the phone knows whether to offer a
/// previous one at all.
generations: usize,
}
/// The tail of one component's log.
///
/// Reading is this server's job rather than the script's: the script says
/// where, and how much of it a phone wants is a question the phone
/// answers. It is also why this is a GET -- nothing here changes anything.
async fn component_logs(
State(state): State<Arc<AppState>>,
UrlPath((key, name)): UrlPath<(String, String)>,
Query(query): Query<LogQuery>,
) -> Result<Json<LogResponse>, ApiError> {
let entry = state
.entry(&key)
.ok_or_else(|| ApiError::UnknownApp(key.clone()))?;
let paths = tokio::task::spawn_blocking({
let entry = Arc::clone(&entry);
let name = name.clone();
let kind = query.kind.unwrap_or(crate::logs::LogKind::Build);
move || entry.component_logs(&name, kind)
})
.await
.context("asking for log paths")?;
let generation = query.generation.unwrap_or(0);
let path = paths.get(generation).ok_or_else(|| {
ApiError::BadRequest(format!(
"{}'s {name} has no {} log to show yet",
entry.label,
match query.kind.unwrap_or(crate::logs::LogKind::Build) {
crate::logs::LogKind::Build => "build",
crate::logs::LogKind::Runtime => "runtime",
}
))
})?;
let lines = query.lines.unwrap_or(100);
let read = {
let path = path.clone();
tokio::task::spawn_blocking(move || crate::logs::tail(&path, lines))
.await
.context("reading the log")?
};
let tail = read.map_err(bad_request)?;
Ok(Json(LogResponse {
path: crate::config::contract_tilde(path),
text: tail.text,
truncated: tail.truncated,
generations: paths.len(),
}))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ServiceActionResponse {
state: crate::service::ServiceState,
/// What Uninstall was asked to take away and could not, one line
/// each, empty on every other action.
///
/// Not an error: the service itself is gone by then, so failing the
/// request would report a successful uninstall as a failure. The card
/// says what is still on disk instead, which is the thing the person
/// would otherwise have to go and find.
#[serde(skip_serializing_if = "Vec::is_empty")]
left_behind: Vec<String>,
}
/// What Uninstall should take away besides the service.
///
/// Each defaults to false, so a phone that says nothing gets what
/// uninstalling has always done. Only `uninstall` accepts them, and
/// naming them on anything else is refused rather than ignored -- a flag
/// that silently does nothing is how somebody comes to believe they
/// cleaned up.
#[derive(Deserialize, Default)]
struct PurgeQuery {
#[serde(default)]
logs: bool,
#[serde(default)]
data: bool,
#[serde(default)]
config: bool,
}
/// Whether a pull may throw this checkout's own history away.
///
/// Off unless the phone says otherwise, and it only says so after being
/// told the checkout and its upstream are unrelated -- there is no
/// fast-forward for that ever, so the alternative to this is a card that
/// can never be pulled again. Destructive, and confirmed on the phone
/// against the branch it names before it is ever sent.
#[derive(Deserialize)]
struct PullQuery {
#[serde(default)]
force: bool,
}
/// Which build a download wants, and whose.
///
/// `component` names the APK on a project that produces more than one;
/// absent is the only APK, which is every project with one and the frozen
/// `/self/apk`, which cannot say a component. A project with two and a
/// request that names neither is refused rather than served the first --
/// see [`ApiError::AmbiguousApk`].
///
/// `variant` absent means the newest, which is what every phone gets until
/// it says otherwise.
#[derive(Deserialize)]
struct VariantQuery {
component: Option<String>,
variant: Option<String>,
}
#[derive(Deserialize)]
struct RootsBody {
roots: Vec<String>,
}
/// Accepts the build step an app's project asks for. Until this is
/// pressed, that app runs nothing -- see `crate::config::ProjectConfig`
/// for why a project can't simply hand this server a command to run.
async fn approve_declaration(
State(state): State<Arc<AppState>>,
UrlPath(key): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
mutate(state, move |state| {
state.approve_declaration(&key)?;
tracing::info!("accepted the build step {key} asks for");
Ok(())
})
.await?;
Ok(StatusCode::NO_CONTENT)
}
/// What the card's settings can change. One field so far; the shape is a
/// body rather than a query so the next one is a field here rather than a
/// second route.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SettingsBody {
git_ipv4: bool,
}
/// Replaces this machine's preferences for one project.
///
/// A whole-object PUT rather than a patch per field: the phone has just
/// shown the person every setting there is, so what it sends back is the
/// complete answer, and a partial update would need a rule for what a
/// missing field means.
async fn set_settings(
State(state): State<Arc<AppState>>,
UrlPath(key): UrlPath<String>,
Json(body): Json<SettingsBody>,
) -> Result<StatusCode, ApiError> {
mutate(state, move |state| {
state.set_project_settings(&key, body.git_ipv4)?;
Ok(())
})
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn set_roots(
State(state): State<Arc<AppState>>,
Json(body): Json<RootsBody>,
) -> Result<StatusCode, ApiError> {
// Blank entries are what an emptied text field sends; dropping them
// here keeps a stray "" out of the config and out of every later scan.
let roots = body
.roots
.iter()
.map(|root| root.trim())
.filter(|root| !root.is_empty())
.map(crate::config::expand_tilde)
.collect();
mutate(state, move |state| state.set_repo_roots(roots)).await?;
Ok(StatusCode::NO_CONTENT)
}
/// Resolves the app a request is about: the `{key}` in the path, or the
/// self entry on the bootstrap listener, which has no key in its path.
fn lookup(state: &AppState, key: Option<UrlPath<String>>) -> Result<Arc<AppEntry>, ApiError> {
let key = key
.map(|UrlPath(key)| key)
.unwrap_or_else(|| crate::registry::SELF_KEY.to_string());
state.entry(&key).ok_or(ApiError::UnknownApp(key))
}
/// Which component a `/prepare` or `/build` request is about, when it is
/// about one rather than the whole project.
///
/// Absent means every component with a build command, which is what both
/// routes have always done and what a project with one component (still
/// nearly all of them) never has reason to change. A project with more
/// than one -- two independent Android clients sharing a checkout, say --
/// otherwise pays for every component's build to get one of them: this is
/// how the phone opts out of that, by naming the one it actually wants
/// before it is willing to wait on the others too.
#[derive(Deserialize)]
struct ComponentQuery {
component: Option<String>,
}
/// Resolves a `?component=` against the project's own components, or says
/// why it could not -- the one place both build routes turn a name from
/// the phone into something [`crate::build_state::BuildState`] accepts, so
/// they cannot disagree about what an unknown name means.
fn named_component<'a>(
entry: &'a AppEntry,
named: Option<&'a str>,
) -> Result<Option<&'a str>, ApiError> {
match named {
Some(name)
if entry
.components
.iter()
.any(|component| component.name() == name) =>
{
Ok(Some(name))
}
Some(name) => Err(ApiError::UnknownComponent(
entry.label.clone(),
name.to_string(),
)),
None => Ok(None),
}
}
/// The callback a build reports each finished component through.
///
/// Made here because this is where both halves are in scope: the app list
/// to write to, and the key of the app being built. The `Arc` it captures
/// is held only for the life of the build task, so nothing in the list
/// ends up pointing back at the list.
fn records_builds(state: &Arc<AppState>, key: &str) -> crate::build_state::RecordBuilt {
let state = Arc::clone(state);
let key = key.to_string();
Arc::new(move |component: &str, sha: String| state.record_built(&key, component, sha))
}
async fn build_prepare(
State(state): State<Arc<AppState>>,
key: UrlPath<String>,
Query(query): Query<ComponentQuery>,
) -> Result<Json<BuildStatus>, ApiError> {
let entry = lookup(&state, Some(key))?;
// Checked here rather than trusted from the entry's build state: the
// project's file can have changed since that state was made, and this
// is the moment its command would actually run.
if entry.pending_declaration().is_some() {
return Err(ApiError::BadRequest(format!(
"{} is asking to run a build step that hasn't been accepted yet -- read it on the \
app's card and accept it first",
entry.label,
)));
}
let component = named_component(&entry, query.component.as_deref())?;
let build = entry
.build
.as_ref()
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
build.trigger_if_needed(component, records_builds(&state, &entry.key));
Ok(Json(build.status()))
}
/// Builds because somebody asked, not because anything looked stale.
///
/// Separate from `prepare` rather than a flag on it: `prepare` runs before
/// a download and must stay cheap when there is nothing to do, while this
/// is a person pressing a button and should do what it says every time.
///
/// Gated on acceptance like every other path that runs a project's
/// command -- the button is not an authorisation.
async fn build_now(
State(state): State<Arc<AppState>>,
key: UrlPath<String>,
Query(query): Query<ComponentQuery>,
) -> Result<Json<BuildStatus>, ApiError> {
let entry = lookup(&state, Some(key))?;
if entry.pending_declaration().is_some() {
return Err(ApiError::BadRequest(format!(
"{} is asking to run a build step that hasn't been accepted yet -- read it on the \
app's card and accept it first",
entry.label,
)));
}
let component = named_component(&entry, query.component.as_deref())?;
let build = entry
.build
.as_ref()
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
build.build_now(component, records_builds(&state, &entry.key));
Ok(Json(build.status()))
}
/// Fetches, fast-forwards, and builds -- reported through the same status
/// the phone already polls for a build, so one progress path covers both.
///
/// Pulling stays available while a project's build step is waiting to be
/// accepted, and stops after the fast-forward: taking commits runs git,
/// not the project's command, and refusing would leave a project whose
/// request you haven't accepted impossible to update at all. It is also
/// how the newest version of that request arrives to be read.
async fn build_pull(
State(state): State<Arc<AppState>>,
key: UrlPath<String>,
Query(query): Query<PullQuery>,
) -> Result<Json<BuildStatus>, ApiError> {
let entry = lookup(&state, Some(key))?;
let build = entry
.build
.as_ref()
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
// Asked after the fast-forward, not now: the declaration the gate
// reads is the one the pull is about to rewrite, so the answer from
// here is the answer for the commit being replaced.
let gate = Arc::clone(&entry);
build.pull_and_build(
query.force,
move || gate.pending_declaration().is_none(),
records_builds(&state, &entry.key),
);
Ok(Json(build.status()))
}
async fn build_status(
State(state): State<Arc<AppState>>,
key: UrlPath<String>,
) -> Result<Json<BuildStatus>, ApiError> {
let entry = lookup(&state, Some(key))?;
let build = entry
.build
.as_ref()
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
Ok(Json(build.status()))
}
/// A reader that keeps its download counted for as long as it is being
/// read.
///
/// The count has to end when the *body* does, not when the handler
/// returns -- the handler returns as soon as the stream is handed to
/// axum, with the bytes still to go. Wrapping the reader means the guard
/// is dropped exactly when the body is finished or the client goes away,
/// which is when this server has genuinely stopped sending.
///
/// An `AsyncRead` wrapper rather than a `Stream` one so this needs no
/// dependency that is not already here.
struct Counted<R> {
inner: R,
_guard: crate::restart::DownloadGuard,
}
impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for Counted<R> {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
struct ByteRange {
start: u64,
end: u64,
is_partial: bool,
}
/// Parses a `Range: bytes=...` header, clamped to `size`. Anything that
/// isn't a well-formed single byte-range -- including a missing header --
/// falls back to the full file; only a range that's present, well-formed,
/// and unsatisfiable (`start > end`) is an error.
fn parse_range(header: Option<&HeaderValue>, size: u64) -> Result<ByteRange, ApiError> {
let full = ByteRange {
start: 0,
end: size.saturating_sub(1),
is_partial: false,
};
let Some(spec) = header
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("bytes="))
else {
return Ok(full);
};
let Some((start_s, end_s)) = spec.split_once('-') else {
return Ok(full);
};
if start_s.is_empty() && end_s.is_empty() {
return Ok(full);
}
let (start, end) = if !start_s.is_empty() {
let Ok(start) = start_s.parse::<u64>() else {
return Ok(full);
};
let end = if end_s.is_empty() {
size.saturating_sub(1)
} else {
match end_s.parse::<u64>() {
Ok(end) => end,
Err(_) => return Ok(full),
}
};
(start, end)
} else {
// Suffix range: last N bytes.
let Ok(suffix) = end_s.parse::<u64>() else {
return Ok(full);
};
(size.saturating_sub(suffix), size.saturating_sub(1))
};
let end = end.min(size.saturating_sub(1));
if start > end {
return Err(ApiError::RangeNotSatisfiable);
}
Ok(ByteRange {
start,
end,
is_partial: true,
})
}
/// What the app needs to know about the newer copy of itself, and
/// nothing else. **This shape is frozen** -- see the module note.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SelfBuild {
/// When the APK on disk was built, in seconds since the epoch. The
/// phone compares it against its own `PackageInfo.lastUpdateTime`,
/// which is the same freshness rule the list uses: these are ad hoc
/// rebuilds with nothing bumping a version code.
mtime: f64,
/// Bytes, so a download can show progress rather than a spinner.
size: u64,
}
/// Is there a build of the updater app here, and how new is it.
///
/// Deliberately does not go through `describe`: that is where a card is
/// built, and a card is exactly the thing whose shape changes. This reads
/// the file and answers two numbers.
async fn self_build(State(state): State<Arc<AppState>>) -> Result<Json<SelfBuild>, ApiError> {
let entry = lookup(&state, None)?;
// No component named, because this route is frozen and cannot carry
// one -- and this server's own project produces exactly one APK,
// which is what makes that an answer rather than a guess.
let component = apk_component(&entry, None)?;
let apk = entry
.resolve_apk(component, None)
.ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?;
let size = tokio::fs::metadata(&apk.path)
.await
.context("stat the updater's own apk")?
.len();
Ok(Json(SelfBuild {
mtime: epoch_secs(apk.modified),
size,
}))
}
/// The APK component a request is about, or why there isn't one.
///
/// The single place a name from a phone becomes a component, so the
/// download and anything that follows it cannot disagree about which APK
/// was meant.
fn apk_component<'a>(
entry: &'a AppEntry,
named: Option<&str>,
) -> Result<&'a crate::config::Component, ApiError> {
if let Some(component) = entry.apk_component(named) {
return Ok(component);
}
Err(match named {
Some(name) => ApiError::UnknownComponent(entry.label.clone(), name.to_string()),
// Nothing named, and not one obvious answer: either the project
// builds no APK at all, or it builds several and the request has
// to say which.
None if entry.apk_components().next().is_none() => ApiError::NotBuilt(entry.label.clone()),
None => ApiError::AmbiguousApk(entry.label.clone()),
})
}
async fn serve_apk(
State(state): State<Arc<AppState>>,
key: Option<UrlPath<String>>,
method: Method,
headers: HeaderMap,
Query(query): Query<VariantQuery>,
) -> Result<Response, ApiError> {
let entry = lookup(&state, key)?;
let component = apk_component(&entry, query.component.as_deref())?;
// Which build this device wants, if it has a preference. Its own
// preference, travelling with the request: two phones enrolled against
// one server must not change what the other gets. Validated against
// *this component's* builds, so naming another one's path is a
// fallback to this one's newest rather than a way to be served it.
let requested = query.variant.map(PathBuf::from);
let apk = entry
.resolve_apk(component, requested.as_deref())
.ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?;
// A fresh download, not a range continuation: the one moment this
// server can notice that a local rebuild changed what the APK installs
// over. Off the request, so it costs the download nothing.
if headers.get(header::RANGE).is_none() {
state.refresh_package(&entry.key, component.name(), apk.path.clone());
}
let resolved = resolve_serveable_path(&apk.path, component.strip()).await?;
let size = tokio::fs::metadata(&resolved)
.await
.context("stat resolved apk")?
.len();
let range = parse_range(headers.get(header::RANGE), size)?;
let length = range.end - range.start + 1;
let mut response = Response::builder()
.status(if range.is_partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
})
.header(
header::CONTENT_TYPE,
"application/vnd.android.package-archive",
)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", entry.filename(&apk.path)),
)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, length);
if range.is_partial {
response = response.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{size}", range.start, range.end),
);
}
if method == Method::HEAD {
return Ok(response
.body(Body::empty())
.expect("response with an empty body is valid"));
}
let mut file = tokio::fs::File::open(&resolved)
.await
.context("open resolved apk")?;
file.seek(std::io::SeekFrom::Start(range.start))
.await
.context("seek resolved apk")?;
// Counted from here rather than at the top of the handler: a HEAD, a
// 404 or an unsatisfiable range sends no bytes and must not hold a
// restart back.
let stream = ReaderStream::new(Counted {
inner: file.take(length),
_guard: state.downloads.start(),
});
Ok(response
.body(Body::from_stream(stream))
.expect("response with a streaming body is valid"))
}