# 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 `