//! The set of apps this server currently serves, and the mutations the //! updater app can make to it. //! //! An entry is the *project*, not a file: the APK underneath it is //! rediscovered per request (`crate::discover`, microseconds) so a rebuild //! is picked up with no bookkeeping, and so an entry whose build hasn't //! happened yet simply reports as not-built instead of erroring. //! //! One entry is not configurable: this server's own updater app. It has to //! exist unconditionally, because it is the only route by which the copy on //! a phone can ever be replaced -- an updater that can be removed from its //! own list can strand itself. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, RwLock}; use anyhow::{Context, Result, bail}; use crate::build_state::BuildState; use crate::config::{Component, Config, ProjectConfig, TokenEntry}; use crate::discover::{self, ApkCandidate}; /// The key of the built-in self-update entry. Reserved, so a project that /// happens to be named `updater` gets `updater-2` rather than silently /// shadowing it. Not read from anywhere, because it is this server's own /// route and the phone's stored download name for it. pub const SELF_KEY: &str = "updater"; /// What to call the self entry, and what it replaces, when there is no /// checkout to read `.dev-updater.ron` out of -- an installed binary /// still has to offer itself, and still has to name the package it is. const SELF_PACKAGE: &str = "com.example.devupdater"; pub(crate) const SELF_LABEL: &str = "Dev Updater"; /// One servable app, resolved from config (or built in, for the self /// entry). Cheap to clone-by-`Arc` and handed to request handlers whole. pub struct AppEntry { pub key: String, pub label: String, pub project_path: PathBuf, /// What this project produces. Ordinarily one `Apk`; a project that /// also runs a server on the build machine declares that too. pub components: Vec, /// Present only for an entry with a configured build step. An entry /// without one reports `needsBuild: false` and its prepare/status /// routes answer 404, so the app never polls them. pub build: Option>, /// True for the self entry, which the app renders without a Remove /// button -- see this module's own doc comment. pub built_in: bool, /// Whether this app opted in to being pulled (`gitPull`). pub git_pull: bool, /// Force git's remote commands onto IPv4 -- this machine's choice /// about this project, not something the project declares. pub git_ipv4: bool, /// The accepted `resources:` declaration, if this project makes one. /// Where the project says its name and its state directories are; see /// `crate::config::Resources`. pub resources: Option, } 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> { self.components .iter() .find(|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) } /// Whether to serve a stripped copy. Declared, not detected. pub fn strip(&self) -> bool { matches!( self.apk_component(), Some(Component::Apk { strip: true, .. }) ) } /// The APK to serve: `requested` if it is still one of this project's /// builds, else the newest one found under it. /// /// Which variant a phone wants is that phone's preference, so it /// arrives with the request rather than being stored here -- two /// devices enrolled against one server would otherwise change what the /// other one gets. Checked against the discovered builds, never taken /// on trust: an arbitrary path would let a phone name any file on disk /// to be served. /// /// A request for something that has since been deleted falls back /// 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 { let variants = self.variants(); if let Some(requested) = requested && let Some(found) = variants .iter() .find(|candidate| candidate.path == requested) { return Some(found.clone()); } variants.into_iter().next() } /// Every build found under this project, newest first -- what the app /// offers when letting the user switch variants. pub fn variants(&self) -> Vec { discover::find_apks(&self.project_path) } /// The build step this project is asking for that nobody has accepted /// -- what the phone shows before offering to accept it, and the /// answer to "may this app's command run right now?". /// /// Read from the project on every call rather than settled when the /// entry was built, because the file changes on a **pull**, and a pull /// writes no config and so rebuilds no entries. Caching it meant a /// pull could swap the command out from under a previous acceptance /// and nothing would notice until an unrelated config write happened. /// /// Cheap enough for the manifest path: one small read per app, beside /// the `git status` that path already runs for each of them. pub fn pending_declaration(&self) -> Option> { // This server's own project is accepted by construction (see // `reconcile_self`), and the row saying so is only re-derived at // startup -- so between a pull that changes the declaration and the // restart that follows it, the two can differ without that meaning // anything is waiting to be accepted. if self.built_in { return None; } let declared = crate::config::project_config(&self.project_path); if declared.components.is_empty() || declared.matches_accepted(&self.components, self.resources.as_ref()) { return None; } Some(declared.components) } /// This project's resources, as something to read, when it declares /// any. `None` is a project that says nothing about itself, which is /// an answer rather than a reason to go looking. pub fn resource_target(&self) -> Option { Some(crate::resources::Target { key: self.key.clone(), project: self.project_path.clone(), declaration: self.resources.clone()?, }) } /// Every server component of this project, as something to ask about. pub fn service_targets(&self) -> Vec { self.components .iter() .filter_map(|component| { let script = crate::service::driver(&self.key, component)?; Some(crate::service::Target { key: self.key.clone(), component: component.name().to_string(), project: self.project_path.clone(), cwd: component.cwd().map(Path::to_path_buf), script, }) }) .collect() } /// Everything readable about one component, newest first: what its /// build wrote, then what its service script reports. /// /// Both kinds in one list because the phone asks one question -- "what /// can I read about this?" -- and the route that answers it should not /// care which produced the file. Build logs come first because a /// component that has just failed to build is the one being looked at. /// /// The service half is read from the cached background answer by the /// caller; this only assembles what a direct ask returns, which is why /// it is used by the log route rather than by the manifest. /// Where one kind of this component's logs are, newest first. /// /// Asked one kind at a time rather than returning both, because the /// caller always knows which it wants and a combined list has to be /// indexed by something that means two things at once. pub fn component_logs(&self, name: &str, kind: crate::logs::LogKind) -> Vec { let Some(component) = self.component(name) else { return Vec::new(); }; match kind { crate::logs::LogKind::Build => crate::logs::build_logs(&self.key, name), crate::logs::LogKind::Runtime => crate::service::driver(&self.key, component) .map(|script| crate::service::logs(&script, &self.project_path, component.cwd())) .unwrap_or_default(), } } /// This project's component of that name, for a route that addresses /// one directly. pub fn component(&self, name: &str) -> Option<&Component> { self.components .iter() .find(|component| component.name() == name) } /// The name the download is offered under. Prefixed with the key /// because build outputs are generically named (`app-debug.apk`, /// `androidApp-debug.apk`) and several apps would otherwise collide in /// the device's download directory. pub fn filename(&self, apk: &Path) -> String { let name = apk.file_name().unwrap_or_default().to_string_lossy(); format!("{}-{name}", self.key) } } /// The packages already read for each component, by name -- what an /// acceptance has to carry across. fn measured_packages(components: &[Component]) -> HashMap { components .iter() .filter_map(|component| { Some(( component.name().to_string(), 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) { if let Some(component) = components .iter_mut() .find(|component| matches!(component, Component::Apk { .. })) { component.set_package(package); } } /// The live app list plus the config it was built from, behind one lock. /// /// Held together rather than separately because every mutation changes both /// and they must not be observed disagreeing: a request that saw a new /// config but the old entries would 404 an app the phone was just told /// exists. pub struct Registry { config: Config, entries: Vec>, } pub struct AppState { /// What each checkout's remote last reported, so showing the list /// costs at most one round trip per repository per half minute. pub remote_checks: crate::git::RemoteChecks, /// APK downloads in flight, so a restart of this server waits for /// them rather than cutting them off. Shared with every `BuildState`, /// because a build of this project ends in one. pub downloads: Arc, /// What each server component was last found to be doing. Beside the /// remote checks and for the same reason -- asking costs a process /// spawn, so the manifest reads a cached answer and the refresh that /// fills it runs off the request. pub service_checks: crate::service::ServiceChecks, /// What each project last said about itself -- its name and where its /// state lives. Beside the other two checks and for the same reason: /// the `Script` variant spawns a process, so the manifest reads a /// cached answer and the refresh that fills it runs off the request. pub resource_checks: crate::resources::ResourceChecks, /// What a project's APK used to install over, for the ones whose /// package name has changed under it. Keyed by project key. /// /// In memory and not in the config, deliberately. Android treats a /// renamed `applicationId` as an unrelated app, so the old one stays /// installed and orphaned -- and this exists only so the card can /// offer to remove it. That is a job with a short life, done once /// after a rename; carrying it on disk would mean a file remembering /// names of apps nobody has any more. A restart between the rename and /// the removal loses the offer, and the orphan is then removed by hand /// like any other app. /// /// 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>, config_path: PathBuf, registry: RwLock, } impl AppState { /// Loads the config and settles the built-in row against /// `self_project` before anything reads it, which is why nothing after /// this has to be told which project is this server's own -- the list /// says so. pub fn new(config_path: PathBuf, self_project: PathBuf) -> Result { let mut config = Config::load(&config_path)?; // Startup is the only moment the working directory is known, and // so the only moment a re-clone or a moved repo can be noticed. if reconcile_self(&mut config, &self_project) { config.save(&config_path)?; } let remote_checks = crate::git::RemoteChecks::default(); let downloads = Arc::new(crate::restart::Downloads::default()); let entries = build_entries(&config, &[], &remote_checks, &downloads); Ok(Self { downloads, remote_checks, service_checks: crate::service::ServiceChecks::default(), resource_checks: crate::resources::ResourceChecks::default(), previous_packages: Mutex::new(HashMap::new()), config_path, registry: RwLock::new(Registry { config, entries }), }) } pub fn entries(&self) -> Vec> { self.registry.read().unwrap().entries.clone() } pub fn entry(&self, key: &str) -> Option> { self.registry .read() .unwrap() .entries .iter() .find(|entry| entry.key == key) .cloned() } /// What this project'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 { self.previous_packages.lock().unwrap().get(key).cloned() } /// Re-reads what `apk` installs over and records it if it has changed. /// /// Called from the download, which is the only place that can notice: /// `aapt2` must never run on the manifest path, and a local rebuild /// with a changed `applicationId` tells this server nothing until the /// 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, key: &str, apk: PathBuf) { let state = Arc::clone(self); let key = key.to_string(); tokio::task::spawn_blocking(move || { let Ok(info) = crate::apkinfo::read(&apk) else { return; }; let Some(entry) = state.entry(&key) else { return; }; if entry.package() == Some(info.package.as_str()) { return; } if let Some(previous) = entry.package() { tracing::info!( "{} now installs {} rather than {previous}", entry.label, info.package, ); state .previous_packages .lock() .unwrap() .insert(key.clone(), previous.to_string()); } 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); } Ok(()) }); if let Err(err) = update { tracing::warn!("could not record {key}'s package: {err}"); } }); } /// Records the commit a component was successfully built from. /// /// Called from a running build through the callback `routes` hands it, /// which is why this takes names rather than a handle: the build lives /// inside this list and must not hold it. /// /// A failure to write is logged and dropped rather than failing the /// build. The build genuinely succeeded; all that is lost is knowing /// what it was built from, which reads as unknown -- the same state a /// project has before this server has ever built it. pub fn record_built(&self, key: &str, component: &str, sha: String) { let written = self.update(|config| { if let Some(project) = config.projects.iter_mut().find(|p| p.key == key) && let Some(built) = project .components .iter_mut() .find(|candidate| candidate.name() == component) { built.set_built_from(sha); } Ok(()) }); if let Err(err) = written { tracing::warn!("could not record what {key}'s {component} was built from: {err}"); } } pub fn tokens(&self) -> Vec { self.registry.read().unwrap().config.tokens.clone() } /// Replaces the enrolled token list. With one device this is rotation: /// the old hash is invalidated the moment the new config is saved. pub fn set_tokens(&self, tokens: Vec) -> Result<()> { self.update(|config| { config.tokens = tokens; Ok(()) }) } pub fn repo_roots(&self) -> Vec { self.registry.read().unwrap().config.repo_roots.clone() } /// Applies `mutate` to the config, then persists it and rebuilds the /// entry list from it. Every mutation goes through here so that /// "changed in memory" and "written to disk" can't come apart -- a /// failed write leaves the previous state intact and reports the error /// to the phone rather than silently diverging from the file. fn update(&self, mutate: impl FnOnce(&mut Config) -> Result) -> Result { let mut registry = self.registry.write().unwrap(); let mut candidate = registry.config.clone(); let result = mutate(&mut candidate)?; candidate.save(&self.config_path)?; // Carry existing build state across, so an in-flight build isn't // forgotten by an unrelated edit to another app. registry.entries = build_entries( &candidate, ®istry.entries, &self.remote_checks, &self.downloads, ); registry.config = candidate; Ok(result) } /// Adds the project at `path`, reading its identity out of its newest /// build. Fails if there's nothing built there yet -- with the path it /// looked under, since "I pointed it at my repo root instead of the app /// directory" is the likely mistake and the message should let that be /// spotted without a log. pub fn add_app(&self, path: &Path) -> Result> { let project = path .canonicalize() .with_context(|| format!("no such directory: {}", path.display()))?; if !project.is_dir() { bail!("{} is not a directory", project.display()); } if let Some(existing) = self .entries() .iter() .find(|entry| entry.project_path == project) { bail!( "{} is already added, as \"{}\"", project.display(), existing.label ); } let name = project .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| "app".to_string()); // Identity comes out of the built APK whenever there is one: it is // the authority on what will actually be installed. Without one the // project is still added -- there is simply nothing installed for // it yet, which is the honest answer and is what lets a project be // added in order to run the build that produces its first APK. let declared = crate::config::project_config(&project); let (label, package) = match discover::find_apks(&project).into_iter().next() { Some(apk) => { let info = crate::apkinfo::read(&apk.path)?; ( info.label.unwrap_or_else(|| name.clone()), Some(info.package), ) } None => (declared.label.unwrap_or_else(|| name.clone()), None), }; let key = self.update(|config| { let key = config.unique_key(&name, SELF_KEY); config.projects.push(ProjectConfig { key: key.clone(), label, project_path: project.clone(), git_pull: true, // Nothing this machine has said about it yet. git_ipv4: false, // Nothing accepted yet either, including where the project // says it keeps its state -- that arrives as a request // alongside the components. resources: None, // One component, nothing to run. Whatever the project's own // file declares arrives as a request to be accepted, the // same as it always did -- adding a project must not be a // way to start running its commands. components: vec![Component::Apk { name: "app".to_string(), build: crate::config::Command::default(), cwd: None, stale_when: None, strip: false, package, built_from: None, }], }); Ok(key) })?; Ok(self .entry(&key) .expect("just-added app is in the rebuilt entry list")) } pub fn remove_app(&self, key: &str) -> Result<()> { if key == SELF_KEY { bail!("{SELF_KEY} is this server's own app and can't be removed"); } self.update(|config| { let before = config.projects.len(); config.projects.retain(|app| app.key != key); if config.projects.len() == before { bail!("no app named {key}"); } Ok(()) })?; // Nothing left for either to be about. self.previous_packages.lock().unwrap().remove(key); self.service_checks.forget(key); self.resource_checks.forget(key); Ok(()) } /// Records this machine's preferences for one project. /// /// Onto that project's own row, including the built-in one, which is /// what having a row for this server's own project buys: there is one /// place a preference lives and no card it silently does nothing on. /// `reconcile_self` carries it across the row being rebuilt. pub fn set_project_settings(&self, key: &str, git_ipv4: bool) -> Result<()> { self.update(|config| { let project = config .projects .iter_mut() .find(|project| project.key == key) .with_context(|| format!("no app named {key}"))?; project.git_ipv4 = git_ipv4; Ok(()) }) } /// Accepts what this project asks to have run, after a person has read /// it on the phone. From here on those components are ordinary /// configuration, indistinguishable from hand-written ones. /// /// Re-read from the project file rather than taken from the request: /// what gets stored is what the project actually asks for, so there is /// no route by which the phone can name a command of its own. The only /// thing that rewrites that file is a pull the same person just /// performed. /// /// Measured values are carried across rather than reset. A declaration /// says nothing about the package a build produces -- that is read from /// the APK -- so accepting one must not throw away what was already /// read, or every acceptance would blank the installed-version check /// until the next build. pub fn approve_declaration(&self, key: &str) -> Result<()> { let entry = self .entry(key) .with_context(|| format!("no app named {key}"))?; let declared = crate::config::project_config(&entry.project_path); if declared.components.is_empty() { bail!( "{} no longer asks for anything -- its {} is gone or unreadable", entry.label, crate::config::PROJECT_CONFIG_FILE, ); } self.update(|config| { let project = config .projects .iter_mut() .find(|project| project.key == key) .with_context(|| format!("{key} is built in and has nothing to accept"))?; let measured = measured_packages(&project.components); project.git_pull = declared.git_pull; // Everything `matches_accepted` compares has to be written // here, or accepting cannot clear the gate. `resources` joined // that comparison and this did not, which left any project // declaring one permanently pending: the write succeeded, the // card refreshed, and it still asked. Nothing failed, because // there is no error to report -- the button simply did // nothing. Add a field to the gate and add it here. project.resources = declared.resources; project.components = declared.components; for component in &mut project.components { if let Some(package) = measured.get(component.name()) { component.set_package(package.clone()); } } Ok(()) }) } pub fn set_repo_roots(&self, roots: Vec) -> Result<()> { self.update(|config| { config.repo_roots = roots; Ok(()) }) } } /// Brings the built-in row up to date with what this server actually is, /// creating it the first time, and reports whether anything changed -- so /// a start with nothing to say doesn't rewrite the file. /// /// This project is a row like any other so that everything about a project /// has one home, this machine's `gitIpv4` included; before it had one, /// such a setting had to be keyed separately and the one card that is /// always in the list was the one card it could not be a field on. /// /// What is *in* the row is still derived, and re-derived here rather than /// trusted: the path is where this process was started (see main.rs's /// `self_project`), and the label and components come from the checkout's /// own declaration. That is what keeps a re-clone or a moved repo fixing /// itself at the next start instead of leaving the row naming a directory /// nobody pulls. Only `git_ipv4` is carried across, because it is the one /// thing here somebody chose rather than something this server measured. /// /// The declaration is taken as accepted by construction, deliberately not /// subject to the gate the other entries have. Gating would protect /// nothing: pulling this repository replaces the binary that would be /// doing the gating, so whatever arrives is already trusted by the time it /// could run. fn reconcile_self(config: &mut Config, self_project: &Path) -> bool { let declared = crate::config::project_config(self_project); // Asked of the project itself, not of its parent. `git status` answers // from any subdirectory, so the two agree whenever `app/` is really // there -- and differ in the one case that matters: a working directory // that is inside this repo but is not its root (`server/`, say) makes // the parent test say yes about a project directory that does not // exist, and the row then claims components from a declaration that is // not there. let in_checkout = crate::git::status(self_project).is_some(); // Always has an APK component, and always knows its package. This is // the one project whose package this server can state without reading // an APK -- it is this program -- and it has to: an installed binary // with no checkout has no declaration to read and still has to tell // the phone what it would be replacing. let mut components = if in_checkout { declared.components } else { Vec::new() }; if !components .iter() .any(|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()); let existing = config .projects .iter_mut() .find(|project| project.key == SELF_KEY); let derived = ProjectConfig { key: SELF_KEY.to_string(), label: declared.label.unwrap_or_else(|| SELF_LABEL.to_string()), project_path: self_project.to_path_buf(), git_pull: declared.git_pull, git_ipv4: existing.as_ref().is_some_and(|project| project.git_ipv4), components, // Re-derived like everything else in this row: accepted by // construction, because this server's own declaration is not // something it has to be asked about. resources: declared.resources, }; match existing { Some(existing) if *existing == derived => false, Some(existing) => { *existing = derived; true } // First in the file and first in the list: this is the entry that // is always there, and the one an installed copy is replaced // through. None => { config.projects.insert(0, derived); true } } } /// Rebuilds the entry list from `config`, reusing any `BuildState` from /// `previous` whose app still has the same prepare configuration -- so an /// unrelated edit doesn't drop a running build's status on the floor. fn build_entries( config: &Config, previous: &[Arc], remote_checks: &crate::git::RemoteChecks, downloads: &Arc, ) -> Vec> { // Where this server's own project is, according to the row // `reconcile_self` keeps -- only for the warning below, since // everything else about that project is read from the row like any // other's. let self_project = config .projects .iter() .find(|project| project.key == SELF_KEY) .map(|project| project.project_path.as_path()); let mut entries = Vec::with_capacity(config.projects.len()); for project in &config.projects { // The one project whose build this server does know how to run: it // is this repository. Given a checkout it gets the same // pull-and-build machinery as anything else, plus a restart -- // pulling rebuilds the binary too, and the point is to end up // running it. let is_self = project.key == SELF_KEY; // Said out loud rather than hidden or dropped. `add_app` refuses a // path already in the list, and this server's own project is in // that list, so this can only be left over from when it resolved // somewhere else (see main.rs's `self_project`). It is worth // naming because the duplicate is not merely redundant: it is not // the built-in row, so a build through it would run this project's // service script and restart *this server* partway through its own // build, instead of the exec that waits until the build is // reported. // // The card is left alone so it keeps its Remove button, which is // the only way anyone can act on this from the phone -- and is what // tells the two cards apart, since the built-in one has none. if !is_self && Some(project.project_path.as_path()) == self_project { tracing::warn!( "{} is this server's own project and is always in the list as the built-in \ {SELF_LABEL} entry, so \"{}\" is a second card for it. Remove that one from \ the app -- it is the one with a Remove button.", project.project_path.display(), project.label, ); } // A project in a checkout gets pull machinery even with nothing // configured -- there is somewhere to pull from, which is all a // Pull button needs. Without a command it pulls and stops; how to // build is still never guessed. let in_checkout = crate::git::status(&project.project_path).is_some(); let git_pull = project.git_pull && in_checkout; // A command can be run wherever it is; a checkout can be pulled // even with nothing configured. Either is reason enough to have // build machinery -- without a command it pulls and stops. // // Except for this server itself, which is not offered a Pull it has // nothing to build with: pulling this repository without rebuilding // leaves the new binary on disk and the old one serving. let has_command = crate::config::has_command(&project.components); let build = (has_command || (in_checkout && !is_self)).then(|| { let components = &project.components; reuse_build_state( previous, &project.key, components, git_pull, project.git_ipv4, ) .unwrap_or_else(|| { // For this server's own project the Server component is // delivered by exec-ing what it just built, which is // what `is_self` says -- see BuildState::is_self. BuildState::new( project.key.clone(), project.components.clone(), git_pull, project.git_ipv4, project.project_path.clone(), crate::build_state::Shared { remote_checks: remote_checks.clone(), downloads: Arc::clone(downloads), }, is_self, ) }) }); entries.push(Arc::new(AppEntry { git_pull, git_ipv4: project.git_ipv4, resources: project.resources.clone(), key: project.key.clone(), label: project.label.clone(), project_path: project.project_path.clone(), components: project.components.clone(), build, built_in: is_self, })); } entries } fn reuse_build_state( previous: &[Arc], key: &str, components: &[crate::config::Component], git_pull: bool, git_ipv4: bool, ) -> Option> { previous .iter() .find(|entry| entry.key == key)? .build .as_ref() .filter(|state| state.matches(components, git_pull, git_ipv4)) .cloned() } #[cfg(test)] mod tests { use super::*; fn asking_for(command: &str) -> Vec { vec![Component::Apk { name: "app".to_string(), build: crate::config::Command::from_line(command), cwd: None, stale_when: None, strip: false, package: None, built_from: None, }] } fn write_request(project: &Path, command: &str) { std::fs::write( project.join(crate::config::PROJECT_CONFIG_FILE), format!("components: [Apk(name: \"app\", build: \"{command}\")],\n"), ) .expect("write"); } /// A live `AppState` holding one configured app pointed at `project`, /// with nothing accepted yet. /// /// Built rather than modelled on purpose: what "accepted" means is /// whatever [`AppState::approve_declaration`] writes, and a test that /// assembles the accepted config itself asserts a *copy* of that rule. /// The copy is what let `resources` join the acceptance comparison /// without joining the write, so accepting a project that declared one /// could never clear the gate and no test noticed. fn state_with(project: &Path) -> (tempfile::TempDir, Arc) { let home = tempfile::tempdir().expect("tempdir"); let config_path = home.path().join("config.ron"); Config { projects: vec![ProjectConfig { key: "demo".to_string(), label: "Demo".to_string(), project_path: project.to_path_buf(), git_pull: true, git_ipv4: false, components: Vec::new(), resources: None, }], ..Config::default() } .save(&config_path) .expect("save"); // Self project pointed at an empty directory: this is about a // configured app, and the built-in row has nothing to accept. let state = AppState::new(config_path, home.path().join("self")).expect("state"); (home, Arc::new(state)) } /// The entry list for one app pointed at `project`, with `accepted` /// standing for whatever is already in this machine's config. fn entry_for(project: &Path, accepted: Vec) -> Arc { let config = Config { projects: vec![ProjectConfig { key: "demo".to_string(), label: "Demo".to_string(), project_path: project.to_path_buf(), git_pull: true, git_ipv4: false, components: accepted, resources: None, }], ..Config::default() }; // No built-in row: this is about a configured app, and leaving it // out keeps the entry list to the one being asked about. build_entries( &config, &[], &crate::git::RemoteChecks::default(), &Arc::new(crate::restart::Downloads::default()), ) .into_iter() .find(|entry| entry.key == "demo") .expect("the configured app") } /// The point of the whole mechanism: a project asking for a command /// does not thereby get to run one. #[test] fn a_build_step_a_project_asks_for_waits_until_it_is_accepted() { let dir = tempfile::tempdir().expect("tempdir"); write_request(dir.path(), "./build.sh"); let pending = entry_for(dir.path(), Vec::new()); assert_eq!( pending.pending_declaration(), Some(asking_for("./build.sh")) ); // Accepted is exactly "the config now says what the project says", // which is what approve_declaration writes. let accepted = entry_for(dir.path(), asking_for("./build.sh")); assert_eq!(accepted.pending_declaration(), None); assert!( accepted .build .as_ref() .is_some_and(|build| build.has_command()) ); } /// Accepting has to clear the gate -- for every project, including /// one that declares `resources:`. /// /// The bug this holds shut: `matches_accepted` compares the components /// *and* the resources declaration, while `approve_declaration` wrote /// only the components. Accepting therefore stored half of what was /// being compared, the card came back still asking, and the phone had /// nothing to show for the press -- the write had succeeded, so there /// was no error either. It went through the whole of ai-app's card /// before anybody could accept anything. #[test] fn accepting_clears_the_gate_including_the_resources_declaration() { let dir = tempfile::tempdir().expect("tempdir"); std::fs::write( dir.path().join(crate::config::PROJECT_CONFIG_FILE), "resources: Ron(\"resources.ron\"),\n components: [Apk(name: \"app\", build: \"./build.sh\")],\n", ) .expect("write"); let (_home, state) = state_with(dir.path()); assert!( state .entry("demo") .expect("entry") .pending_declaration() .is_some(), "nothing accepted yet", ); state.approve_declaration("demo").expect("approve"); let entry = state.entry("demo").expect("entry"); assert_eq!( entry.pending_declaration(), None, "accepting must clear the gate, not half of it", ); assert_eq!( entry.resources, Some(crate::config::Resources::Ron(PathBuf::from( "resources.ron" ))), "the accepted copy is what a later comparison reads", ); } /// The case the gate exists for, and the one a cached answer got /// wrong: the project file changes on a *pull*, which writes no config /// and so rebuilds no entries. An entry built while the request /// matched must still notice when it stops matching. #[test] fn a_request_that_changes_under_a_built_entry_withdraws_the_acceptance() { let dir = tempfile::tempdir().expect("tempdir"); write_request(dir.path(), "./build.sh"); let entry = entry_for(dir.path(), asking_for("./build.sh")); assert_eq!(entry.pending_declaration(), None, "accepted to begin with"); // What a pull bringing a changed .dev-updater.ron does. The entry // is not rebuilt -- nothing wrote the config -- so this is read // through the same entry that was just current. write_request(dir.path(), "./something-else.sh"); assert_eq!( entry.pending_declaration(), Some(asking_for("./something-else.sh")), "a changed request must stop counting as accepted", ); } /// A checkout laid out the way this repository is: `/app` is the /// self entry's project, and `` is what gets pulled. fn self_checkout(root: &Path) -> PathBuf { let app = root.join("app"); std::fs::create_dir_all(&app).expect("mkdir"); // Committed, not merely initialised: reading a checkout's branch // is what tells this apart from an installed binary, and a // repository with no commits has no branch to report. for args in [ &["init", "-q"][..], &["config", "user.email", "t@example.com"], &["config", "user.name", "Test"], &["commit", "-q", "--allow-empty", "-m", "one"], ] { let status = std::process::Command::new("git") .args(args) .current_dir(root) .status() .expect("run git"); assert!(status.success(), "git {args:?}"); } app } /// The built-in entry as it comes out of a config `reconcile_self` /// has settled -- which is what startup does before anything reads the /// list. fn self_entry(app: &Path) -> Arc { let mut config = Config::default(); assert!( reconcile_self(&mut config, app), "the row has to be written the first time" ); assert!( !reconcile_self(&mut config, app), "and not rewritten with nothing to say" ); build_entries( &config, &[], &crate::git::RemoteChecks::default(), &Arc::new(crate::restart::Downloads::default()), ) .into_iter() .find(|entry| entry.key == SELF_KEY) .expect("the self entry always exists") } /// The updater describes itself with the same file any other project /// carries. Describing it twice is how the two descriptions come to /// disagree. #[test] fn the_self_entry_reads_its_own_declaration() { let dir = tempfile::tempdir().expect("tempdir"); let app = self_checkout(dir.path()); std::fs::write( app.join(crate::config::PROJECT_CONFIG_FILE), "label: \"Declared Name\",\n\ components: [\n\ Server(\n\ name: \"server\",\n\ build: \"cargo build --release\",\n\ service: Script(\"../server/service\"),\n\ ),\n\ Apk(name: \"app\", build: \"./build-apk.sh\"),\n\ ],\n", ) .expect("write"); let entry = self_entry(&app); 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!( entry .build .as_ref() .is_some_and(|build| build.has_command()) ); assert!(entry.git_pull); // Never waiting to be accepted: pulling this repository replaces // the binary that would be doing the gating. assert_eq!(entry.pending_declaration(), None); } /// An installed binary has no checkout to read, and still has to offer /// itself -- it is the only route by which the phone's copy is ever /// replaced. #[test] fn the_self_entry_survives_having_nothing_to_read() { let dir = tempfile::tempdir().expect("tempdir"); let app = self_checkout(dir.path()); let entry = self_entry(&app); assert_eq!(entry.label, SELF_LABEL); assert_eq!(entry.package(), Some(SELF_PACKAGE)); assert!(entry.built_in); // Nothing declares how to build it, so nothing is guessed. assert!(entry.build.is_none()); } /// The point of the built-in project having a row at all: a setting /// somebody chose from the phone belongs to it like any other /// project's, and re-deriving the row every start must not take it /// back off. #[test] fn what_this_machine_chose_survives_the_built_in_row_being_re_derived() { let dir = tempfile::tempdir().expect("tempdir"); let app = self_checkout(dir.path()); let mut config = Config::default(); reconcile_self(&mut config, &app); config.projects[0].git_ipv4 = true; assert!( !reconcile_self(&mut config, &app), "a setting is not a reason to rewrite" ); assert!( config.projects[0].git_ipv4, "kept: nothing else here is somebody's choice" ); // And the entry the phone is shown says so. let entries = build_entries( &config, &[], &crate::git::RemoteChecks::default(), &Arc::new(crate::restart::Downloads::default()), ); assert!(entries[0].git_ipv4); } /// What is derived stays derived. The symptom this prevents is the one /// the path being compiled in used to cause: a row naming a directory /// nobody pulls, on a card that otherwise works. #[test] fn a_moved_checkout_repoints_the_built_in_row() { let dir = tempfile::tempdir().expect("tempdir"); let app = self_checkout(dir.path()); let mut config = Config::default(); reconcile_self(&mut config, Path::new("/nonexistent/app")); config.projects[0].git_ipv4 = true; assert!( reconcile_self(&mut config, &app), "the path it was started in wins" ); assert_eq!(config.projects[0].project_path, app); assert!(config.projects[0].git_ipv4, "and the choice comes with it"); } /// The ordinary case, which must keep working untouched: a project /// that asks for nothing is configured entirely from this machine. #[test] fn a_project_that_asks_for_nothing_is_left_to_the_local_config() { let dir = tempfile::tempdir().expect("tempdir"); let entry = entry_for(dir.path(), asking_for("./build.sh")); assert_eq!(entry.pending_declaration(), None); assert!( entry .build .as_ref() .is_some_and(|build| build.has_command()) ); } }