Give each component build modes and a settings sheet

A component now declares its ways of being built in one list -- `modes:
["release", "debug"]`, the first the default -- and every command it runs
is handed the mode as its last argument, so a project whose script takes
`release` or `debug` names that script once. A field may instead be
written per mode, which is the escape hatch for the commands that 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.
A command written per mode is not handed the word as well; it already is
the answer, and a stray argument to a service binary is a process that
will not start.

One list rather than gathering names from whichever fields happened to
mention them is what makes a mode missing from one part unsayable: every
per-mode map is checked against it, so a gap is named rather than
resolved to some other mode's command.

Which mode to build in and whether to strip are the build machine's --
there is one checkout and one set of outputs, so a per-device mode would
have two phones rebuilding over each other silently. Which of the
finished builds a phone installs stays that phone's. Neither choice is
part of the acceptance gate, so both have to be carried across the
components list being rewritten, by acceptance and by the self entry's
startup reconciliation alike; without the second a mode chosen for this
server's own component would not survive the restart that applies it.

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 and serves the debug build for ever -- and for
an APK nothing else notices, because the "never built at all" check finds
any variant under the component's directory.

Each component card gains a settings sheet behind a gear at the row's
right-hand end, holding the mode, which build to install, strip, and an
Enrol button. The variant picker moved into it: on the card the two read
as one choice, both saying debug and release, and they are not. The row's
text is now bounded and truncates, so a long status can no longer push
the log and settings buttons off the edge.

`enroll:` is a declared command whose one line of stdout is a URL for the
phone to open after installing -- generic on purpose, run per press since
such a link is one-shot and carries a credential, and in the acceptance
gate because it runs on the build machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 06:22:05 -04:00
1 parent 0b7164bb30
commit 17d873ab7c
16 files changed
+2225 -220

No files matched your search

+105 -1
View File
@@ -36,7 +36,9 @@ mutable at runtime from the phone.
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.
`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` /
@@ -618,6 +620,108 @@ mutable at runtime from the phone.
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 the variant picker
lives in it.** 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 the build mode, which of the discovered builds this phone
installs, 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. The variant
picker moved here from the foot of the card because on the card the two
read as one choice -- both say `debug` and `release` -- and they are
not: one decides what the build machine *builds*, the other which
finished build this phone takes. Everything applies on Save except
Enrol, which is an action and happens on the press.
**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*