A project can produce more than one APK

tdep-survey is one checkout with a backend and two independent Android
clients, so a project has to be able to declare two Apk components. It
could not: APK_PATTERNS was anchored at the project root, which reached
the first client and stopped, and package, strip, the variant list and
the download all came from AppEntry::apk_component()'s first match --
correct only while a project produced one APK.

Each component's builds are now found under its own cwd, which is what
cwd already meant everywhere else: the directory the build command runs
in, the subtree staleness is scoped to, a server's WorkingDirectory. A
component that declares none sits at the project root, so nothing about
the single-APK case changes. The alternative -- naming the file on the
component -- would have made a component a file path, and an app being a
project path rather than a file path is this project's central invariant.

Everything derived from an APK follows it onto the component: package,
previousPackage, strip, size, mtime, variants and the rename note, in a
nested `apk` block that a Server simply doesn't have. Two clients install
over different packages, so first-wins would have checked the installed
state of one and reported it as the other's -- which looks exactly like a
correct answer. For the same reason a download naming no component is
refused rather than guessed; naming none still answers for a project with
one, which is what lets the frozen /self/apk keep working.

On the phone the per-device state is keyed by project and component, so
the variant picker moved inside the component's own card, beside the
build it picks, and the installed state, size and icon are each their own
component's. A project building two clients shows no single icon of its
own rather than borrowing the first one's.

Measured against the real thing: pointed at tdep-survey, the two clients
resolve to their own builds (15 MB and 153 MB), strip applies only to the
one that asked for it (153 MB served as 52 MB), pressing Install on one
row installed that row's package and left the other row offering Install,
and a download with no component named answers 400 saying which flag to
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 23:00:32 -04:00
1 parent db47972a25
commit c4e19c2e2b
9 files changed
+760 -309

No files matched your search

