commit b0e83059a36737db0e4425a461b04c535961e74a Author: iris <2+iris@noreply.localhost> Date: Mon Aug 31 20:31:08 2026 -0400 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. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..d0f0a29 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "env": { + "ANDROID_HOME": "/home/bob/Android/Sdk", + "ANDROID_SDK_ROOT": "/home/bob/Android/Sdk", + "PATH": "/home/bob/Android/Sdk/platform-tools:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl:/usr/lib/rustup/bin:/home/bob/.local/bin" + } +} diff --git a/.dev-updater.ron b/.dev-updater.ron new file mode 100644 index 0000000..f56f03e --- /dev/null +++ b/.dev-updater.ron @@ -0,0 +1,47 @@ +// Read by Dev Updater -- this project's entry in its own list. Structured +// as the body of the config: no outer parentheses, so nothing here is +// indented for the sake of a wrapper. + +// What to call this project before there is a build to read a label from. +label: "Dev Updater", + +// Where this project's own resources live. Read off the request path and +// cached, because the `Script` variant would otherwise put a process +// spawn on the manifest -- see `server/src/resources.rs`. +resources: Ron("resources.ron"), + +// Walked in order, and the order is the point: the binary is built first +// so that a failing APK build leaves the phone the APK it already had +// rather than half of a matched pair. +// +// The server component is this process. That changes how it is *delivered* +// -- restarting means exec-ing the binary just built, not asking the +// script below to stop and start the code that is running (see +// `restart.rs`) -- but nothing about how it is declared, installed or +// reported on. The card is an ordinary one. +components: [ + Server( + name: "server", + // No cwd: this component is built and run from the checkout root, + // which is where the server looks for its own project. That is + // also what the generated unit takes as its working directory -- + // started anywhere else this server finds no checkout of its own, + // and its card silently loses the branch line, its commit count + // and the Pull button. + build: "cargo build --release --manifest-path server/Cargo.toml", + // Managed, like anything else that just wants its binary kept + // running. Nothing about restarting *this* server lives in the + // script -- that is `restart.rs`, which defers the hand-over past + // the reply and spawns it detached. So there was nothing left for + // a script of its own to say. + service: Managed("server/target/release/dev-updater"), + ), + Apk( + name: "app", + // The command resolves against the project root and `cwd` says + // where to run it -- two different things, which is why this is + // not `./build-apk.sh`. + build: "app/build-apk.sh", + cwd: "app", + ), +], diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e11f7fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +.gradle/ +build/ +app/androidApp/build/ +local.properties +.kotlin/ +*.iml +.idea/ +.DS_Store +server/target/ +server/dev-updater.log + +# Private key material. The server keeps this in $XDG_CONFIG_HOME by +# default; this catches a run pointed back into the repo with --certs. +certs/ + +# The added-apps list and the enrolled token hashes: machine-local +# absolute paths, and whatever this particular machine has been pointed +# at. Kept in $XDG_CONFIG_HOME by default, same as above -- this catches +# a run with --config. Nothing here is shareable. +config.ron + +# A test project fails its build while this exists; see test-projects/README.md. +break-the-build + +# Dead: the live list is config.ron, kept in $XDG_CONFIG_HOME. This one was +# an older format nothing reads, carrying one machine's absolute paths. +config.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d5f4f96 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/wg-app-link"] + path = vendor/wg-app-link + url = git@git.arirex.me:iris/wg-app-link.git diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cc6584f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,634 @@ +# dev-updater + +Serves locally-built debug APKs to a phone, and an Android app that +installs them. See `README.md` first for what this is and how to run it; +this file is the working notes on top of that. + +The central design point, worth not undoing by accident: **an app is a +project path, not a file path.** Everything downstream — which APK, which +package it replaces, its label, whether it's worth stripping — is derived +from what's actually built under that path, at add time or per request. +Nothing about any particular project is compiled in, and the app list is +mutable at runtime from the phone. + +## Layout + +- `vendor/wg-app-link/` — a **submodule**, and the shared half of this and + ai-app: the WireGuard binding, the CA the app pins, QR enrollment, + owner-only file creation, and the RON house rules. A submodule rather + than a published crate because it pins an exact commit, so the two + servers cannot end up on versions of it that disagree. Clone with + `--recurse-submodules`; `git::pull` runs `submodule update` after the + merge, because a merge moves the recorded pointer without touching the + submodule's working tree and the build would otherwise keep compiling + the old contents. + What stays here is what differs: this server's routes, registry, build + walk and service contract, plus `auth.rs`, whose middleware is generic + over each project's own state -- only the token functions underneath it + are shared. +- `server/` — Rust + Axum. `main.rs` is the bootstrap and the two + listeners; `routes.rs` has the whole HTTP table in its module doc + comment. `auth.rs` is the bearer token every TLS request carries, + applied once around the whole router so a new route cannot forget it. `registry.rs` owns the live app list and every mutation of it + (config writes funnel through `AppState::update`, so in-memory and + on-disk state can't come apart; the one other write is + `AppState::new`'s startup reconciliation, before anything can read the + list). `discover.rs` is the scanner, + `config.rs` the persisted schema and the RON both config files are in, + `apkinfo.rs` the `aapt2` reads, + `strip.rs` the slim-APK pipeline, `sdk.rs` the SDK/NDK tool lookups. +- `app/` — Kotlin + Compose, a single `:androidApp` module. `UpdaterScreen.kt` + is the list, `AddAppScreen.kt` the add/settings screen, `AppsApi.kt` the + management calls, `UpdateManifest.kt` the read side, `ApkInstaller.kt` / + `InstalledBuilds.kt` the download-and-install path, `DownloadServer.kt` + the transport and `Link.kt` the two values it hands the shared library. `Theme.kt` is Catppuccin Mocha + mapped onto Material's roles, plus the four `ActionTone`s buttons come in + -- red takes something away, the scheme's mauve replaces it with the same + thing, green brings it up, blue puts a new build on the phone. What a + button does is said in colour rather than by which component it sits on, + so the same consequence looks the same everywhere. The mapping that + matters is the surface ladder: Mocha names its darks in order (Crust, + Mantle, Base, Surface 0) and Material asks for the same thing under other + names, so the page is Base, a component's outlined card stays Base beside + it, and a project's card is Surface 0 -- one visible step, which is all + the nesting has to say. + `NerdIcons.kt` names the icon glyphs, + which are drawn as *text* in a small Nerd Fonts subset rather than + as vector assets -- an icon beside a line of text wants that line's size, + colour and baseline, and a `Text` gets all three for free. The font is + generated: add a codepoint in `NerdIcons.kt` **and** in + `app/build-icon-font.sh`, then re-run the script, or the glyph silently + isn't there. + +## Checking your work + +- Server, from `server/`: `cargo fmt`, `cargo clippy --all-targets`, and + `../run-tests.sh`. All three every time, not just when a change looks + big enough to warrant them. The build is warning-clean and + `cargo fmt --check` passes; keep both true. Formatting is plain rustfmt + defaults with no `rustfmt.toml` — layout is not something to decide per + line, so take what it gives rather than hand-formatting against it. +- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin` + (or `:androidApp:assembleDebug`), plus `./gradlew ktfmtFormat + ktfmtFormatScripts :androidApp:ktfmtFormat :androidApp:ktfmtFormatScripts` + and `./gradlew :androidApp:lintDebug`. Both are clean; keep them that + way. ktfmt is `kotlinLangStyle()` with nothing else configured, so + formatting is never a thing to decide per line. +- Neither replaces running it. `app/run-android.sh` builds, installs and + launches on an emulator; screenshot with `adb shell screencap -p + /sdcard/x.png && adb pull /sdcard/x.png `. Package + `com.example.devupdater`, activity `.MainActivity`. +- **`test-projects/` is what to point it at**, rather than a real project. + Four fake ones -- a plain APK, one building two variants, one whose build + fails on demand, and a `Server` + `Apk` pair with a service and a + `resources:` declaration. They exist because a real project only does what + it happens to do, where these can be asked to fail, to be slow, or to + declare nothing. That directory's own README says how they are reached + (they are deliberately not scanned) and why each one builds into an + `app/` subdirectory; the short version of the second is that two levels is + what `find_apks` matches, so a test APK any shallower is offered as a + build of *this* project. +- The server is easy to exercise directly, which is usually faster than + going through the UI — but it takes three things, and leaving any of + them out looks like the server being broken: + + ```sh + # The CA it actually presents is the one in its config dir, not the + # stale certs/ left in the repo. The address is wg0's -- it binds that + # and nothing else, so 127.0.0.1 refuses the connection unless the + # server was started with --bind. And every route on this port needs + # the enrolled bearer token. + curl --cacert "${XDG_CONFIG_HOME:-$HOME/.config}/dev-updater/certs/ca.pem" \ + -H "Authorization: Bearer $TOKEN" \ + "https://$(ip -4 -o addr show wg0 | awk '{print $4}' | cut -d/ -f1):8090/manifest" + ``` + + Only the token's SHA-256 is stored, so there is no reading it back out + of `config.ron`: either use one printed by `--rotate-token`, or run a + throwaway server with `--config`/`--certs` pointed somewhere temporary + and `--bind 127.0.0.1`, which prints a fresh token at startup. + +## Things that have bitten + +- **Don't add a general recursive search to `discover.rs`.** It was + measured and rejected; the numbers are in that module's doc comment and + in the README. Extend `APK_PATTERNS` instead. +- **`aapt2` and the strip pipeline never run on the manifest path.** A + process spawn or a zip walk there turns a 43 ms refresh into something a + phone notices, and the manifest is fetched on every open, resume and + Refresh. `aapt2` is add-time work cached in `config.ron`; stripping + belongs to the download alone, which is why the manifest reports the + size of whatever is on disk (`strip::serveable_now`) rather than + producing the slim copy to measure it. +- **Both config files are RON with two house rules**, and + `wg_app_link::format` is the only place that knows them -- ai-app's files + are in the same shape, which is why they are shared rather than described + twice. `config.rs`'s own `format` module is now only the migration hook + in front of it, and goes when that does. A file is the *body* + of the struct -- no outer parentheses, so nothing is indented for a + wrapper -- because RON has no implicit top-level struct; `parse` adds the + paren and `render` strips it. And `IMPLICIT_SOME` is set on the + deserializer rather than by a header each file would have to carry, which + is why every optional field also needs `skip_serializing_if` so nothing + is written back that nobody typed. The two halves only round-trip + together: change one and every file on disk still loads while only + looking wrong, which is why the config test asserts the written shape. +- **A `Server` component is driven through one script, run as + `