dev-updater: build an app on the machine, install it on the phone
A Rust backend that discovers Android projects under configured roots, builds one on request, and serves the APK over pinned TLS on a WireGuard interface; an Android client that lists what is buildable, watches a build, and installs the result. Enrolment carries the token and the CA, so the phone trusts exactly the machine that issued it and nothing else. `AGENTS.md` is the working guide and `README.md` the configuration reference. The shared tunnel-and-TLS code lives in `vendor/wg-app-link`, which ai-app uses too. History before this point was squashed away, and a stale `config.json` went with it: nothing had read that file since the config moved to RON outside the checkout, and what it still held was one machine's absolute paths and the names of projects on it.
This commit is contained in:
commit
b0e83059a3
82 files changed
+20372
No files matched your search
@@ -0,0 +1,266 @@
|
||||
//! Reading what a project says about itself.
|
||||
//!
|
||||
//! A project's `resources:` declaration points at values the project
|
||||
//! keeps for its own use -- its name, where its data and config live --
|
||||
//! and this reads the few of them this server needs. The file belongs to
|
||||
//! the project: its own code is expected to read the same one, which is
|
||||
//! why unrecognised keys are ignored rather than refused.
|
||||
//!
|
||||
//! **Off the request path, like every other slow answer here.** The
|
||||
//! `Script` variant spawns a process, and `/manifest` is fetched on every
|
||||
//! open, resume and Refresh -- the one thing that path must not do. So
|
||||
//! this goes through [`crate::checks`] beside the git and service checks,
|
||||
//! answers arriving after the response that started them, and the phone
|
||||
//! waiting on all three through the same outstanding count.
|
||||
//!
|
||||
//! Not knowing is a first-class answer. A project that declares nothing,
|
||||
//! a file that will not parse, a script that fails: each leaves the
|
||||
//! Uninstall dialog saying it cannot tell where the data lives, rather
|
||||
//! than filling in a directory that looks plausible. The version of this
|
||||
//! that guessed -- first from the config key, then from the checkout's
|
||||
//! directory name -- was wrong in a way nothing could see, because a path
|
||||
//! that is not there reads as "this component keeps nothing here".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::checks::{Checks, Report};
|
||||
use crate::config::{ResourceFacts, Resources};
|
||||
|
||||
/// How long a resources script is given before it is treated as having
|
||||
/// failed.
|
||||
///
|
||||
/// It prints three values; anything that takes longer than this is stuck
|
||||
/// rather than slow, and a check that never finishes holds the key
|
||||
/// claimed forever -- so the card would sit on "still finding out" with
|
||||
/// nothing ever arriving.
|
||||
const SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Reads a project's resources, or says why it could not.
|
||||
///
|
||||
/// The error is what the card shows, so it names the file or the command
|
||||
/// rather than only the underlying complaint -- "no such file" without a
|
||||
/// path is not something a person can act on.
|
||||
pub fn read(project: &Path, declaration: &Resources) -> Result<ResourceFacts, String> {
|
||||
match declaration {
|
||||
Resources::Inline(facts) => Ok(facts.clone()),
|
||||
Resources::Ron(path) => {
|
||||
// `~` expanded the same way it is for a path typed on a
|
||||
// phone; `join` on an absolute path yields that path, so a
|
||||
// resources file may live outside the checkout.
|
||||
let path = project.join(
|
||||
path.to_str()
|
||||
.map(crate::config::expand_tilde)
|
||||
.unwrap_or_else(|| path.clone()),
|
||||
);
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|err| format!("reading {}: {err}", path.display()))?;
|
||||
parse(&text).map_err(|err| format!("in {}: {err}", path.display()))
|
||||
}
|
||||
Resources::Script(command) => {
|
||||
let text = run(project, command)?;
|
||||
parse(&text).map_err(|err| format!("in the output of {}: {err}", command.to_line()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The project's own house rules, which are this server's: the file is the
|
||||
/// *body* of the struct, and an optional value is written bare.
|
||||
///
|
||||
/// Shared with `wg_app_link::format` rather than reimplemented, because
|
||||
/// the project's own code parses the same file and the two must agree
|
||||
/// about whether it has outer parentheses.
|
||||
fn parse(text: &str) -> Result<ResourceFacts, String> {
|
||||
wg_app_link::format::parse(text).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn run(project: &Path, command: &crate::config::Command) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut child = command
|
||||
.to_process(project, None, &[])?
|
||||
// Closed, because a script that asks a question would otherwise
|
||||
// wait for an answer nobody is there to give -- the same rule the
|
||||
// service scripts run under.
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|err| format!("starting {}: {err}", command.to_line()))?;
|
||||
|
||||
let deadline = std::time::Instant::now() + SCRIPT_TIMEOUT;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Err(err) => return Err(format!("waiting on {}: {err}", command.to_line())),
|
||||
Ok(Some(status)) => {
|
||||
let mut out = String::new();
|
||||
if let Some(mut pipe) = child.stdout.take() {
|
||||
let _ = pipe.read_to_string(&mut out);
|
||||
}
|
||||
if status.success() {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut err = String::new();
|
||||
if let Some(mut pipe) = child.stderr.take() {
|
||||
let _ = pipe.read_to_string(&mut err);
|
||||
}
|
||||
return Err(format!(
|
||||
"{} failed ({status}): {}",
|
||||
command.to_line(),
|
||||
err.lines().next().unwrap_or("no output").trim()
|
||||
));
|
||||
}
|
||||
Ok(None) if std::time::Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
return Err(format!(
|
||||
"{} did not answer within {}s",
|
||||
command.to_line(),
|
||||
SCRIPT_TIMEOUT.as_secs()
|
||||
));
|
||||
}
|
||||
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One project to read, and where from.
|
||||
pub struct Target {
|
||||
pub key: String,
|
||||
pub project: PathBuf,
|
||||
pub declaration: Resources,
|
||||
}
|
||||
|
||||
/// The resources this server has read, by project key.
|
||||
///
|
||||
/// A facade over [`Checks`] in the same shape as `git::RemoteChecks` and
|
||||
/// `service::ServiceChecks`, so all three are started, counted and
|
||||
/// reported the same way.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct ResourceChecks(Arc<Checks<String, ResourceFacts>>);
|
||||
|
||||
impl ResourceChecks {
|
||||
/// What this project last said about itself. `None` before anything
|
||||
/// has been read, which is not the same as a project that declares
|
||||
/// nothing -- the caller knows which by whether there is a
|
||||
/// declaration at all.
|
||||
pub fn facts(&self, key: &str) -> Option<ResourceFacts> {
|
||||
self.0.answer(key)
|
||||
}
|
||||
|
||||
pub fn is_checking(&self, key: &str) -> bool {
|
||||
self.0.is_checking(key)
|
||||
}
|
||||
|
||||
pub fn error(&self, key: &str) -> Option<String> {
|
||||
self.0.error(key)
|
||||
}
|
||||
|
||||
/// Reads every target that isn't already being read. Returns at once.
|
||||
pub fn refresh(&self, targets: Vec<Target>) {
|
||||
for target in targets {
|
||||
let Target {
|
||||
key,
|
||||
project,
|
||||
declaration,
|
||||
} = target;
|
||||
self.0
|
||||
.start(key, move |_| Report::from(read(&project, &declaration)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets a project being removed, so its key cannot be answered
|
||||
/// after nothing points at it.
|
||||
pub fn forget(&self, key: &str) {
|
||||
self.0.retain(|held| held != key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_file_is_read_as_the_body_of_the_struct() {
|
||||
let dir = std::env::temp_dir().join(format!("resources-ron-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("resources.ron"),
|
||||
"// what this project calls itself\nname: \"ai-app\",\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let facts = read(&dir, &Resources::Ron(PathBuf::from("resources.ron"))).unwrap();
|
||||
assert_eq!(facts.name.as_deref(), Some("ai-app"));
|
||||
assert_eq!(facts.data, None, "not said is not a value to invent");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The project's file, so what this server does not recognise is
|
||||
/// somebody else's business rather than an error.
|
||||
#[test]
|
||||
fn keys_this_server_does_not_know_are_ignored() {
|
||||
let dir = std::env::temp_dir().join(format!("resources-extra-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("r.ron"),
|
||||
"name: \"ai-app\",\nmodelCache: \"~/models\",\nport: 8443,\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let facts = read(&dir, &Resources::Ron(PathBuf::from("r.ron"))).unwrap();
|
||||
assert_eq!(facts.name.as_deref(), Some("ai-app"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_says_which_file() {
|
||||
let err = read(
|
||||
Path::new("/nowhere-at-all"),
|
||||
&Resources::Ron(PathBuf::from("r.ron")),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("r.ron"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_is_read_from_its_output() {
|
||||
let dir = std::env::temp_dir().join(format!("resources-script-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("say.sh");
|
||||
std::fs::write(&script, "#!/bin/sh\necho 'name: \"computed\",'\n").unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
|
||||
let facts = read(
|
||||
&dir,
|
||||
&Resources::Script(crate::config::Command::from_line("./say.sh")),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(facts.name.as_deref(), Some("computed"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A failing script is a state the card has a word for, not something
|
||||
/// to fall back from.
|
||||
#[test]
|
||||
fn a_failing_script_reports_rather_than_answering() {
|
||||
let dir = std::env::temp_dir().join(format!("resources-fail-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("no.sh");
|
||||
std::fs::write(&script, "#!/bin/sh\necho 'nope' >&2\nexit 3\n").unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
|
||||
let err = read(
|
||||
&dir,
|
||||
&Resources::Script(crate::config::Command::from_line("./no.sh")),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("nope"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user