+7
View File
@@ -310,6 +310,13 @@ impl Component {
}
}
/// Whether to serve a stripped copy of this component's build. Only
/// an `Apk` has anything to strip, so a `Server` is always false
/// rather than this being an option only one variant carries.
pub fn strip(&self) -> bool {
matches!(self, Self::Apk { strip: true, .. })
}
pub fn cwd(&self) -> Option<&Path> {
match self {
Self::Apk { cwd, .. } | Self::Server { cwd, .. } => cwd.as_deref(),
+27 -11
View File
@@ -333,10 +333,15 @@ async fn main() -> Result<()> {
// out from a bare 404 in a phone browser -- which is where this
// flag is used and where there is least to go on.
let self_entry = state.entry(registry::SELF_KEY);
match self_entry
.as_ref()
.and_then(|entry| entry.resolve_apk(None))
{
// Named as no component, because this listener is half of the
// frozen rescue contract and cannot say one -- and this server's
// own project produces a single APK, which is what makes that
// safe. Two would answer `None` here rather than pick.
match self_entry.as_ref().and_then(|entry| {
entry
.apk_component(None)
.and_then(|component| entry.resolve_apk(component, None))
}) {
// The age is here because this listener serves the file already
// on disk and builds nothing, so an old APK installs in silence.
// That one is unusually expensive to land on: a fresh install is
@@ -386,14 +391,25 @@ async fn main() -> Result<()> {
tracing::info!("scanning for projects under {}", root.display());
}
}
// One line per APK, not per project: a project producing two clients
// has two answers here, and folding them into one would hide exactly
// the case where a component's builds are not where somebody expected.
for entry in state.entries() {
match entry.resolve_apk(None) {
Some(apk) => tracing::info!(" {} -> {}", entry.key, apk.path.display()),
None => tracing::warn!(
" {} -> no build found under {} (it will show as not built)",
entry.key,
entry.project_path.display(),
),
for component in entry.apk_components() {
match entry.resolve_apk(component, None) {
Some(apk) => tracing::info!(
" {}/{} -> {}",
entry.key,
component.name(),
apk.path.display()
),
None => tracing::warn!(
" {}/{} -> no build found under {} (it will show as not built)",
entry.key,
component.name(),
entry.component_dir(component).display(),
),
}
}
}
+218 -61
View File
@@ -61,30 +61,60 @@ pub struct AppEntry {
}
impl AppEntry {
/// The APK component this entry serves, if it has one.
///
/// One for now, and the first wins if a project ever declares two: the
/// download route serves a project, and which of two APKs it meant
/// would need saying. That is a question for whoever adds the second.
fn apk_component(&self) -> Option<&Component> {
/// Every APK this project produces, in declaration order.
pub fn apk_components(&self) -> impl Iterator<Item = &Component> {
self.components
.iter()
.find(|component| matches!(component, Component::Apk { .. }))
.filter(|component| matches!(component, Component::Apk { .. }))
}
/// The package this project's APK installs over, once a build has been
/// read. `None` until there has been one -- a project can be added
/// before it has ever been built.
pub fn package(&self) -> Option<&str> {
self.apk_component().and_then(Component::package)
/// The APK component a request means: the one it names, or the only
/// one when it names none.
///
/// Deliberately not first-wins for a project with two. Two APKs differ
/// in the package they install over and in whether their symbols are
/// worth carrying to a phone, so picking one for a request that didn't
/// say would answer a question nobody asked -- and it would look
/// exactly like a correct answer, which is the expensive kind of
/// wrong. `None` is the caller's cue to say which.
///
/// Naming none stays right for the projects that produce one, which is
/// nearly all of them, and for the frozen `/self` contract: it cannot
/// carry a component name, and the project it describes has a single
/// APK.
pub fn apk_component(&self, named: Option<&str>) -> Option<&Component> {
match named {
Some(name) => self
.apk_components()
.find(|component| component.name() == name),
None => {
let mut components = self.apk_components();
let only = components.next()?;
components.next().is_none().then_some(only)
}
}
}
/// Whether to serve a stripped copy. Declared, not detected.
pub fn strip(&self) -> bool {
matches!(
self.apk_component(),
Some(Component::Apk { strip: true, .. })
)
/// Where one component's builds are.
///
/// Its own directory, which is what `cwd` already means everywhere
/// else: the directory its build command runs in, the subtree its
/// staleness and its recorded commit are scoped to, and a server's
/// working directory. A component that doesn't say one sits at the
/// project root, which is what a project with a single APK has always
/// meant -- so nothing about the one-APK case changes.
///
/// This is what lets one project produce two APKs: the patterns are
/// anchored per component rather than at the root, so the second
/// client's build is reachable and each component's builds are its
/// own.
pub fn component_dir(&self, component: &Component) -> PathBuf {
match component.cwd() {
// `join` on an absolute path yields that path, as everywhere
// else a cwd is resolved.
Some(cwd) => self.project_path.join(cwd),
None => self.project_path.clone(),
}
}
/// The APK to serve: `requested` if it is still one of this project's
@@ -101,8 +131,12 @@ impl AppEntry {
/// rather than failing -- a `./gradlew clean` shouldn't take an app out
/// of the list, and the fallback is the answer this would have given
/// before any variant was chosen.
pub fn resolve_apk(&self, requested: Option<&Path>) -> Option<ApkCandidate> {
let variants = self.variants();
pub fn resolve_apk(
&self,
component: &Component,
requested: Option<&Path>,
) -> Option<ApkCandidate> {
let variants = self.variants(component);
if let Some(requested) = requested
&& let Some(found) = variants
.iter()
@@ -115,8 +149,8 @@ impl AppEntry {
/// Every build found under this project, newest first -- what the app
/// offers when letting the user switch variants.
pub fn variants(&self) -> Vec<ApkCandidate> {
discover::find_apks(&self.project_path)
pub fn variants(&self, component: &Component) -> Vec<ApkCandidate> {
discover::find_apks(&self.component_dir(component))
}
/// The build step this project is asking for that nobody has accepted
@@ -223,25 +257,37 @@ impl AppEntry {
}
}
/// The packages already read for each component, by name -- what an
/// acceptance has to carry across.
fn measured_packages(components: &[Component]) -> HashMap<String, String> {
/// The packages already read for each component -- what an acceptance has
/// to carry across.
///
/// Keyed by name *and* directory, because a name alone stopped
/// identifying an APK once each component's builds came from its own
/// `cwd`: a declared component that happens to reuse a name while
/// pointing somewhere else is a different app, and handing it the package
/// read from the old one would make the card check the installed state of
/// something else -- silently, and looking like an answer, until that
/// component was downloaded once and re-read.
type ComponentId = (String, Option<PathBuf>);
fn component_id(component: &Component) -> ComponentId {
(
component.name().to_string(),
component.cwd().map(Path::to_path_buf),
)
}
fn measured_packages(components: &[Component]) -> HashMap<ComponentId, String> {
components
.iter()
.filter_map(|component| {
Some((
component.name().to_string(),
component.package()?.to_string(),
))
})
.filter_map(|component| Some((component_id(component), component.package()?.to_string())))
.collect()
}
/// Records a freshly read package on whichever component is the APK.
fn set_measured_package(components: &mut [Component], package: String) {
/// Records a freshly read package on the APK component that produced it.
fn set_measured_package(components: &mut [Component], name: &str, package: String) {
if let Some(component) = components
.iter_mut()
.find(|component| matches!(component, Component::Apk { .. }))
.find(|component| matches!(component, Component::Apk { .. }) && component.name() == name)
{
component.set_package(package);
}
@@ -291,7 +337,10 @@ pub struct AppState {
/// It needs no clearing protocol: the phone knows what is installed,
/// so it only shows the offer while the old package is actually there.
/// Dropped when the project is, so this can't outlive it.
previous_packages: Mutex<HashMap<String, String>>,
/// Keyed by project *and* component: a project with two clients can
/// rename either of them, and one card would otherwise offer to
/// remove the other's old package.
previous_packages: Mutex<HashMap<(String, String), String>>,
config_path: PathBuf,
registry: RwLock<Registry>,
}
@@ -336,10 +385,14 @@ impl AppState {
.cloned()
}
/// What this project's APK used to install over, if it has been
/// What one component's APK used to install over, if it has been
/// renamed since this server started. See [`Self::previous_packages`].
pub fn previous_package(&self, key: &str) -> Option<String> {
self.previous_packages.lock().unwrap().get(key).cloned()
pub fn previous_package(&self, key: &str, component: &str) -> Option<String> {
self.previous_packages
.lock()
.unwrap()
.get(&(key.to_string(), component.to_string()))
.cloned()
}
/// Re-reads what `apk` installs over and records it if it has changed.
@@ -350,9 +403,10 @@ impl AppState {
/// bytes are asked for. Runs off the request so the download is not
/// held up by a process spawn -- the answer is wanted by the *next*
/// manifest, not this one.
pub fn refresh_package(self: &Arc<Self>, key: &str, apk: PathBuf) {
pub fn refresh_package(self: &Arc<Self>, key: &str, component: &str, apk: PathBuf) {
let state = Arc::clone(self);
let key = key.to_string();
let component = component.to_string();
tokio::task::spawn_blocking(move || {
let Ok(info) = crate::apkinfo::read(&apk) else {
return;
@@ -360,12 +414,16 @@ impl AppState {
let Some(entry) = state.entry(&key) else {
return;
};
if entry.package() == Some(info.package.as_str()) {
let known = entry
.apk_component(Some(&component))
.and_then(Component::package)
.map(str::to_string);
if known.as_deref() == Some(info.package.as_str()) {
return;
}
if let Some(previous) = entry.package() {
if let Some(previous) = known {
tracing::info!(
"{} now installs {} rather than {previous}",
"{}/{component} now installs {} rather than {previous}",
entry.label,
info.package,
);
@@ -373,11 +431,11 @@ impl AppState {
.previous_packages
.lock()
.unwrap()
.insert(key.clone(), previous.to_string());
.insert((key.clone(), component.clone()), previous);
}
let update = state.update(|config| {
if let Some(project) = config.projects.iter_mut().find(|p| p.key == key) {
set_measured_package(&mut project.components, info.package);
set_measured_package(&mut project.components, &component, info.package);
}
Ok(())
});
@@ -547,7 +605,10 @@ impl AppState {
Ok(())
})?;
// Nothing left for either to be about.
self.previous_packages.lock().unwrap().remove(key);
self.previous_packages
.lock()
.unwrap()
.retain(|(project, _), _| project != key);
self.service_checks.forget(key);
self.resource_checks.forget(key);
Ok(())
@@ -616,7 +677,7 @@ impl AppState {
project.resources = declared.resources;
project.components = declared.components;
for component in &mut project.components {
if let Some(package) = measured.get(component.name()) {
if let Some(package) = measured.get(&component_id(component)) {
component.set_package(package.clone());
}
}
@@ -674,21 +735,28 @@ fn reconcile_self(config: &mut Config, self_project: &Path) -> bool {
} else {
Vec::new()
};
if !components
// Named rather than assumed: which component is the APK is the
// declaration's business, and only the fallback below gets to pick a
// name for it.
let apk_name = match components
.iter()
.any(|component| matches!(component, Component::Apk { .. }))
.find(|component| matches!(component, Component::Apk { .. }))
{
components.push(Component::Apk {
name: "app".to_string(),
build: crate::config::Command::default(),
cwd: None,
stale_when: None,
strip: false,
package: None,
built_from: None,
});
}
set_measured_package(&mut components, SELF_PACKAGE.to_string());
Some(component) => component.name().to_string(),
None => {
components.push(Component::Apk {
name: "app".to_string(),
build: crate::config::Command::default(),
cwd: None,
stale_when: None,
strip: false,
package: None,
built_from: None,
});
"app".to_string()
}
};
set_measured_package(&mut components, &apk_name, SELF_PACKAGE.to_string());
let existing = config
.projects
.iter_mut()
@@ -925,6 +993,89 @@ mod tests {
.expect("the configured app")
}
fn apk_named(name: &str, cwd: Option<&str>) -> Component {
Component::Apk {
name: name.to_string(),
build: crate::config::Command::default(),
cwd: cwd.map(PathBuf::from),
stale_when: None,
strip: false,
package: None,
built_from: None,
}
}
fn write_apk(dir: &Path, relative: &str) -> PathBuf {
let path = dir.join(relative);
std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
std::fs::write(&path, "not really an apk").expect("write");
path
}
/// A project with two clients: each component's builds are its own,
/// found under the directory the component already says it lives in.
///
/// The second APK is the case this exists for -- anchored at the
/// project root, the patterns reach the first client and stop, so the
/// second was invisible while the first looked like the project's
/// answer.
#[test]
fn each_apk_component_finds_its_own_builds() {
let dir = tempfile::tempdir().expect("tempdir");
let first = write_apk(
dir.path(),
"app/androidApp/build/outputs/apk/debug/androidApp-debug.apk",
);
let second = write_apk(
dir.path(),
"app-dioxus/target/dx/app-dioxus/debug/android/app/app/build/outputs/apk/debug/app-debug.apk",
);
let entry = entry_for(
dir.path(),
vec![
apk_named("app", Some("app")),
apk_named("app-dioxus", Some("app-dioxus")),
],
);
let paths = |name: &str| {
let component = entry.apk_component(Some(name)).expect("the component");
entry
.variants(component)
.into_iter()
.map(|candidate| candidate.path)
.collect::<Vec<_>>()
};
assert_eq!(paths("app"), vec![first]);
assert_eq!(paths("app-dioxus"), vec![second]);
}
/// Which of two APKs a request means is a question, not something to
/// answer with whichever was declared first -- two clients install
/// over different packages, so a guess reads as a correct answer while
/// putting the wrong app on the phone.
#[test]
fn naming_no_component_answers_only_for_a_project_with_one_apk() {
let dir = tempfile::tempdir().expect("tempdir");
let one = entry_for(dir.path(), vec![apk_named("app", None)]);
assert_eq!(one.apk_component(None).map(Component::name), Some("app"));
let two = entry_for(
dir.path(),
vec![
apk_named("app", Some("app")),
apk_named("app-dioxus", Some("app-dioxus")),
],
);
assert!(two.apk_component(None).is_none());
assert_eq!(
two.apk_component(Some("app-dioxus")).map(Component::name),
Some("app-dioxus")
);
assert!(two.apk_component(Some("nothing-by-that-name")).is_none());
}
/// The point of the whole mechanism: a project asking for a command
/// does not thereby get to run one.
#[test]
@@ -1091,7 +1242,10 @@ mod tests {
assert_eq!(entry.label, "Declared Name");
// Its own package is this server's, not something a file may
// claim -- it is the one entry that is this program.
assert_eq!(entry.package(), Some(SELF_PACKAGE));
assert_eq!(
entry.apk_component(None).and_then(Component::package),
Some(SELF_PACKAGE)
);
assert!(
entry
.build
@@ -1114,7 +1268,10 @@ mod tests {
let entry = self_entry(&app);
assert_eq!(entry.label, SELF_LABEL);
assert_eq!(entry.package(), Some(SELF_PACKAGE));
assert_eq!(
entry.apk_component(None).and_then(Component::package),
Some(SELF_PACKAGE)
);
assert!(entry.built_in);
// Nothing declares how to build it, so nothing is guessed.
assert!(entry.build.is_none());
+189 -82
View File
@@ -127,6 +127,14 @@ enum ApiError {
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}")]
@@ -140,7 +148,11 @@ enum ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
Self::UnknownApp(_) | Self::NotBuilt(_) | Self::NoBuildStep(_) => StatusCode::NOT_FOUND,
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) => {
@@ -162,6 +174,56 @@ 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.
@@ -246,15 +308,22 @@ struct ManifestComponent {
/// 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 {
fn read(
/// 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,
) -> Self {
) -> 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
@@ -271,7 +340,7 @@ impl ManifestComponent {
.then(|| state.resource_checks.facts(key))
.flatten()
.map(|facts| crate::purge::paths(&facts, &entry.project_path));
Self {
Ok(Self {
kind: if is_server { "server" } else { "apk" },
state: is_server
.then(|| state.service_checks.state(key, &name))
@@ -317,8 +386,56 @@ impl ManifestComponent {
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(),
})
}
}
@@ -341,44 +458,22 @@ struct ManifestResponse {
struct ManifestApp {
key: String,
label: String,
filename: 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,
/// 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 project's APK 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>,
project_path: String,
/// 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,
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,
/// False when the project has no APK yet (never built, or cleaned).
/// Such an app is still listed -- it was added deliberately, and a card
/// saying so is a better answer than one that silently vanished -- with
/// `mtime`/`size` at zero and nothing to download.
/// 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
@@ -422,7 +517,6 @@ struct ManifestApp {
/// something the keys don't say.
#[serde(skip_serializing_if = "Option::is_none")]
pending_declaration: Option<String>,
variants: Vec<ManifestVariant>,
}
/// One build found under a project. Which of them a device wants is that
@@ -514,27 +608,13 @@ async fn app(
/// 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> {
let apk = entry.resolve_apk(None);
// Whatever is on disk right now, never a strip run to find out --
// see `strip::serveable_now`. This path is fetched on every open,
// resume and Refresh.
let size = match &apk {
Some(apk) => tokio::fs::metadata(crate::strip::serveable_now(&apk.path, entry.strip()))
.await
.context("stat the apk to be served")?
.len(),
None => 0,
};
let variants = entry
.variants()
.into_iter()
.map(|candidate| ManifestVariant {
path: candidate.path.to_string_lossy().into_owned(),
variant: candidate.variant,
mtime: epoch_secs(candidate.modified),
})
.collect();
// 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
@@ -550,34 +630,22 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
// 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),
components: entry
.components
built: components
.iter()
.map(|component| ManifestComponent::read(state, &entry.key, entry, component))
.collect(),
.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),
filename: apk
.as_ref()
.map(|apk| entry.filename(&apk.path))
.unwrap_or_else(|| format!("{}.apk", entry.key)),
key: entry.key.clone(),
label: entry.label.clone(),
package: entry.package().map(str::to_string),
previous_package: state.previous_package(&entry.key),
// 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),
mtime: apk
.as_ref()
.map(|apk| epoch_secs(apk.modified))
.unwrap_or(0.0),
size,
// 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.
@@ -588,11 +656,9 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
.is_some_and(|build| build.has_command()),
git_ipv4: entry.git_ipv4,
built_in: entry.built_in,
built: apk.is_some(),
pending_declaration: pending
.as_ref()
.and_then(|components| crate::config::render_request(components).ok()),
variants,
})
}
@@ -747,7 +813,10 @@ async fn add_app(
tracing::info!(
"added {} ({}) from {}",
entry.key,
entry.package().unwrap_or("package not read yet"),
entry
.apk_component(None)
.and_then(crate::config::Component::package)
.unwrap_or("package not read yet"),
entry.project_path.display()
);
Ok(Json(AddedResponse {
@@ -1038,10 +1107,19 @@ struct PullQuery {
force: bool,
}
/// Which build a download wants. Absent means the newest, which is what
/// every phone gets until it says otherwise.
/// 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>,
}
@@ -1334,8 +1412,12 @@ struct SelfBuild {
/// 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(None)
.resolve_apk(component, None)
.ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?;
let size = tokio::fs::metadata(&apk.path)
.await
@@ -1347,6 +1429,28 @@ async fn self_build(State(state): State<Arc<AppState>>) -> Result<Json<SelfBuild
}))
}
/// 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>>,
@@ -1355,20 +1459,23 @@ async fn serve_apk(
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.
// 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(requested.as_deref())
.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, apk.path.clone());
state.refresh_package(&entry.key, component.name(), apk.path.clone());
}
let resolved = resolve_serveable_path(&apk.path, entry.strip()).await?;
let resolved = resolve_serveable_path(&apk.path, component.strip()).await?;
let size = tokio::fs::metadata(&resolved)
.await
.context("stat resolved apk")?