Every notification this app posts was on one IMPORTANCE_LOW channel, so all of them were silent -- including the row for a build that landed while nobody was looking, which is the one that carries the Install button and is the only thing here that is news rather than a report of something somebody just pressed a button for. A second channel rather than raising that one, because Android lets an app only ever lower an existing channel's importance: raising "work" in code would do nothing whatever on a phone that had already run this app, and the whole fix would be somebody finding it in Android's settings. The waiting row also has to undo the setOnlyAlertOnce every other row wants. It replaces the running row it grew out of -- same tag, same id -- and an update to a notification posted alert-once is silent however loud its channel is, which is exactly the arrival nobody is watching for. Measured on the API 36 emulator: pressing Update on a test project and going to the home screen posts the row on channel=ready, importance 3, with mSound resolved and mIsInterruptive=true, and the shade files it above its own "Silent" divider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1662 lines
106 KiB
Markdown
1662 lines
106 KiB
Markdown
# 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,
|
|
`script.rs` the one runner for a project's short declared commands
|
|
(`resources:`, `enroll:`) -- stdin closed, a deadline, stdout kept.
|
|
- `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 <local>`. 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
|
|
`<script> <subcommand>`.** `service.rs` knows the subcommands and the
|
|
four words `status` may print (`running`, `stopped`, `failed`,
|
|
`not-installed`); it knows nothing about systemd or OpenRC, and
|
|
shouldn't. Scripts run with stdin closed, because one that prompts would
|
|
hang rather than fail.
|
|
**On OpenRC, `status` reads the exit code and never the text**
|
|
(`0` started, `3` stopped, `32` crashed, anything else "could not find
|
|
out"). `rc-service status` prints its status line to *stderr*, so the
|
|
obvious check -- discard stderr, grep stdout for `crashed` -- throws away
|
|
the word it is looking for and reports a crashed service as `stopped`,
|
|
which is the exact lie the `failed` state exists to prevent. The codes
|
|
also distinguish "could not find out", which the text cannot: an
|
|
uninitialised user softlevel makes every call fail, and that is not a
|
|
state the service is in. Assign the code through `|| code=$?`, or `set
|
|
-e` kills the script before it can read one -- every answer but "running"
|
|
is a non-zero exit. Measured on OpenRC 0.63.3, not read.
|
|
**There is a guest to measure in**, at `~/vm/gentoo` on this machine --
|
|
`./start.sh`, then `./shell.sh 'cmd'`, ssh on 127.0.0.1:2223 as `tester`
|
|
with `guest_key`. Its own README is the reference. Everything in this
|
|
section, and the restart behaviour below, was run there rather than
|
|
reasoned about; do the same rather than trusting either the
|
|
documentation or a systemd result. Detecting OpenRC is itself a trap:
|
|
`rc-service --user --help` fails when `XDG_RUNTIME_DIR` is unset, so
|
|
probing with it first reports "no service manager here" on a machine
|
|
that has one.
|
|
- **Which script is what `service:` says**, and it is a sum:
|
|
`Script("path")` for a project that carries its own, `Managed("the
|
|
binary")` for one that wants dev-updater's built-in. Two variants rather
|
|
than two optional fields, because a component picks one and "both" and
|
|
"neither" would be states the file could express that mean nothing. The
|
|
built-in is `server/src/service-default.sh`, compiled in, written to
|
|
`$XDG_DATA_HOME/dev-updater/service-default` at every startup so an
|
|
updated dev-updater cannot be driving last version's copy, and handed
|
|
its identity as arguments (`--name <key>-<component> --exec "..."`) so
|
|
the subcommand the caller appends still lands last. `service::driver` is
|
|
the single place the two variants become one command -- everything
|
|
downstream takes a command and cannot tell which it was, which is what
|
|
lets a project switch between them without anything else changing.
|
|
It is deliberately a *script* rather than an in-process implementation:
|
|
a built-in that ran through Rust would put systemd and OpenRC knowledge
|
|
back in the one place it has been kept out of, and the contract would
|
|
become a special case of itself. **dev-updater declares `Managed` like
|
|
anything else**, and has no service script of its own: nothing about
|
|
restarting this server lives in a script, it lives in `restart.rs`, so
|
|
there was nothing left for one to say. Its component carries `cwd: ".."`
|
|
because the unit's working directory has to be the checkout root -- that
|
|
is where this server looks for its own app project, and started anywhere
|
|
else its card silently loses the branch line, its commit count and the
|
|
Pull button. `start.sh` knows none of this: it runs
|
|
`dev-updater --service <subcommand>` on the binary it has just built,
|
|
which derives the name and the script from the same declaration the
|
|
running server reads. One place decides how this service is named and
|
|
invoked, which is the only reason a bootstrap script cannot drift away
|
|
from `service::driver`.
|
|
A managed service is named `<key>-<component>`, because a service
|
|
manager's names are one flat namespace across every project on the
|
|
machine. Switching a project from its own script to `Managed` therefore
|
|
changes the unit's name: **uninstall the old one first**, or its unit is
|
|
orphaned with nothing left pointing at it.
|
|
- **Service state is checked off the request path**, in `ServiceChecks`
|
|
beside `git::RemoteChecks` and for the same reason: a process spawn per
|
|
server component on `/manifest` is exactly what that path must not do.
|
|
Which means it is subject to the same rule as a remote check: it lands
|
|
*after* the response that started it, so `checksPending` counts both, and
|
|
the phone waits on both (`ManifestEntry.checksOutstanding` for one card).
|
|
And the answering starts at startup rather than on the first request
|
|
(`main.rs`), so the window in which a component has no state is this
|
|
server's own start rather than something a phone has to sit through --
|
|
restarting is how this server is updated, which makes it the moment
|
|
somebody is most likely to be looking at the list.
|
|
Counting only remotes was a real bug and an invisible one: the state is
|
|
unknown only until the first answer of a server's life, remote checks
|
|
ordinarily outlast service ones, and the symptom is a component row with
|
|
no buttons -- so it showed up as "the buttons vanish after a restart if I
|
|
come back to the app too quickly", and only on whichever cards lost the
|
|
race. Nothing looked again until somebody hit Refresh.
|
|
- **dev-updater declares itself as an ordinary `Server` + `Apk` pair**, but
|
|
its server component *is this process*, and that changes one thing only:
|
|
*when* it restarts. Running the script's `restart` inside the walk would
|
|
kill it before the APK is built and before anyone is told how it went, so
|
|
the restart is deferred past `finish`; the Restart route defers for the
|
|
same reason, since a script that killed this process before it could
|
|
answer would make a restart that worked read as a failed request. Once
|
|
deferred, both go through the one function (`restart::deferred`), which
|
|
asks the manager when the service is up and only otherwise execs the new
|
|
binary itself. Going through the manager is what makes a refreshed unit
|
|
apply -- an exec inherits the unit this process was started with, so a
|
|
pull that changed the service script would otherwise land only at the
|
|
next restart by hand. The button used to exec unconditionally, on the grounds
|
|
that exec keeps the PID; that made it the one restart a rewritten unit
|
|
never applied to, which is the kind of surprise a special case buys.
|
|
Deciding it in one place is what stops the two drifting again. `BuildState::is_self` is the flag, and it is passed in
|
|
rather than declared -- a file that could claim it is a file that can
|
|
make this server exec an arbitrary binary. A build that changed nothing
|
|
skips the restart altogether (`restart::binary_unchanged`): exec-ing into
|
|
an identical binary drops the phone's connection to deliver the build it
|
|
already had. It is a `stat` of the binary against what it was at startup
|
|
rather than a hash -- cargo installs a new binary by renaming over the
|
|
old one, so the inode alone answers it, and reading tens of megabytes to
|
|
learn the same thing would cost more than the restart it avoids. It says
|
|
"changed" whenever it cannot tell, because a needless restart costs a
|
|
second while a skipped one leaves the old build serving and reads as the
|
|
update having silently failed. The Restart *button* is unconditional --
|
|
somebody asked. Stop and Uninstall do go
|
|
through the script, and do strand the phone; the app confirms them
|
|
rather than hiding them.
|
|
- **A component's freshness covers its own directory plus whatever it
|
|
declared with `alsoWatch`** (`Component::watched_paths`, handed to
|
|
`git::subtree_head` and `subtree_dirty` as pathspecs -- git takes
|
|
several, so it stays one call). Scoping to `cwd` is right about where a
|
|
component's files are and wrong about what its build reads: tdep-survey's
|
|
two clients each source one shared `scripts/android-sdk-env.sh`, so a fix
|
|
committed there changed what every build does, moved no component's
|
|
subtree head, rebuilt nothing, and left every card correctly reporting
|
|
"current" -- the narrower-question shape, where nothing is wrong and
|
|
nothing says so. Declared rather than inferred because which files a
|
|
build reads is not knowable from here, and both wrong guesses are
|
|
expensive: too wide rebuilds everything on every commit, too narrow is
|
|
that silence. It is part of the acceptance gate like every other
|
|
declared field -- `same_declaration` destructures exhaustively, which is
|
|
what forced the decision when the field was added.
|
|
- **A declaration that cannot be read says so on the card.** RON's
|
|
`deny_unknown_fields` discards the *whole* declaration rather than the
|
|
key it did not recognise, so a machine that takes a project's commit
|
|
before it takes a dev-updater new enough to understand it loses that
|
|
project's components, `strip`, `staleWhen` and resources at once -- and
|
|
the card does not look broken, it looks like a project that declares
|
|
nothing, which is the ordinary case. That was the only difference
|
|
between the two states: a line in this server's log, on a machine the
|
|
person holding the phone cannot read. `config::project_config` now
|
|
answers a `ProjectFile` carrying the parse error beside the
|
|
declaration, `AppEntry::declaration_state` returns both halves from one
|
|
read (the manifest wants both, and the file is on that path), and the
|
|
card draws its own sentence about what it means followed by the
|
|
parser's own words -- selectable, since the fix happens on the other
|
|
machine. Nothing is *blocked* by it: what was already accepted is what
|
|
runs, so an unrelated typo cannot stop a project that was working.
|
|
Worst on the self entry, where `reconcile_self` derives the row from
|
|
that file at every startup: unreadable, and this server's own card
|
|
silently loses its server component and its build commands rather than
|
|
merely stopping noticing changes.
|
|
- **The debug keystore is not `~/.android/debug.keystore`** -- that is the
|
|
last of five places, and `sdk::android_prefs_dirs` resolves it the way
|
|
AGP does, because the only thing that makes it the right key is being
|
|
the file Gradle signed with. Measured against AGP 32.3.2 by running real
|
|
builds: `$ANDROID_USER_HOME` is the preferences directory itself with no
|
|
`.android` under it, `$ANDROID_PREFS_ROOT` and `$ANDROID_SDK_HOME` get
|
|
`.android` appended, then `$XDG_CONFIG_HOME/.android` **but only when
|
|
that directory already exists** (the bytecode tests it and falls
|
|
through), then `$HOME/.android`. That fourth step is the whole bug: an
|
|
ordinary desktop Linux machine with `XDG_CONFIG_HOME` set signs its APKs
|
|
with a keystore this server never looked at, so it reported "no debug
|
|
keystore" about a machine that had one and was using it -- and this VM
|
|
sets no `XDG_CONFIG_HOME`, which is why the case could not appear here.
|
|
The failure names a keystore found further down the list, because an
|
|
inert one and an absent one look identical from a phone: mistaking one
|
|
for the other is what turned "this machine has none" into a signature
|
|
mismatch nobody could explain.
|
|
- **A stripped copy is only serveable if it is signed by the key the
|
|
build was.** `strip.rs` re-signs with `~/.android/debug.keystore`, and
|
|
nothing about a debug keystore says which builds it made -- it is per
|
|
machine, and one recreated after an APK was built signs as an entirely
|
|
unrelated certificate (measured: a fresh AGP-parameter keystore against
|
|
the existing one, no relation, as a new RSA keypair should be). Where
|
|
they differ Android refuses the package and the phone says "App not
|
|
installed" with no cause, which reads as the download rather than the
|
|
signing and is among the most expensive sentences here to be handed. So
|
|
the two are compared -- `apksigner verify --print-certs` on the source
|
|
and on what was just signed, as sets of digests so a v2 source and a v3
|
|
output still match -- and a mismatch is refused with both certificates
|
|
and the keystore path named. Measured on the files rather than inferred
|
|
from the keystore, so it stays true if the signing step changes. The
|
|
slim copy is deleted on refusal: with no stamp beside it nothing would
|
|
serve it, but `serveable_now` reports the size of whatever slim file is
|
|
on disk, so leaving it would have the card describing a download
|
|
nothing can install. Empty digests mean "not compared" rather than "no
|
|
signer", and say so in the log rather than refusing -- an APK apksigner
|
|
cannot read is one this comparison has no opinion about.
|
|
- **A 500 answers with its message.** `ApiError::Internal` used to be a
|
|
bare status with an empty body, on the grounds that an internal cause
|
|
is not safe to hand back -- but every route here is behind the bearer
|
|
token of a device somebody enrolled themselves, so there is no third
|
|
party to withhold it from, and what these actually say is which tool on
|
|
the build machine could not be found or would not run. Withheld, a
|
|
missing NDK reached the phone as "Server returned HTTP 500", with the
|
|
explanation in a log on a machine the person holding it cannot see; the
|
|
app has always shown a failure body when there is one, so the whole fix
|
|
was on this side. The log keeps the same `{err:#}` chain.
|
|
- **A remote git failure is shortened and explained in one place**
|
|
(`git::remote_failure`). Git's stderr runs to a paragraph of generic
|
|
advice, so the card gets the first line and the log gets all of it; and
|
|
when the failure was authentication, what this process can see of the
|
|
ssh agent is appended, because "works in my terminal, fails from the
|
|
service" is almost always `SSH_AUTH_SOCK` not being inherited by a
|
|
daemon, and that is invisible from a phone. Asked rather than assumed --
|
|
the note can say the agent is fine, which is what stops it being blamed.
|
|
- **A branch with no upstream is not a broken one.** `can_pull` requires an
|
|
upstream as well as a checkout, which is what stops the card reporting
|
|
the same fact twice -- once as its own note and once as the check's
|
|
complaint about it -- and stops it offering a Pull that `git::pull`
|
|
would refuse. It is also not worth *saying*: plenty of checkouts have no
|
|
remote deliberately, and a card that mentions it every time is nagging
|
|
about a choice somebody made.
|
|
|
|
- **A checkout can be moved from the phone, and it is nearly the same act
|
|
as a pull.** The project settings sheet lists the checkout's branches and
|
|
its last fifty commits, and picking one moves the checkout.
|
|
`BuildState::after_moving` is the half a pull and a checkout share -- one
|
|
copy, because the part that is easy to get wrong is not the git command
|
|
but handing the run over under a single lock, so nothing can observe the
|
|
moment between the move ending and the builds it decided on starting. It
|
|
reports through the project's own state for the same reason: one progress
|
|
path, not a second to keep in step.
|
|
**Where they differ is that a move builds nothing** (`then_build` is
|
|
`None`, which is what `BuildAfterMoving` exists to say). A pull is
|
|
somebody taking new work, so building it is the point; moving the
|
|
checkout is somebody *looking* -- at another branch, at last week's
|
|
commit -- and charging a full build of every component for a look means
|
|
an accidental tap starts a minute of work with no way to cancel it, and
|
|
replaces outputs that were wanted. What it leaves behind is a component
|
|
whose build no longer matches the checkout, which the card already says
|
|
and already carries the button for. Iris asked for this on 2026-09-02.
|
|
Which is why **the build button's word follows the state**: `buildWord`
|
|
in `UpdateManifest.kt` says `Rebuild` only where every component it
|
|
covers is *known* current, and `Build` otherwise -- behind, never built,
|
|
or parked on a commit nobody measured the output against. One rule at
|
|
both scales, so the project row and a component row cannot come to
|
|
disagree about what pressing them means; the project's asks only about
|
|
the components it actually builds, since one with no command has no
|
|
output to be current with anything and counting it would pin that button
|
|
to `Build` for ever.
|
|
The two lists are read by `GET /apps/{key}/refs` when the sheet is
|
|
opened, never on the manifest -- both spawn git, and both are **local
|
|
reads**, so opening the sheet cannot stall on a round trip or fail the
|
|
way a remote check can.
|
|
Two things about listing branches were wrong until a real checkout was
|
|
looked at, and neither shows up in a fixture built to pass:
|
|
`git branch --format` adds a **`(HEAD detached at abc123)` pseudo-entry**
|
|
that is not a branch and cannot be checked out, so the list comes from
|
|
`for-each-ref refs/heads`; and `refs/remotes/origin/HEAD` abbreviates to
|
|
a bare **`origin`**, so the obvious filter for a name ending in `/HEAD`
|
|
matches nothing and a phantom branch called `origin` reaches the phone.
|
|
It is dropped by *being* a symref (`%(symref)` non-empty) instead. The
|
|
test that was meant to cover the second asserted the same wrong thing
|
|
and passed.
|
|
|
|
- **Fetch is a button, because nothing else here writes a
|
|
remote-tracking ref.** The only `git fetch` this server ran was the one
|
|
inside a pull, and a pull is reachable only when the branch you are
|
|
already on is behind -- so a branch pushed from another machine reached
|
|
the picker only as a side effect of pulling something else, and a project
|
|
already up to date could never be moved onto a new branch at all. The way
|
|
out was a terminal on the build machine, which is where the person
|
|
holding the phone is not. `POST /apps/{key}/fetch` is the fix, sitting
|
|
under the two pickers in the project settings sheet. Deliberately still
|
|
not on a timer and not on opening the sheet: a fetch mutates the checkout
|
|
and pulls down objects, so it stays something somebody pressed, and
|
|
`/refs` stays the local read described above.
|
|
Both pickers sit in a `SettingRow`, which weights the label *and* the
|
|
control rather than letting the control take what it likes: unweighted,
|
|
a pill showing `second-branch-from-elsewhere` squeezes "Branch" into a
|
|
three-character column that wraps one letter per line. The same rule the
|
|
component rows already follow, in the other place a row mixes text with
|
|
a control. The fetch failure and the "reading branches" note go
|
|
*below* both pickers for the same family of reason -- above them, each
|
|
appears and disappears mid-sheet and shoves the pickers down the screen
|
|
as somebody is reaching for one, and the failure ends up nowhere near
|
|
the button that produced it.
|
|
It answers with the **whole ref list** rather than an acknowledgement, so
|
|
the pickers repopulate from after the fetch in the same round trip --
|
|
`routes::read_refs` is the one place both routes build that list.
|
|
`--prune` is asked for here and nowhere else: this is the call whose job
|
|
is to make the picker say what the remote says, where a pull should
|
|
change as little as it can. The phone's read timeout for it is longer
|
|
than the server's own 30s hard stop for a remote command, so a slow
|
|
remote is reported in git's words rather than as the phone giving up on a
|
|
request that is still running. While it runs, both pickers are disabled
|
|
-- it is replacing the lists they are showing -- but uncommitted work is
|
|
*not* a reason to disable it, since a fetch touches no file in the
|
|
working tree. It is the one control in that sheet that still works on a
|
|
dirty checkout.
|
|
|
|
- **`git checkout origin/topic` detaches HEAD**, which is why
|
|
`git::local_branch_for` exists. Git's DWIM that starts a tracking branch
|
|
fires on the bare name `topic` and on nothing else, and the picker's
|
|
remote-only entries are named `origin/topic` because that is what they
|
|
are -- so picking a branch left the checkout on no branch, with no
|
|
upstream and so no Pull: the state picking a *commit* is meant to
|
|
produce, reached from the control that says it is picking a branch.
|
|
Nothing about it looks like a failure either, since `git checkout`
|
|
prints its detached-HEAD advice and succeeds. Found by running it
|
|
against a real clone once the Fetch button made remote-only branches
|
|
something you could actually reach.
|
|
|
|
- **The checkout guard is deliberately weaker than the pull guard, and
|
|
that is what stops it being a one-way door.** `git::checkout` refuses
|
|
only on **tracked** modifications (`--untracked-files=no`), where
|
|
`git::pull` refuses on any dirt at all. With the strict check, moving
|
|
back to a commit from before the `.gitignore` that covers this
|
|
project's build output leaves that output sitting there untracked, the
|
|
tree is dirty, and every move afterwards is refused -- you can go back
|
|
and you cannot come forward, from a phone, with the way out being the
|
|
build machine. Nothing is given up by relaxing it, because git makes
|
|
the better check itself: `git checkout` refuses when an untracked file
|
|
would be overwritten and carries across the ones that would not, and
|
|
its refusal arrives as the error the card shows. So this guard covers
|
|
work somebody typed and git's covers the files it would clobber.
|
|
Found by moving a real checkout back and forth rather than by reading
|
|
the code; the first version passed its tests and trapped the checkout
|
|
on the second move.
|
|
|
|
- **A detached HEAD is a state the card has to say out loud.** Picking a
|
|
commit leaves the checkout on no branch, `git::status` reports the
|
|
branch as the literal `HEAD`, and `can_pull` correctly goes false
|
|
because there is no upstream. The card draws that as **"no branch"**
|
|
rather than `HEAD`, which beside a branch icon reads as a branch
|
|
somebody named HEAD -- worth doing now that the sheet can produce the
|
|
state, where before it was only reachable on the build machine.
|
|
|
|
- **A checkout parked on a chosen commit is not one to call out of
|
|
date, and the way back is `HEAD`.** Two halves of the same fact.
|
|
The commit list is read from the **branch** (`git::head_branch`, then
|
|
`git log <branch>`), not from HEAD: listed from HEAD, the commits
|
|
*after* the parked one are not in the list, so the picker that moved
|
|
the checkout back could only move it further back -- the same one-way
|
|
door the tracked-only dirty check was written to close, in a place
|
|
that check does not reach. `head_branch` answers the branch a
|
|
checkout is on, or, when it is parked, the single local branch
|
|
containing that commit; `None` when several contain it or none does,
|
|
because there is then no such thing as *the* branch and choosing one
|
|
would be this server deciding which history somebody meant. It rides
|
|
on `/refs` as `head`, and the picker's `HEAD` entry moves there --
|
|
shown as the *current* value while the checkout is following that
|
|
branch, which is what "not pinned to anything" looks like. A checkout
|
|
parked further back than the window gets its own commit appended, so
|
|
the picker never has nothing to show for where it is.
|
|
And `freshness` takes a `parked` flag, reporting a differing commit as
|
|
**unknown** rather than `Behind` while the checkout is detached *past*
|
|
what was built: every way it reads as behind while parked is a build
|
|
that failed, a declaration waiting to be accepted, or a component with
|
|
no build step, and each of those already says so on the same card
|
|
beside the button for it -- so "out of date" there is either redundant
|
|
or a nag about a decision somebody made. Unknown rather than current,
|
|
because nothing measured the output to be what was wanted.
|
|
`component_is_stale` is deliberately *not* changed: Update and Rebuild
|
|
still rebuild a parked checkout, which is the whole point of parking
|
|
one. The flag is passed down from `describe` rather than read per
|
|
component, since git has already been asked for that project's status
|
|
once and `/manifest` is fetched on every open, resume and Refresh.
|
|
|
|
- **The other direction is reported, and its word is Downgrade.** A
|
|
checkout parked *behind* its build is somebody rolling back, and
|
|
withholding that was the whole of why a rollback could not be finished
|
|
from the phone: picking an old commit detaches HEAD, so every rollback
|
|
is parked by construction, and the card answered the move with "up to
|
|
date" and nothing to press (Bryan, 2026-09-06). `git::is_ancestor`
|
|
asks which of the two commits is the later one -- one extra process,
|
|
and only where they already differ -- and `Freshness::BuiltAhead` says
|
|
the build is. It is stale like `Behind`, so the card leaves "Up to
|
|
date" and both Update buttons come alive; `updateWord` then says
|
|
**Downgrade**, at both scales and by one rule, the way `buildWord`
|
|
does. Nothing else about the press differs, which is what Bryan asked
|
|
for. The row says "newer than the checkout" rather than "out of date",
|
|
which is the wrong direction.
|
|
Two things fell out of it. `UpdateButton` asks about the direction
|
|
*before* comparing file times, since what is on disk is older than
|
|
this phone's copy right up until the press rebuilds it -- otherwise a
|
|
card with a rollback waiting says "Reinstall". And a project-wide
|
|
Update now does the build-and-install half alone when there is nothing
|
|
to pull *from* -- a parked checkout, a branch tracking nothing, or
|
|
`gitPull` off -- rather than failing on a pull it was never going to
|
|
make, which is what a downgrade press used to hit.
|
|
|
|
- **Unrelated histories are the one pull failure the phone may override.**
|
|
A checkout sharing no commit with its upstream has no fast-forward and
|
|
never will, so with nothing offered the card is one that can never be
|
|
pulled again -- and the way out is on the build machine, which is
|
|
exactly where the person holding the phone isn't. So `?force=true` on
|
|
the pull route resets onto the upstream instead of merging, behind a
|
|
dialog that names the branch it is about to overwrite (the only place
|
|
that is seen before it goes). Whether this *is* that failure is decided
|
|
structurally -- `git merge-base` finding no common ancestor -- and never
|
|
by matching what git printed: those messages are translated, and a
|
|
button that appeared only on an English build machine is worse than no
|
|
button. It travels as `PullError::unrelated_histories` rather than
|
|
inside the message for the same reason. The dirty-tree refusal stays in
|
|
front of it, so a forced pull can only ever discard something that was
|
|
committed, and a merely *diverged* history is not offered it -- there is
|
|
something better than throwing that away.
|
|
|
|
- **The branch line is how a failed remote check is visible at all.** With
|
|
it gone, `newCommits` stays false and the card reads as an app with no
|
|
updates -- the same silent failure the self-entry note below describes.
|
|
It was deleted once by accident in a refactor and nothing failed; only
|
|
the display went quiet.
|
|
|
|
- **Forcing git onto IPv4 is done in `GIT_SSH_COMMAND`, not on the git
|
|
subcommand.** `git fetch` takes `-4`; `git ls-remote` does not, and
|
|
answers "unknown switch `4'". `ls-remote` is what the new-commits check
|
|
runs, so splicing the flag onto every remote command turned the setting
|
|
into a switch that broke every card's commit count -- which is how it
|
|
was first written, and what `git::ssh_command` and
|
|
`forcing_ipv4_does_not_break_the_check` now hold in place. `fetch` keeps
|
|
the flag as well, since that is what carries the preference to an https
|
|
remote; ssh is covered either way.
|
|
|
|
- **This server's own project is a row in `config.projects` like every
|
|
other, and everything in that row except `gitIpv4` is re-derived at
|
|
startup** by `registry::reconcile_self`. The row is what gives a
|
|
per-project setting one home -- it used to have to be keyed in a
|
|
separate `config.settings` list, because the one card that is always in
|
|
the list was the one card the setting could not be a field on. What is
|
|
derived stays derived: the path is the working directory (see below),
|
|
and the label and components come from the checkout's own
|
|
`.dev-updater.ron`, so a re-clone or a moved repo fixes itself at the
|
|
next start instead of leaving the row naming a directory nobody pulls.
|
|
Only what somebody chose is carried across. The declaration is accepted
|
|
by construction there, which is why `AppEntry::pending_declaration`
|
|
answers nothing for it: between a pull that rewrites the declaration and
|
|
the restart that follows, the row and the file can differ without that
|
|
meaning anything is waiting to be accepted. A config still carrying the
|
|
old keyed `settings` list loses it silently -- `Config` ignores fields it
|
|
does not know -- so a machine that skips straight past the version that
|
|
migrated one has its `gitIpv4` to set again.
|
|
|
|
- **A component has two kinds of log, and the kind is an axis of its
|
|
own.** The build log is what this server captured while building it; the
|
|
runtime log is what the component wrote while running, reported by its
|
|
service script. They are asked for one kind at a time (`?kind=`), and the
|
|
modal gives each a tab -- opening on runtime, since what a service is
|
|
doing now is the usual question, and on build for the component a build
|
|
stopped at (`build_failed`). They were one list indexed by generation
|
|
once, and because build logs came first the runtime one sat at an index
|
|
nothing ever asked for: unreachable, and silent about it. ANSI escapes
|
|
are rendered rather than stripped (`AnsiLog.kt`) -- the colour is how a
|
|
process marked its own errors -- and sequences with no meaning on a
|
|
phone are consumed rather than printed, so a cursor movement cannot
|
|
arrive looking like a corrupted log.
|
|
- **An APK's runtime log comes from the phone, through a contract any
|
|
managed app can implement.** A `Server` runs here and its script says
|
|
where it writes; an `Apk` runs on the phone, where Android forbids one
|
|
app reading another's `logcat` -- so the app keeps a bounded copy of its
|
|
own log and exposes it through a `ContentProvider` at
|
|
`<applicationId>.devlog`, guarded by `dev.updater.permission.READ_DEVLOG`
|
|
(declared here, `protectionLevel="normal"`). README.md has the whole
|
|
contract; what matters here is the shape of it.
|
|
**One kind, two sources**: `LogKind::Runtime` stays one tab and one
|
|
route, and `AppEntry::component_logs` picks the source from the
|
|
component's variant. That is deliberately not a third kind -- the reader
|
|
is asking the same question either way, and a `hasDevLog` beside
|
|
`hasRuntimeLogs` would have been the same axis said twice.
|
|
The phone forwards rather than rendering the provider directly, into
|
|
`POST /apps/{key}/components/{name}/runtime-log`, so the history
|
|
outlives the phone and both kinds render from one store. **This server
|
|
parses nothing**: what arrives is one line of text each, formatted on
|
|
the phone, appended by `logs::append_devlog` -- the same posture it
|
|
takes towards a service's stdout, and what keeps every managed app's log
|
|
format out of here. Refused for a `Server`, because a file with two
|
|
writers is two versions of what a service printed. The store is
|
|
`$XDG_DATA_HOME/dev-updater/devlogs/<key>-<component>.log` with one
|
|
rotation at 4 MiB (`.1`, the same two generations a build log has, and
|
|
the only bound available -- a phone forwards a stream with no run
|
|
boundary to rotate on).
|
|
**`normal` rather than `signature` is a real trade and is written down
|
|
in three places** (the manifest, README.md, and ai-app's
|
|
`docs/DECISIONS.md` 2026-09-07): the two apps are signed with different
|
|
locally generated keys, so a signature permission would be held by
|
|
nothing, and what `normal` costs is that any app requesting it by name
|
|
can read another's dev log on that phone.
|
|
**What this replaced**: ai-app's app used to post its ring to
|
|
`ai-server` over the tunnel, which re-emitted it into its own `tracing`
|
|
output and so into the *server* component's runtime log. Nothing here
|
|
had to change for that, which was its whole appeal -- but it put a
|
|
phone's lines under a different component, needed the app to be enrolled
|
|
against a server with a baked-in token, and only ever worked for that
|
|
one project. It is deleted; their reasoning is in ai-app's
|
|
`docs/DECISIONS.md`, 2026-09-07.
|
|
- **A project's row has three buttons -- Pull, Update, Remove -- and the
|
|
first two are the same act at two lengths.** Pull takes the commits and
|
|
stops (`?build=false` on the pull route, `BuildState::pull_only`);
|
|
Update pulls, builds what that brought in, and installs every APK it
|
|
produced. Iris asked for exactly this pair on 2026-09-02, replacing
|
|
"Pull & Build" beside a project-wide Rebuild. The point of splitting
|
|
them is that bringing a checkout up to date is cheap and something
|
|
somebody does while looking, where building and installing is the thing
|
|
they press when they want it on the phone -- the same reasoning that
|
|
stopped a checkout building.
|
|
Update is composed in the app out of the two things it already does
|
|
rather than given a route of its own: the pull-and-build reports in the
|
|
project's row exactly as Pull does, and each install reports in its own
|
|
component's row exactly as that component's own Update does. It skips a
|
|
component whose build just failed -- that row already says so, and
|
|
asking again would run the same failing command to say it twice -- and
|
|
it hands the installer **one** APK at a time. An install intent is
|
|
modal and takes the screen, so a project building two APKs would
|
|
otherwise have the second intent replace the first and one component
|
|
would silently never be installed; the rest wait in
|
|
`ComponentState.ReadyToInstall`, which `continuePendingInstalls`
|
|
offers when the screen comes back, through the same machinery and the
|
|
same two triggers as a download waiting for a wrongly-signed copy to be
|
|
removed.
|
|
A component's own install button says **Install** rather than "Build"
|
|
where nothing is built yet: with the right-hand button now saying
|
|
"Build" for a component that is not current, the two read as one word
|
|
meaning two things in the same row. It is named for what you end up
|
|
with, which is also the half it has that the Build beside it does not.
|
|
The project-wide Rebuild is gone with it (each component still has its
|
|
own), and with it `ProjectState.Rebuilding` -- the two remaining
|
|
project-wide states collapsed into one `Working(what, status)` that
|
|
carries its own words, because Pull, Update and moving the checkout
|
|
differ in nothing but the label and the old one said "Pulling and
|
|
building" for all three.
|
|
|
|
- **"This server has never built it" is something to build, not
|
|
something to sit on** -- and getting that backwards made it the one
|
|
state nothing could clear. `component_is_stale` treated a missing
|
|
`built_from` as no evidence of staleness, so Update built nothing, so
|
|
no build ever wrote the record: a card reading "not built here" with
|
|
Pull, Update and the project's own button all doing nothing about it,
|
|
for ever. Worst for a `Server`, which the output check beside it
|
|
deliberately skips (it has no APK to look for), so nothing else could
|
|
rescue it. Measured on a two-component fixture rather than argued:
|
|
`/prepare` answered `components: []` and the freshness was unchanged
|
|
afterwards; with the fix the same call reports `step: "building"` and
|
|
the card reads `current`.
|
|
So `nothing_built` now asks the record as well as the output, and the
|
|
cost is one rebuild per component on a machine whose records were lost
|
|
-- which was the objection when it was written the other way, and is
|
|
the right price now that the state is *visible*. It is self-clearing:
|
|
the first build writes the record and the question is never asked
|
|
again.
|
|
Which leaves `NotBuiltHere` meaning exactly one thing: a component with
|
|
**no build command**, where nothing here was ever going to measure
|
|
anything and no button would change it. That is the whole difference
|
|
between it and `NeverBuilt` -- one is actionable and flagged, the other
|
|
is quiet, and they were one word covering both.
|
|
`nothing_built` takes the record map rather than reading it: a `lock()`
|
|
temporary inside a call's argument list stays held for the whole call,
|
|
and a test helper doing exactly that deadlocked the moment anything
|
|
under it wanted the same lock.
|
|
|
|
- **Freshness says *which* answer it is, and only three of them read as
|
|
out of date.** `Freshness` was `current | behind | unknown`, and the
|
|
card drew unknown as nothing at all -- so a component this server had
|
|
never built looked exactly like one whose build was current, and the
|
|
only difference lived in a config file on the other machine. That
|
|
silence cost two rounds of "why isn't it flagging anything" that no
|
|
amount of looking at the phone could answer. It now names the state:
|
|
`behind`, `builtAhead`, `neverBuilt` and `otherMode` are measured and
|
|
have something to do about them; `uncommitted`, `notBuiltHere`, `noCheckout` and
|
|
`parked` are the ways of not knowing, and each says so in the row in
|
|
the ordinary text colour -- words, not a colour, because "we could not
|
|
check" is a difference in kind from "there is something here".
|
|
`ProjectComponent.isStale` is the one place the division is written
|
|
down, and it decides all three of: whether the row says "out of date",
|
|
whether the card sits above the "Up to date" heading, and whether
|
|
Update is pressable. Iris's rule for it, 2026-09-02: "it should not do
|
|
that until things actually can be updated."
|
|
|
|
- **Two places rewrite a project's components list, and one type says
|
|
what survives it.** `AppState::approve_declaration` and
|
|
`registry::reconcile_self` both replace `components` wholesale with
|
|
what the checkout declares -- right for everything the project asked
|
|
for, wrong for the three things this machine knows: the package read
|
|
out of an APK, the mode somebody chose here, and the commit and mode a
|
|
build recorded. `CarriedOver` / `carried_over` / `restore_carried_over`
|
|
hold all three, keyed by name *and* `cwd` like every other
|
|
carry-across here, and both callers take the whole thing.
|
|
It is one type because it was three collect-and-restore pairs
|
|
remembered separately, and each caller forgot a different one.
|
|
`reconcile_self` dropped `builtFrom`, so the commit written down by the
|
|
build that produced the new binary was forgotten by the process that
|
|
build started -- and restarting is how this server takes an update, so
|
|
its own card had no freshness ever again: never behind, never current,
|
|
and its Update with nothing to do, since both readings need a commit to
|
|
compare against. Then `approve_declaration` turned out to drop the same
|
|
field, which is worse because it hits *every* project: accepting a
|
|
declaration told every one of that project's cards it had never been
|
|
built here. Silent both times, because "not built here" is a true
|
|
sentence about plenty of projects and so reads as an answer rather than
|
|
as a loss -- it was found by making freshness say which kind of unknown
|
|
it was and then looking at a card that had no business saying that one.
|
|
Adding a field to a component now means adding it here once.
|
|
`reconcile_self` sets the built-in row's package *after* the restore,
|
|
so what is derived wins over an older row carrying something else.
|
|
|
|
- **One predicate decides both the "Up to date" heading and whether
|
|
Update can be pressed** (`hasWorkWaiting` in `UpdaterScreen.kt`): they
|
|
are the same question, since Update is the button that clears
|
|
everything the heading is about, and a card filed under "Up to date"
|
|
above a pressable Update is the list disagreeing with itself. Three
|
|
ways to have work waiting -- commits on the remote, an APK newer than
|
|
the copy installed here, and **a component whose build no longer
|
|
matches the checkout**. The third was missing, so switching branches
|
|
from the phone left the card sitting under "Up to date" with only the
|
|
small note in one component's row to say otherwise. Iris found it by
|
|
switching ai-app onto another branch and seeing nothing flagged.
|
|
|
|
- **The list follows a card that an action moved, and a checkout waits
|
|
for the answer before it stops.** Two halves of the same press, both
|
|
Iris's, 2026-09-02. Switching a branch makes the build out of date,
|
|
which moves the card out of "Up to date" and somewhere else entirely
|
|
on a list of ten: `followCard` scrolls to it, but only when it is
|
|
actually off screen, since moving something the reader can already see
|
|
is a jump they did not ask for. It waits two frames first -- the entry
|
|
has just been applied, so the first frame carries the composition and
|
|
the second reports the layout it produced; reading the position before
|
|
both have passed gives where the card *was*. Positions come from an
|
|
`onGloballyPositioned` per card (`CardPlace`) rather than from the
|
|
order, because the cards are different heights and the headings count
|
|
too.
|
|
And `startCheckout` now waits on `awaitCheck` afterwards. The build
|
|
machine drops what it knew about that checkout's remote and asks again,
|
|
but the answer lands *after* the response -- so with a single read the
|
|
card kept a pending check for ever: no commit count, Pull disabled, on
|
|
a branch with commits waiting. That was the whole of "it doesn't show
|
|
any updates or the ability to pull even tho there's obviously new
|
|
stuff", reproduced against a clone of ai-app: immediately after the
|
|
move `newCommits: false, checkPending: true`, three seconds later
|
|
`newCommits: true` -- with nothing asking again in between.
|
|
|
|
- **A moved checkout re-asks its remote, because the last answer was
|
|
about the branch it left.** `RemoteChecks::recheck` forgets the cached
|
|
answer and starts a fresh check, called from `move_checkout` where the
|
|
thing that invalidated it happens. Without the forgetting the card
|
|
answers for the previous branch with `checkPending` false -- so a
|
|
branch one commit behind its upstream reads as one with nothing to
|
|
pull, and Pull is disabled about it with nothing on screen saying the
|
|
answer predates the move. Measured, not argued: git said `[behind 1]`
|
|
while the card said `newCommits: false`. `Checks::forget` is the
|
|
primitive, and it deliberately leaves `in_flight` alone for the reason
|
|
`update` does -- a worker already running still owns the right to write
|
|
its answer.
|
|
|
|
- **There are three sizes of refresh, and using the wrong one is what
|
|
makes the list feel like it has a mind of its own.** `refresh()` drops
|
|
the list to a spinner and asks every remote -- it belongs to arriving,
|
|
resuming, and the Refresh button, which are the moments somebody asked
|
|
to be shown the current state. Pull-to-refresh is the same ask without
|
|
the spinner, since the gesture brings its own indicator and taking the
|
|
list away underneath it would say the same thing twice. `applyOne` reads `GET /apps/{key}`
|
|
and puts that one entry back, so an action on one card cannot move or
|
|
change any other; every card action uses it, including adding one --
|
|
the Add screen hands back the key and the list fetches just that app.
|
|
`awaitCheck` is the middle one: ask a single checkout's remote and wait
|
|
on *that* answer, which the card's own refresh control and a freshly
|
|
added app both use. Waiting on the whole list to settle would make one
|
|
card's refresh sit behind another card's.
|
|
Arriving and resuming are **one** event, not two: `LifecycleResumeEffect`
|
|
runs when the screen first reaches RESUMED, so there is no
|
|
`LaunchedEffect(Unit)` beside it and one thing decides when the list is
|
|
read. It refreshes from *any* state including a failed one -- guarded on
|
|
"loaded", as it used to be, a card that had gone red stayed red until
|
|
somebody found the Refresh button, which is the opposite of what
|
|
returning to an app should do. `loadingList` is what stops the first
|
|
composition and the first resume stacking two reads, and it is claimed
|
|
*before* the coroutine launches, because both run in the same frame and
|
|
a flag set inside the coroutine is set too late to be a guard.
|
|
|
|
- **A failure that lands while nobody is looking is not shown.** Work
|
|
started before the app went away keeps running -- deliberately, since a
|
|
download that finishes in the background is a download that worked -- and
|
|
when the device sleeps or the link drops it fails. Reported, that meant
|
|
coming back an hour later to a five-second read timeout that said nothing
|
|
about the server and that there was nothing left to do about. So every
|
|
catch in `UpdaterScreen.kt` goes through `failure(e)`, which answers null
|
|
while the screen is not resumed, and **null means clear, never leave** at
|
|
every site -- a dropped failure that left the card alone would leave a
|
|
spinner up for an operation that has already stopped. `setProject` and
|
|
`setComponent` both take a nullable state so the answer can be handed
|
|
straight over. Iris asked for this on 2026-09-01: "I got a socket timeout
|
|
by leaving the app for too long. Make sure not to show that if the app
|
|
just gets unloaded."
|
|
The other half of the same complaint is the read on the way back *in*:
|
|
the link may have been asleep as long as the app was, and the first
|
|
request across one still coming back times out at the five seconds every
|
|
request gets. So the resume's read alone retries once (`afterAGap`),
|
|
which costs nothing when the server really is down -- a refused
|
|
connection comes back at once rather than waiting out a timeout. That is
|
|
a retry, not a guess: nothing is displayed that was not measured.
|
|
Dropping a failure is only safe *because* the resume re-reads from any
|
|
state; the two changes hold each other up, and undoing either alone
|
|
leaves the list stuck on a spinner or stuck on a stale error.
|
|
|
|
- **The Add screen is drawn over the list, not in place of it.** Swapped
|
|
out, the list is composed again from nothing on the way back -- and
|
|
rebuilding it means fetching it, which is the whole-list refresh that
|
|
adding an app has no business causing. `describe` in `routes.rs` is the
|
|
one place a card is built, so `/manifest` and `GET /apps/{key}` cannot
|
|
come to describe the same app differently.
|
|
|
|
- **A progress bar is drawn from counts the build reports, never from an
|
|
estimate**, and the knowledge of how to get those counts belongs to this
|
|
server rather than to each project. Two ways in, both ending as
|
|
`@@progress done/total` on the build's output:
|
|
cargo reports its own once `CARGO_TERM_PROGRESS_WHEN=always` is set,
|
|
which `spawn` sets for every build command, so a cargo build needs
|
|
nothing and must not be wrapped. Gradle cannot -- an init script is
|
|
refused by the configuration cache, and its rich console reports a
|
|
percentage only as a full-screen redraw that would make the output
|
|
unreadable -- so `server/src/build-progress.sh` does the `--dry-run`
|
|
count and the `> Task` tally, shipped by this server (`crate::shipped`)
|
|
and offered to build scripts as `$DEV_UPDATER_PROGRESS`. A script tests
|
|
for the variable rather than depending on it, so building by hand still
|
|
works. This is what stops each project carrying its own copy of the
|
|
same twenty-five lines.
|
|
Reading the output means splitting on carriage returns as well as
|
|
newlines: a tool redrawing a counter in place puts several updates and
|
|
then real output inside one `\n`-delimited line.
|
|
- **Work in flight holds a foreground service, and the notification is
|
|
what that costs and what it is for** (`WorkNotice.kt`). Everything this
|
|
app asks for is minutes of the build machine's time followed by a
|
|
download, and all of it runs in the list screen's own coroutines -- so
|
|
with the app in the background the process is an ordinary cached one and
|
|
Android is free to take it away in the middle of a build. A foreground
|
|
service is the only way to say otherwise; measured on the emulator, the
|
|
process sits at `fg-service-act` (oom adj 50) with the app at the home
|
|
screen, where it would otherwise be cached, and the builds it started
|
|
landed while it was there.
|
|
The work itself stays in the screen. The service runs nothing: it
|
|
collects a list of what is running and reposts one notification, and
|
|
stops as soon as the list is empty. That list is **derived** by
|
|
`workItems` from the same `projectStates`/`componentStates` the cards
|
|
are drawn from, rather than posted by each action that starts something,
|
|
so there is nothing to forget to post and nothing to forget to take
|
|
back, and the shade cannot come to claim something the card does not.
|
|
The words are shared rather than written twice: `componentWorkLine` and
|
|
`buildLine` are read by the card's own bars and by the notification, for
|
|
the reason the note about two words for one measurement gives.
|
|
**There is one notification per thing running, not one for all of
|
|
them**, grouped so they arrive together -- which is what the shade is
|
|
for, and what lets a component that finishes take its own row down while
|
|
its sibling carries on. Rows are keyed by a *tag* (`<key>/<component>`)
|
|
rather than by an id, so an update replaces the row it is about and two
|
|
components cannot collide; `drawn` is the path out, since nothing
|
|
removes a row by itself and one left behind would sit there reporting a
|
|
build that is over.
|
|
The foreground service needs one notification that outlives every row,
|
|
so **the service's own notification is the row itself while one thing is
|
|
running, and the group's summary once there are more.** Measured on API
|
|
36: a group of one is collapsed to its header, which is the line of text
|
|
with the bar left out of it -- so grouping a single row costs it its
|
|
progress. Measured there too: **a collapsed row drops the content text
|
|
the moment there is a bar to draw**, which is why a row's title carries
|
|
both the component and what is happening to it (`Test Tablet — tablet:
|
|
building 14/40`) with nothing set beside it, that being the same words
|
|
twice once expanded. The summary draws no bar at all, because the only
|
|
number it could show is an average of the rows' and that is nobody's
|
|
measurement.
|
|
**An app may only start a foreground service while it is in front of
|
|
somebody**, which is ordinarily where this one is -- work begins with a
|
|
button, and the service outlives the press; everything after the start
|
|
is an update to a `StateFlow` the service is already collecting, so a
|
|
build that finishes while the phone is in a pocket still reports. What
|
|
is left is work *reappearing* after the list has been empty for a moment
|
|
with the app by then behind something else, and Android answers that
|
|
with an exception rather than a return value -- so the start is asked
|
|
for whenever there is work and nothing showing it, the refusal is
|
|
caught, and the next thing the run reports asks again. Stops go through
|
|
`stopSelf(lastStart)` rather than `stopSelf()`, so work starting in the
|
|
moment between deciding to stop and stopping is not taken away with it.
|
|
**A build that finishes while nobody is looking keeps its notification,
|
|
and that notification carries the install.** Handing an APK to the
|
|
system installer is starting an activity, which Android refuses from the
|
|
background -- fired anyway it does nothing at all, so a download that
|
|
worked read as an update that never landed. So `updateComponent` stops
|
|
at `ComponentState.ReadyToInstall` whenever this screen is not in front
|
|
of anybody, exactly as it already did for the second APK of a
|
|
project-wide update, and that state is a `WorkKind.Waiting` row: no bar,
|
|
dismissible, out of the group, and carrying an **Install** action whose
|
|
`PendingIntent` is the install intent itself. An activity is the one
|
|
press a notification may make from any state; a broadcast back into this
|
|
app would have to start the installer itself, which is the thing that
|
|
cannot be done from there.
|
|
Which is why the drawing lives in `WorkNotice` rather than in the
|
|
service: that row outlives the last running thing, and something still
|
|
alive has to take it down. The list screen is that something, since the
|
|
states being drawn are its own.
|
|
**Two things had to be true for it to come down again**, and neither was.
|
|
`continuePendingInstalls` now begins by forgetting every download whose
|
|
install has already happened -- compared as the installed copy's
|
|
`lastUpdateTime` against the downloaded file's mtime, rather than by
|
|
listening for an install, because the same broadcast reports removals
|
|
and a removal is what the wrong-key case is *waiting* for. Without it,
|
|
pressing Install on the notification and then returning to the app ran
|
|
the whole install a second time (measured, not feared). And
|
|
`registerPackageChangeReceiver` dropped **every** arrival that was an
|
|
update: `EXTRA_REPLACING` marks the REMOVED half of a replace, the
|
|
ADDED half, *and* the REPLACED that follows, so testing it without the
|
|
action threw the lot away and an in-place install reached nothing here
|
|
until the screen next resumed. Invisible while the resume was the only
|
|
thing waiting on it; the moment a notification depended on it, it was
|
|
the difference between the row coming down by itself and sitting there
|
|
after the install it was for.
|
|
What is left is a row that outlives the process: nothing cancels it if
|
|
the install happens with this app long since reclaimed. It is
|
|
`autoCancel`, so the tap that opens the app takes it away, and the app
|
|
is where the truth about that build is -- which is the cheap answer to a
|
|
case that costs a stale line in the shade.
|
|
**There are two channels, and the difference is the sound.** Work in
|
|
progress is `IMPORTANCE_LOW`, because a build is an ongoing report of
|
|
something somebody just pressed a button for and the screen they
|
|
pressed it on already says the same thing; the row waiting to be
|
|
installed is `IMPORTANCE_DEFAULT`, because it is the arrival of
|
|
something nobody was watching for and a silent one is a button nobody
|
|
is told about. Two channels rather than one raised, because Android lets
|
|
an app only ever *lower* an existing channel's importance -- raising
|
|
the one that shipped would do nothing at all on a phone that had
|
|
already run this app, and the whole fix would be somebody finding it
|
|
in Android's settings. The waiting row also undoes the
|
|
`setOnlyAlertOnce` every other row wants: it replaces the running row
|
|
it grew out of, same tag and same id, and an update to a notification
|
|
posted alert-once is silent however loud its channel is.
|
|
`POST_NOTIFICATIONS` is asked for at startup beside the local-network
|
|
one, and refusing it costs the watching rather than the work: the
|
|
service still runs, and there is simply nothing to draw. The type is
|
|
`dataSync`, whose 6-hour daily budget on Android 15+ arrives as
|
|
`Service.onTimeout` and is answered by stopping -- unreachable for a
|
|
build, but a build machine that says "building" and never stops is
|
|
polled for as long as it keeps saying it.
|
|
- **Discovery must stay side-effect free.** It runs on every manifest
|
|
request and every suggestion scan.
|
|
- **An app in the list is a *project*, and what it produces is its
|
|
components.** Ordinarily one `Apk`; a project that also runs a server on
|
|
the build machine declares a `Server` beside it. They are a sum, not one
|
|
struct with both halves' fields, and the common fields are repeated per
|
|
variant so the file reads `Apk(name: ...)` with nothing nested inside a
|
|
`kind`. `Component::same_declaration` destructures exhaustively, so
|
|
adding a field anywhere fails to compile until someone has said whether a
|
|
project declares it or this server measures it.
|
|
- **A build reinstalls every server component's unit, not just changed
|
|
ones.** The unit is generated from a script in the repository, so a pull
|
|
can change how a service is *defined* rather than what it runs -- and
|
|
that has to land even when the build produced a byte-identical binary,
|
|
which is the ordinary case for a pull that only touched the script.
|
|
Installing is safe on a running service (it writes the unit and reloads;
|
|
it stops nothing), which is why `start.sh` has always done it every run.
|
|
It is deliberately not gated on `restart::binary_unchanged`: that answers
|
|
whether a *restart* is worth the interruption, and unit staleness is a
|
|
different question. Wiring the two together left the unit stale exactly
|
|
when the build had nothing to do.
|
|
- **A build runs every component at once, and each reports for itself.**
|
|
They are independent -- a Rust build and a Gradle build share nothing but
|
|
the machine -- and measured here, together takes about three quarters of
|
|
the time one after the other does. The saving only appears when more than
|
|
one has work, which is what a pull produces.
|
|
The status is per component (`ComponentStatus`), so a card draws each
|
|
component's bar and last line inside that component's own row;
|
|
the project's own area at the bottom keeps only what belongs to the whole
|
|
project, which is fetching and pulling. A bar under the card could only
|
|
ever say that *something* was happening, and with everything building at
|
|
once that is exactly what the reader is trying to find out.
|
|
A failure no longer stops the others -- they are already running -- and
|
|
it is reported against the component whose command it was, never as the
|
|
project's. The phone draws every component in
|
|
a card of its own, including a project with only one: the flat layout it
|
|
used to get meant two shapes to keep in step, and put that APK's size up
|
|
beside the card's corner controls where it read as belonging to them.
|
|
- **There is a build slot per component, not per project, and the two
|
|
halves have to agree on that.** A component being built neither blocks
|
|
another's build nor disables its controls: `Inner` has no `building`
|
|
flag, only a `ComponentRun` per component whose open `step` *is* the
|
|
answer, and `claim` writes that entry synchronously under the same lock
|
|
the route answers from -- so nothing can read a component the request
|
|
just claimed as idle, which the phone would take for "the build is
|
|
over". `BuildStatus::building` stays, but it means "anything at all is
|
|
happening here" and is only for the controls that act on the whole
|
|
checkout; anything about one component reads that component's `step`.
|
|
The app mirrors the split exactly: `ProjectState` for the pull and the
|
|
project-wide Rebuild, `ComponentState` keyed by component name for
|
|
everything a single component is asked to do. One map keyed by project
|
|
alone is what the bug was -- pressing Update on one client of a
|
|
two-client project disabled the other's button and drew this one's
|
|
download bar under it -- and two hierarchies rather than one keyed by a
|
|
pair is what stops it coming back, since a download has no
|
|
project-wide meaning to be stored with.
|
|
The exception, and it is worth keeping visible so it does not read as
|
|
more of the same: **a pull really is exclusive with everything.** There
|
|
is one checkout, and it rewrites the files every component builds from,
|
|
so `Inner::pulling` blocks any component from being claimed and waits
|
|
for any still building. Ending the pull and claiming what it decided to
|
|
build happen under one lock for the same reason `claim` is
|
|
synchronous -- a phone polling in the gap would see a project that is
|
|
neither pulling nor building and call the run finished.
|
|
- **Text a command produced is selectable; text this app wrote is not.**
|
|
`Theme.kt`'s `OutputText` is the whole of it, and every failure message
|
|
goes through it -- a component's build or download, a service action, a
|
|
checkout's remote check, and the log dialog's own failure line -- plus a
|
|
`SelectionContainer` around the log body, which cannot use `OutputText`
|
|
because it is an `AnnotatedString` the ANSI renderer coloured inside its
|
|
own scrolling panel. The reason is that this is the one text on screen a
|
|
person has to take somewhere else, and the machine that produced it is
|
|
not the machine in their hand. A status word or a button label stays
|
|
unselectable on purpose: selection handles on those are noise, and a
|
|
card that starts a selection on long-press fights the gestures it
|
|
already has. Iris asked for exactly that line on 2026-09-01: "not the
|
|
'failed' but the command output for build errors and stuff".
|
|
`OutputText` also renders the ANSI escapes rather than printing them,
|
|
through the same `ansiAnnotated` the log dialog uses -- a compiler marks
|
|
its own errors in colour and the tail of a failed build is that output
|
|
verbatim, so raw it arrived as `[1;31merror` with punctuation welded
|
|
onto the one line somebody was trying to read. Selection copies
|
|
`AnnotatedString.text`, which is the message with every escape already
|
|
gone, so what lands on the clipboard is what was on screen rather than
|
|
what was on the wire.
|
|
- **A finished component shows nothing, and its button goes back to
|
|
normal.** Iris's call, 2026-09-01: "you shouldn't see the time it took
|
|
once it finishes, it should just go back to its normal enabled button
|
|
state." So `ComponentBuildProgress` draws only while the step is open,
|
|
and the elapsed times are gone from both halves of the wire -- a bar, a
|
|
count and a last line all describe something happening *now*, and every
|
|
one of them sits there looking live beside a sibling that genuinely is.
|
|
What says the build landed is the control becoming pressable again and
|
|
the card's own freshness. The failure is the exception, because it is an
|
|
outcome rather than residue; it is drawn by the component card next to
|
|
the Retry that acts on it, in the one place that reports that
|
|
component's failures whether they came from a build, a download or a
|
|
service action.
|
|
- **A card being worked on has no freshness, rather than the one from
|
|
before the press.** Nothing re-reads the manifest during a run -- the
|
|
entry was fetched before the button was pressed and read again only
|
|
once the run is over -- so "out of date" drawn beside the bar that is
|
|
making it current is last minute's answer wearing this minute's
|
|
clothes. `UpdaterScreen` therefore maps a busy component's `freshness`
|
|
to `unknown` in the one place the component list is built for the
|
|
cards, which is what makes both readers of it -- the row's own note and
|
|
`MismatchedPairNote` -- go quiet without either having to know why. The
|
|
condition is deliberately the same pair that disables the Update button
|
|
(`projectState.busy || componentState.busy`): what cannot be acted on
|
|
is exactly what cannot be measured just now. It comes back the instant
|
|
the run ends, still saying "out of date" if the build failed, because
|
|
by then the entry has been read again -- withheld is not the same as
|
|
cleared.
|
|
- **A project's own `.dev-updater.ron` is a request, never an
|
|
instruction.** It only runs once accepted from the phone, which copies
|
|
it into `config.ron`; `AppEntry::pending_declaration` is the whole gate.
|
|
Evaluate it *fresh* rather than caching it on the entry — the file
|
|
changes on a pull, and a pull writes no config, so nothing rebuilds the
|
|
entry list. Caching it meant a pull could swap the command out from
|
|
under a previous acceptance and nothing noticed until an unrelated
|
|
config write. Enforce it wherever a command would actually run, not
|
|
only where the list is built.
|
|
Two things follow that both went wrong the moment `resources:` joined
|
|
the gate. **Whatever `Declaration::matches_accepted` compares,
|
|
`AppState::approve_declaration` has to write** -- it compared the
|
|
resources declaration and wrote only the components, so accepting stored
|
|
half of what was being read back and the gate could never clear. The
|
|
press succeeded, so there was no error either; the card simply kept
|
|
asking. Adding a field to one without the other is the shape to watch
|
|
for, and the tests are what let it through: they built an "accepted"
|
|
config by hand instead of calling `approve_declaration`, so they
|
|
asserted a copy of the rule rather than the rule.
|
|
And **on the pull path the gate is asked after the fast-forward, never
|
|
before it** (`build_state::pull_and_build` takes a closure for exactly
|
|
this reason). The declaration lives in the checkout, so the pull is the
|
|
one thing that changes the answer -- a bool computed at the call site is
|
|
the answer for the commit being replaced, which made a pull that changed
|
|
the declaration build anyway. Pulling stays allowed while a request is
|
|
unaccepted, since taking commits runs git rather than the project's
|
|
command, and it is how the new request arrives to be read.
|
|
- **A component declares its build modes in one list, and that list is the
|
|
only place a mode is declared.** `modes: ["release", "debug"]` on the
|
|
component; the first is the default, which is why a project puts the one
|
|
it wants built by default first. Every command the component runs is
|
|
then handed the mode **as its last argument** -- so a project whose
|
|
script takes `release` or `debug` names that script once and nothing is
|
|
written twice. The escape hatch is writing a field *per mode*
|
|
(`build: {"release": ..., "debug": ...}`, `service: Managed({...})`),
|
|
and it exists because some commands cannot take the word: cargo takes
|
|
`--release` or nothing, and its profile for the unoptimised build is
|
|
called `dev` while the directory it writes is called `debug`, so no
|
|
single word serves as both the flag and the path. Gradle is the same
|
|
shape (`assembleRelease` needs capitalising).
|
|
**A command written per mode is not handed the mode as well** -- it
|
|
already is the answer, and appending the word would pass a stray
|
|
argument to a binary that never asked for one, which for a service is a
|
|
process that will not start. `ByMode::mode_argument` is the one place
|
|
that rule lives.
|
|
Having *one* list is what makes "this part has a mode the other part
|
|
never heard of" unsayable rather than something to detect: every
|
|
per-mode map is checked against it (`config::mode_problem`), so a
|
|
missing entry is named as missing instead of resolving to some other
|
|
mode's command. A map on a component that declares no modes is the same
|
|
mistake from the other side and is refused with a message saying what
|
|
to write. A declaration that fails the check is discarded exactly as an
|
|
unknown field is, and the card says so.
|
|
**`ByMode` holds a `Command` and nothing else, and that is load-bearing
|
|
rather than incidental.** Telling the single form from the map needs
|
|
`deserialize_any`, and RON reports a struct `(path: "a")` as a *map* --
|
|
so a struct-valued field behind it would read its own field names as
|
|
mode names. Worse, measured: RON **discards the variant name** under
|
|
`deserialize_any`, so `Cargo("server")` and the array command
|
|
`["server"]` arrive as the same one-element sequence. That is why the
|
|
by-mode map lives *inside* `Service` rather than around it, and why a
|
|
`Cargo(...)`-style shorthand cannot be added to `build:` beside the
|
|
bare-string form without a format migration.
|
|
|
|
- **Which mode to build in, and whether to strip, are the build
|
|
machine's choices; which finished build to install is the phone's.**
|
|
There is one checkout and one set of outputs, so a per-device mode
|
|
would have two enrolled phones rebuilding over each other with nothing
|
|
on either screen to say why -- the settings sheet says as much out
|
|
loud. They are stored on the component in `config.ron` (`mode`,
|
|
`stripHere`), set through `PUT /apps/{key}/components/{name}/settings`,
|
|
and excluded from `Component::same_declaration`: choosing one of the
|
|
declared modes is not the project asking for something new, and
|
|
including it would make every settings change re-open the acceptance
|
|
gate. Which means both halves of the carry-across have to be written --
|
|
`registry::chosen_settings`/`restore_chosen_settings`, called by
|
|
`approve_declaration` **and** `reconcile_self`. Without the second, a
|
|
mode chosen for this server's own component would last until the next
|
|
restart, and restarting is how this server is updated.
|
|
`strip` stays declarable by the project and `stripHere` overrides it,
|
|
rather than being copied in at acceptance: copied, a later change to
|
|
the declaration would be ignored on every machine, silently.
|
|
**`BuildState::matches` compares the effective mode as well as the
|
|
declaration**, because that state holds a *snapshot* of the components
|
|
it builds from -- reused across a mode change it would go on running
|
|
the old mode's command from a card reporting the new one.
|
|
|
|
- **A mode switch moves no commit, so `builtMode` is recorded beside
|
|
`builtFrom`.** Without it a component built in `debug` and switched to
|
|
`release` reads as current, offers nothing to press, and serves the
|
|
debug build for ever -- and for an `Apk` nothing else would notice,
|
|
because the "never built at all" check finds *any* variant under the
|
|
component's directory, so the debug APK sitting there is enough to
|
|
satisfy it. `Component::built_in_another_mode` answers it and both
|
|
`freshness` and `component_is_stale` ask, the first reporting `Behind`
|
|
*before* consulting the checkout at all: a clean tree at the very
|
|
commit the debug build was made from still does not make that build a
|
|
release one. It stays quiet until this server has built the component
|
|
once, so a project built by hand is not told it is out of date.
|
|
|
|
- **Each component card has a settings sheet, and there is one picker in
|
|
it, not two.** The gear sits at the row's right-hand end beside the log
|
|
button, drawn unconditionally so its presence is never the signal. The
|
|
sheet holds one **Build** choice, whether to strip, and Enrol; a
|
|
section with nothing to offer says so rather than vanishing, since
|
|
"this project declares one way of building" and "we could not tell"
|
|
must not look alike. Everything applies on Save except Enrol, which is
|
|
an action and happens on the press.
|
|
The build mode and the installed variant were two dropdowns, and Iris
|
|
asked for one (2026-09-02). They are genuinely different questions --
|
|
what the build machine *builds*, and which finished build this phone
|
|
*takes* -- but they answer to the same words, so two pickers both
|
|
offering `debug` and `release` read as one choice asked twice; moving
|
|
the variant off the card into the sheet had not fixed that, it had only
|
|
moved it. So **where a component declares modes, the mode is the whole
|
|
answer**: `AppEntry::resolve_apk` serves the build named after the
|
|
component's effective mode when the phone asks for no particular one,
|
|
which is what lets the phone stop having a second opinion. Saving in
|
|
that state clears any stored variant outright rather than leaving it,
|
|
since a pin set before the modes existed would otherwise go on quietly
|
|
overriding a control that is no longer shown. A component with no
|
|
declared modes still gets the variant list, because otherwise there
|
|
would be nothing to choose at all -- and the note says which of the two
|
|
it is offering, so "the machine decides" and "this phone decides" are
|
|
never confused.
|
|
Serving the mode's build rather than the newest is the right default
|
|
for its own sake: a debug build made by hand after the mode was set to
|
|
release is *newer*, and serving it would put a debug app on the phone
|
|
from a card saying release.
|
|
Every dropdown in the app hangs from one `PickerButton` -- an outlined
|
|
pill with a chevron, Iris's ask in the same message -- so "there is a
|
|
choice here" looks the same for a mode, a branch and a commit. The
|
|
chevron is `md-chevron_down`, which like every other glyph had to be
|
|
added to **both** `NerdIcons.kt` and `build-icon-font.sh` and the font
|
|
regenerated.
|
|
**The row's text is inside one weighted child so it can never push the
|
|
controls off the edge**, and every reading in it truncates with an
|
|
ellipsis. A control that leaves because the text grew is one the reader
|
|
cannot get back to.
|
|
|
|
- **`enroll:` is a command whose one line of stdout is a URL for the
|
|
phone to open after installing.** Deliberately "a URL to open" rather
|
|
than anything named after enrolment -- a route that knew what enrolling
|
|
was would be a special case of itself. Run **per press**
|
|
(`POST /apps/{key}/components/{name}/enroll-link`), never cached: the
|
|
link a project mints is ordinarily one-shot and carries a credential,
|
|
so a stored one would be both stale and a secret sitting in a file. In
|
|
the acceptance gate like `build`, because it runs on the build machine.
|
|
`crate::script::capture` is the shared runner it and `resources` both
|
|
use -- stdin closed, a deadline, stdout kept, and a failure naming the
|
|
command and the first line of its stderr.
|
|
|
|
- **Which build variant to serve is the phone's choice, not the server's.**
|
|
It arrives as `?variant=` on the download, beside the `?component=` that
|
|
says whose build it is, and is validated against *that component's*
|
|
discovered builds -- an unvalidated path would let a phone name any file
|
|
on disk to be served. Storing it server-side meant one enrolled device
|
|
silently changing what another was offered. The stored choice is keyed
|
|
by project *and* component on the device, so pinning one client to a
|
|
release build says nothing about the other. Naming none is answered by
|
|
the component's own mode before it is answered by the mtime; see the
|
|
settings sheet below.
|
|
|
|
- **A build that cannot be installed over what is on the phone says so
|
|
before the installer does.** Android refuses a package signed by a
|
|
different key than the installed copy and reports it as "App not
|
|
installed" with no cause, which reads as the download having failed.
|
|
So `SigningKeys.kt` compares the downloaded APK's certificates against
|
|
the installed package's, after the download and before the install
|
|
intent, and the card puts up a dialog naming both digests with the one
|
|
thing that gets past it: remove the old app. Iris asked for this on
|
|
2026-09-02.
|
|
It is **not** a guarantee and must not become one: where either side
|
|
cannot be read the answer is "don't know", the install goes ahead, and
|
|
Android decides exactly as before -- blocking on a guess would be worse
|
|
than the sentence it replaces. Certificate *sets* are compared first,
|
|
because a multiply-signed package is only replaceable by one signed by
|
|
all the same keys and `hasSigningCertificate` reports false for it
|
|
however the certificate is presented; that call is then asked as well,
|
|
because it is the only thing that knows a rotated key's lineage.
|
|
The download is carried on the state (`ComponentState.WrongKey`) so
|
|
that removing the old app is followed by the install it was for rather
|
|
than by a second download. The removal happens in the system's own
|
|
dialog with this app off screen, so `continuePendingInstalls` is called
|
|
from **both** things that can learn it finished -- the package
|
|
broadcast and the resume -- since which arrives first depends on how
|
|
long somebody spends in that dialog.
|
|
**`ACTION_DELETE` needs `REQUEST_DELETE_PACKAGES` now**, and without it
|
|
the failure is invisible from this side: the uninstaller starts, logs
|
|
that this uid lacks the permission, and finishes without drawing, so
|
|
the button reads as dead. It needed no permission when this app's first
|
|
uninstall offer (the renamed-package one) was written, so that button
|
|
had presumably never worked and nothing said so. Found by pressing it.
|
|
|
|
- **A project can produce more than one APK, and each component's builds
|
|
are found under its own `cwd`.** `APK_PATTERNS` is anchored at
|
|
`project.join(cwd)` rather than at the project root, which is what
|
|
`cwd` already meant everywhere else -- the directory the build command
|
|
runs in, the subtree `subtree_head` scopes staleness to, a server's
|
|
`WorkingDirectory`. A component that declares none sits at the root,
|
|
which is what every single-APK project has always meant, so nothing
|
|
about that case changed. tdep-survey is the project that needed it: one
|
|
checkout, a backend and *two* independent Android clients, where the
|
|
root-anchored patterns reached the first and stopped.
|
|
The alternative -- naming the file on the component, `apk: "path"` --
|
|
was rejected because it makes a component a file path, and **an app
|
|
being a project path rather than a file path** is the invariant at the
|
|
top of this document.
|
|
Everything downstream is per component in consequence: `package`,
|
|
`strip`, the variant list, the size, the mtime and the rename note.
|
|
Those were all `apk_component()`'s *first* match before, which was
|
|
correct only while a project had one. Two clients install over
|
|
different packages and differ in whether their symbols are worth
|
|
carrying to a phone, so first-wins would have checked the installed
|
|
state of one app and reported it as the other's -- the expensive kind
|
|
of wrong, because it looks exactly like an answer.
|
|
**A download that names no component is refused, not guessed**
|
|
(`ApiError::AmbiguousApk`), for that reason. Naming none still answers
|
|
for a project with one, which is nearly all of them and is what lets
|
|
the frozen `/self/apk` keep working -- it cannot carry a component, and
|
|
the project it describes has a single APK.
|
|
The measurement carried across an acceptance is keyed by name *and*
|
|
`cwd` (`registry::component_id`): once the directory decides which
|
|
builds a component has, a reused name is not the same APK, and handing
|
|
it the old one's package would be wrong until that component happened
|
|
to be downloaded.
|
|
- **`/prepare` and `/build` take `?component=` to build one component
|
|
instead of every one with a command.** Without it, pressing Update on
|
|
one client of a multi-client project ran every component's build to
|
|
get the one that was actually asked for -- fine when a project had one
|
|
APK, expensive the moment it had two and one of them was slow.
|
|
`named_component` in `routes.rs` is the one place a name from the phone
|
|
is checked against the project's own components, so `/prepare` and
|
|
`/build` cannot disagree about what an unknown name means, and
|
|
`BuildState::{trigger_if_needed,build_now,run_build,is_stale}` all take
|
|
the same `Option<&str>` -- `None` still means the whole project, which
|
|
is what `Pull & Build`, the project-row `Rebuild`, and every project
|
|
with a single component keep doing. **Each component's row carries its
|
|
own Rebuild**, which is `/build` scoped to it. That used to be
|
|
deliberately absent, on the grounds that Update already builds a
|
|
component that needs it -- but Update runs `/prepare`, which builds only
|
|
when the staleness rules say the output is behind, and those rules are
|
|
blind to everything a commit does not describe: a command reading files
|
|
nobody declared, an output changed underneath this server, a signing key
|
|
replaced since the APK was made. In all of them nothing reads as stale,
|
|
so Update does nothing, and the only force was a project-wide Rebuild
|
|
that rebuilds every sibling too. Iris asked for the per-component
|
|
button on 2026-09-01. The two are the same action at two scales, so
|
|
they share a word and a colour, and each sits at the right-hand end of
|
|
its own row.
|
|
A component with no command of its own draws no Rebuild -- `hasBuild`
|
|
on the manifest component says so per component, which the project's
|
|
own `needsBuild` (the OR of every component's) cannot. Absent rather
|
|
than disabled, matching the project row, because having no build step
|
|
is not a state a component is in.
|
|
A server's Rebuild sits in its service row but outside the condition
|
|
that draws the service buttons: those wait for the script's answer,
|
|
while a server whose script cannot be reached is still one this machine
|
|
can compile.
|
|
**Where `component.dir()` belongs is now one definition**
|
|
(`Component::dir` in `config.rs`), because a second one very nearly
|
|
shipped a real bug: `component_is_stale`'s "never built at all" check
|
|
still asked `find_apks` of the *project root* after per-component
|
|
discovery had already moved everywhere else to `component.dir()`. A
|
|
project with two `Apk` components has one's output sitting under the
|
|
root-anchored patterns too -- `*/build/outputs/apk/*/*.apk` matches any
|
|
one-level subdirectory, regardless of which component put it there --
|
|
so the moment *either* component had ever been built, the whole
|
|
project read as "something is built here," and the *other* component,
|
|
never built, silently stopped being offered its own first build:
|
|
`prepare` saw a component with a command and no output and declared it
|
|
current. Caught by testing the actual behaviour of a two-APK project
|
|
rather than trusting that scoping the build implied scoping the
|
|
staleness check that decides whether to run it -- they are two
|
|
different reads of "which directory is this component's," and only one
|
|
of them had been moved.
|
|
The same check is deliberately *not* asked of a `Server`: a service
|
|
never has an APK to find under its own directory by definition, so
|
|
asking would report every server "never built" forever. Guarded on
|
|
`matches!(component, Component::Apk { .. })` for that reason.
|
|
- **The self entry's project is the working directory itself**, so this
|
|
server has to be started from the root of its own checkout -- which is
|
|
where everything else here is driven from, and what the service unit
|
|
sets (`WorkingDirectory=$REPO_ROOT`, not `server/`). Its components then
|
|
say where they live from there, exactly as any other project's do; there
|
|
is nothing special about this one's layout, which is the point of it not
|
|
being `app/` any more. It used to be
|
|
`CARGO_MANIFEST_DIR`, fixed when the binary was *compiled*, so a
|
|
re-clone or a move left the card watching a directory nobody pulls. The
|
|
symptom is why this is worth remembering: the entry keeps working and
|
|
keeps serving an APK, but silently loses its branch line, its Pull
|
|
button and its commit count, so it reads as an app that simply never has
|
|
an update. It took a "why can't my server see the new commits?" to find.
|
|
Startup warns when there is no checkout at the resolved path, which is
|
|
the only cheap moment to notice.
|
|
- **A configured project pointing at this server's own project is a second
|
|
card for it.** `add_app` refuses it (the self entry is in the list it
|
|
checks against, and `self_project` is canonicalized so a symlink can't
|
|
make one directory look like two), so this only survives from a config
|
|
written while the self entry resolved somewhere else. It is not merely
|
|
redundant: it is not the self entry, so `is_self` is false and a build
|
|
through it would restart this server partway through its own build. `build_entries` warns rather than dropping
|
|
it -- dropping it would leave a config entry with no card and so no way
|
|
to remove it from the phone. The built-in card has no Remove button, so
|
|
the duplicate is the one that does.
|
|
- **A project says where its own state is; nothing infers it.** The
|
|
`resources:` declaration points at a RON file, a script that prints the
|
|
same RON, or an inline struct, and it holds what a project would
|
|
otherwise write down in several places -- its `name`, and its `data` and
|
|
`config` directories when those are not `$XDG_*_HOME/<name>`. **The file
|
|
is the project's, not this server's**: its own code is meant to read the
|
|
same one, which is why `ResourceFacts` ignores unknown keys where
|
|
`Declaration` sets `deny_unknown_fields`, and why it goes through
|
|
`wg_app_link::format` like every other RON here.
|
|
A project that says nothing gets nothing -- the dialog reports not
|
|
knowing rather than filling in a directory. Two guesses were tried and
|
|
both rejected: the config key (`updater`, `app`, which resolve to
|
|
`~/.config/updater` and `~/.local/share/app`, neither of which exists)
|
|
and the checkout's directory name (right for both projects here, and
|
|
still an inference presented as a fact). Iris's call, and the reason is
|
|
the failure mode: a wrong path does not error, it reads as "this
|
|
component keeps nothing here".
|
|
Read **off the request path**, through `crate::checks` beside the git
|
|
and service checks, because `Script` spawns a process and `/manifest` is
|
|
fetched on every open, resume and Refresh. It is in `checksPending` with
|
|
the other two -- counting only some of them is the bug that once left
|
|
components with no buttons after a restart. And it is part of the
|
|
acceptance gate, since a `Script` runs: comparing the whole declaration
|
|
rather than the variant means changing which file is read is a change
|
|
somebody is asked about.
|
|
|
|
- **Uninstall can take three things away, and the path on screen is the
|
|
only guard.** The dialog offers logs, data and config as separate
|
|
toggles -- only logs on by default, and ticking data forces logs on,
|
|
since a service that writes its log inside its own data directory would
|
|
lose it either way. `purge.rs` is the whole of it. Where the data and
|
|
config are comes from the project's `resources:` declaration, above; a
|
|
project that does not say gets two disabled toggles saying so, and the
|
|
four reasons a toggle is disabled are distinguishable on screen because
|
|
"couldn't read the resources" is a fault to fix while "doesn't say" is
|
|
an ordinary project.
|
|
A path is removed **wherever it points**, with no check that it
|
|
sits under the XDG directories -- Iris's call, over the alternative of
|
|
refusing anything outside them. What replaces that check is the dialog
|
|
showing each resolved path before the button can be pressed, so the
|
|
phone never asks for a path nobody saw. Anything that stops the path
|
|
being displayed removes the only guard there is.
|
|
Log paths are collected *before* the uninstall runs, because the script
|
|
that reports where a service's log lives is the thing being removed.
|
|
|
|
- **The self entry can't be removable.** The app can only be updated
|
|
through this server, so an updater that can drop itself from its own list
|
|
strands the installed copy (recovery is a manual reinstall over the
|
|
bootstrap port).
|
|
- **`/self` and `/self/apk` are a frozen contract, and the only rescue the
|
|
app has.** Every other route is reachable only by an app new enough to
|
|
understand it: change what `/manifest` says and an older app cannot read
|
|
the list, which is where the button that would replace it lives. So the
|
|
path that fetches a newer app depends on nothing likely to change --
|
|
two numbers, no nesting, no variants, no query parameters -- and the app
|
|
asks it on every launch and again after building its own project, which
|
|
is the moment the newer copy exists and the server it must keep talking
|
|
to has just changed. Add fields at your peril and never rename one;
|
|
anything richer belongs on a route an old app never calls. It does not
|
|
survive a changed CA, port or token, since those break the connection
|
|
before any route is reached: those stay one-way doors and `--download`
|
|
stays their answer.
|
|
**It is also how the built-in card downloads its own APK**, not just
|
|
how the rescue dialog does. `downloadApk` sends the built-in project to
|
|
`/self/apk` rather than to the manifest's route, because this is the
|
|
one download whose server changed underneath it between the build and
|
|
the request -- so it takes the route nothing can rename. It carries no
|
|
`component` or `variant` and does not need to: that project has one
|
|
APK, and which build of it to serve is the mode set on the build
|
|
machine.
|
|
**And the card's Update installs this app itself**, rather than
|
|
building it and leaving a dialog to offer it. Which means it waits for
|
|
the server *twice* (`awaitServerBack`): once before fetching, and again
|
|
before handing anything to the installer. The restart is deferred a
|
|
couple of seconds past the build and then held off for as long as an
|
|
APK is going down the wire, so it lands either just before the download
|
|
or in the gap between the last byte and the install -- a download that
|
|
dies partway reads as the update having failed at the moment it worked,
|
|
and an install offered mid-restart replaces this app while the server
|
|
it must talk to is down. Bounded, and it goes ahead anyway when the
|
|
wait runs out: by the second one the APK is already here, and never
|
|
installing it would be the worse failure.
|
|
**The dialog is now offered only when the built-in card cannot offer
|
|
it** -- that card missing from the list, or no list at all because this
|
|
app is too old to read what the server now sends. `AppListScreen`
|
|
derives that from `manifestState` and reports it up; `Loading` counts
|
|
as present, since not having found out yet is not an answer and a
|
|
dialog flashing up during every load is exactly the noise it exists to
|
|
avoid. Iris's call, 2026-09-02: the card's own Update should do the
|
|
work, "though keep the modal if the dev updater card itself is missing
|
|
or fails to load."
|
|
- **There is deliberately no general migration mechanism.** Iris's call:
|
|
each app decides its own, and the updater's job is only to be able to
|
|
get both halves of *itself* to the next version. A project that renames
|
|
or restructures its service is uninstalled and reinstalled from the card,
|
|
which works because dev-updater is not the thing being stopped.
|
|
- **The bootstrap link (`--download`) serves the APK already on disk and
|
|
builds nothing.** Shipping a stale one there is the expensive mistake,
|
|
because a fresh install is not enrolled and everything that would replace
|
|
it -- Update, and the QR scan that enrols a device at all -- lives in the
|
|
copy being installed, so the only way out is another trip through
|
|
`--download`. It cost a round of "the scanner fix never landed" when the
|
|
fix was in the checkout the whole time. The startup line names the
|
|
variant and the build's age for that reason; run `./app/build-apk.sh`
|
|
first.
|
|
- **Changing the pinned CA is a one-way door for any installed copy.** Same
|
|
reason. `wg_app_link::certs` deliberately won't regenerate an existing
|
|
CA; only the leaf is reissued, on every start.
|
|
- **State is per machine, not in the repo**:
|
|
`$XDG_CONFIG_HOME/dev-updater/{config.ron,certs}`, owner-only. This
|
|
repo is shared with a VM at a different path, so a shared config hands
|
|
each machine the other's project paths, and a CA key in it is one that VM
|
|
could sign with. The APK pins the CA of whatever machine builds it
|
|
(`app/build-apk.sh`, `DEV_UPDATER_CA` overrides).
|
|
- Freshness is the APK's **mtime** versus `PackageInfo.lastUpdateTime`, not
|
|
a version code — these are ad hoc rebuilds with nothing bumping a
|
|
version. It follows that a build and an install landing in the same
|
|
second can briefly read as "update available"; that's inherent, not a
|
|
bug to chase.
|
|
- **Claiming "the whole project" must still mean "what's actually
|
|
stale," not "everything with a command."** `after_moving` (what Pull
|
|
and Update share) used to gate a post-pull build on
|
|
`pulled || is_stale(None)` and then, once open, `claim(&mut inner,
|
|
None)` -- which marks *every* component with a build command, stale or
|
|
not. `trigger_if_needed(None, ...)` had the same shape: check
|
|
`is_stale`, then hand off to `build_now`, which is unconditional by
|
|
design (it is also the button). So any pull that fetched anything --
|
|
a one-line fix under `server/`, say -- rebuilt the APK beside it too,
|
|
reported as "it tries to update the compose apk even if nothing was
|
|
done." `claim` now takes `only_stale`: `false` for `build_now` (a
|
|
person pressed a specific button, and staleness has no business
|
|
overruling that), `true` for `trigger_if_needed` and for the build
|
|
`after_moving` runs after a pull. `component_is_stale` was already
|
|
scoped correctly per component (`watched_paths`) -- the bug was never
|
|
in *what* counts as stale, only in building past that answer once any
|
|
one component tripped it. Regression test:
|
|
`a_pull_touching_one_component_does_not_rebuild_its_sibling`.
|
|
- **A project-wide Update installs only the APKs that are waiting on this
|
|
phone.** Skipping a current APK's build was only half of the rule above:
|
|
after the pull, `startProjectUpdate` re-read the correctly current
|
|
component and then downloaded and offered every APK in the project to the
|
|
installer anyway. This showed up on a server-only change as an APK update
|
|
despite the card already knowing the installed copy was current.
|
|
`needsInstall` is deliberately the one predicate used both to make the
|
|
card's Update button pressable and to select the APKs that press installs;
|
|
the reason a project needs attention may belong to a sibling component.
|
|
- **Two words for one measurement is a bug even when both are true.**
|
|
`MismatchedPairNote` said a sibling still on its old build was "older
|
|
than this build," while `freshnessNote` already has a word for that
|
|
exact freshness (`behind` → "out of date"). Same field, two names --
|
|
which is what "pulling says something weird about the compose apk"
|
|
turned out to be: not a wrong condition, a second vocabulary for a
|
|
state the reader had already been told about elsewhere on the same
|
|
card. Reuse the existing word rather than inventing an adjacent one.
|
|
|
|
## Running the server for real
|
|
|
|
`./start.sh` is the shortest way back to a working state: it builds both
|
|
halves, rewrites the service unit (so a pull that changes the service script
|
|
takes effect) and starts or restarts it. Use it after a pull, or when the
|
|
running server and the checkout have drifted.
|
|
|
|
To run one by hand instead, launch it **from the repo root** -- that is
|
|
where it looks for its own app project (`app/`), and started anywhere else
|
|
its own card has no checkout -- and **fully detached from the calling
|
|
shell**, not via an agent's background-task tracking, which ties its
|
|
lifetime to the session:
|
|
|
|
```sh
|
|
setsid nohup /path/to/dev-updater </dev/null >server/dev-updater.log 2>&1 & disown -h
|
|
```
|
|
|
|
Verify with `ps -o pid,ppid,pgid,sid,tty,comm -p <pid>` — detached means
|
|
`PPID 1`, its own `SID`, `TT ?`. Check for a stale instance first
|
|
(`pgrep -af "[d]ev-updater" || true`): a second instance fails to bind and
|
|
silently leaves the old one answering. Stop with
|
|
`pkill -f "[d]ev-updater" || true`.
|
|
|
|
**Run `pgrep -f`/`pkill -f` as a command of its own, with nothing else on
|
|
the line, and bracket the first character of the pattern**
|
|
(`"[d]ev-updater"`). Both also exit nonzero when nothing matches, which is
|
|
the normal case; `|| true` that.
|
|
|
|
Why the pattern alone isn't enough, since this keeps catching people: each
|
|
command runs as `bash -c '<the whole command text>'`, so the wrapper's own
|
|
argv contains every word you wrote. An unbracketed pattern therefore
|
|
matches the shell running it — `pgrep` reports phantom matches and `pkill`
|
|
kills that shell outright (exit 144, truncated output, reads exactly like
|
|
the thing under test having crashed). Bracketing the pattern fixes only
|
|
*that* word: any **other** mention of the same string on the line re-arms
|
|
it. And you usually can't fix the other mention, because it is the real
|
|
command —
|
|
|
|
```sh
|
|
# Kills its own shell: the pattern is bracketed, but the path below
|
|
# still puts the plain string "target/debug/dev-updater" in the argv,
|
|
# and bracketing *that* would change which file gets executed.
|
|
pkill -f "[t]arget/debug/dev-updater" || true; setsid nohup ./target/debug/dev-updater …
|
|
```
|
|
|
|
so the only rule that always works is to keep them in separate commands:
|
|
one to stop, one to start.
|
|
|
|
One more way this bites, which bracketing does nothing about: `-f` matches
|
|
the **whole command line**, so it also catches processes that merely have
|
|
the string somewhere in a path. An agent's scratchpad directory is named
|
|
after the repository, so anything launched with a path through it -- the
|
|
Android emulator, say -- matches `[d]ev-updater` and gets killed along
|
|
with the server. Match the binary rather than the project when stopping
|
|
one: `pkill -f "[t]arget/debug/dev-updater"`.
|
|
|
|
## Environment notes
|
|
|
|
The machine itself — where the Android SDK is, that each command runs in a
|
|
fresh shell so exports have to be chained, and each repo having its own
|
|
emulator — is described once in `~/.claude/MACHINE.md`, which every
|
|
session reads. What follows is what that means here.
|
|
|
|
- `app/android-env.sh` applies the SDK override unconditionally, and
|
|
`.claude/settings.json` puts `platform-tools` alone on `PATH` so `adb`
|
|
works without sourcing anything. Everything else needs the env script.
|
|
- **This repo has its own AVD, named `dev-updater`**, which is what
|
|
`app/run-android.sh` and `app/enroll-emulator.sh` default to; every
|
|
Android project on this machine has one named after it, and none of them
|
|
share. Two sessions in two repos on one emulator each silently replace
|
|
the app the other just installed, which reads as a build that never
|
|
landed rather than as interference.
|
|
- **To exercise installing, add a throwaway test app**, not another repo's
|
|
real one. This server's whole job is putting *other* projects' builds on a
|
|
device, so trying that out needs something to install -- and a scratch
|
|
project you wrote is better for it than a real app, because you can make
|
|
it do whatever the case under test needs: fail its build, produce two
|
|
variants, change its label, bump nothing at all. A real app only does what
|
|
it happens to do. It goes in the list like any other project, so nothing
|
|
here needs to know it is a test.
|
|
- **To actually drive the app on the emulator, use
|
|
`app/enroll-emulator.sh`** rather than working enrolment out again. There
|
|
is no camera to scan the QR with, so it fires the `devupdater://enroll`
|
|
intent the app already accepts, generating a token once and adding it to
|
|
`config.ron` beside any real phone's. Two things it exists to stop you
|
|
rediscovering: `adb shell am start -d` loses everything after the first
|
|
unescaped `&`, because the URI reaches the *device's* shell; and the
|
|
server must be started with `--bind 0.0.0.0` for the emulator to reach
|
|
it at all, since it otherwise binds wg0 and the emulator has no route
|
|
there. The script's header has the detail.
|
|
**It names the device rather than assuming there is only one**, which
|
|
matters more now that every repo has its own AVD: a second emulator
|
|
running beside this one makes every bare `adb` call exit 1 with "more
|
|
than one device/emulator", and the version that ran `adb get-state` read
|
|
that as *no emulator running* -- the opposite of what had happened, which
|
|
sends you off to start a third. It resolves the serial by AVD name the
|
|
way `run-android.sh` always has, defaults to `dev-updater`, takes
|
|
`--avd NAME` or `AVD_NAME=`, and when it cannot find that one it lists
|
|
what *is* attached instead of guessing which of the three situations it
|
|
is in.
|
|
- The server needs `aapt2` (SDK build-tools) to add an app, and, for an
|
|
app that needs stripping, `llvm-strip` (NDK), `zipalign` and
|
|
`apksigner` (build-tools) **and `~/.android/debug.keystore`** -- the
|
|
last is the least guessable of the four, since it belongs to no SDK,
|
|
is created as a side effect of any Gradle Android build, and is
|
|
consumed by the pipeline's final step. A machine that builds APKs and
|
|
has no keystore is odd rather than new: something removed it after the
|
|
build, and the APK it produced is signed by a key that is now gone. **It has to find
|
|
both without an environment**, because the way this server usually
|
|
starts is from a service manager, and one hands its process a scrubbed
|
|
environment: measured in the Gentoo guest, an OpenRC user service gets
|
|
19 variables with neither `$ANDROID_HOME` nor `$ANDROID_NDK_HOME` among
|
|
them, and a systemd user unit inherits an equally bare one unless
|
|
somebody imported theirs. Neither branch of `service-default` passes any
|
|
through, so this is not a Gentoo quirk and "export it in the unit" is
|
|
not the fix. `HOME` does survive, which is what the home-relative
|
|
candidates in `sdk.rs` are for. The trap it produces is a build that
|
|
*succeeds* and a download that 500s -- a project's own build script
|
|
sources its `android-env.sh` and repairs the environment inside its own
|
|
process, while the strip runs in-process here and sources nothing.
|
|
So `sdk_roots` lists candidates and every lookup takes the first that
|
|
actually contains the tools it needs: an `$ANDROID_HOME` that exists is
|
|
not one with the tools in it (this machine's system-wide
|
|
`/opt/android-sdk` has a `build-tools/36.0.0` holding nothing but
|
|
`package.xml`), and taking it blamed `zipalign` for a root that should
|
|
never have been chosen. Filter *before* taking the newest, too --
|
|
`newest_child_where` exists because filtering the single newest child
|
|
can only reject that one directory, so `Sdk` sitting beside
|
|
`android-ndk-r27c` in `~/Android` hid the NDK from a message that said
|
|
"install one".
|