Commit Graph
17 Commits
Author SHA1 Message Date
irisandClaude Opus 5 17d873ab7c 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>
2026-09-02 06:22:05 -04:00
irisandClaude Opus 5 0b7164bb30 Say on the card when a project's own file was ignored
An unknown field 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 that understands it loses that project's components,
strip, staleWhen and resources at once. Nothing looks broken afterwards:
the card becomes a project that declares nothing, which is the ordinary
case, and the only thing saying otherwise was a line in this server's log
on a machine the person holding the phone cannot read.

project_config now answers a ProjectFile carrying the parse error beside
the declaration, and AppEntry::declaration_state returns both halves from
one read -- the manifest wants both, and the file is on that path. The
card draws its own sentence about what it means and what to do, then the
parser's own words, which name the file, the position and the offending
field; that half is 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 -- asserted in the
test, along with the error naming both the field and the file.

Seen on the emulator both ways: a project whose file gained a field from
the future says so under its action row, and the whole message goes when
the file parses again.

Raised by the tdep-survey session after its alsoWatch commit hit exactly
this against the older binary running here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:41:38 -04:00
irisandClaude Opus 5 c7b3af4861 A Rebuild on every component, and the keystore AGP actually uses
Two things, both from the same stuck download.

Each component row now carries its own Rebuild, which is /build scoped to
it -- the route already took ?component=, only the button was missing.
Update builds only what the staleness rules call behind, and those rules
cannot see a command reading undeclared files, an output changed
underneath this server, or a signing key replaced since the APK was made;
in all of those Update does nothing and the only force was a project-wide
Rebuild that rebuilds every sibling. Same action at two scales, so it
takes the project Rebuild's word and colour. hasBuild is new on the
manifest component so a component with no command of its own draws none,
which the project-level needsBuild could not answer.

And debug_keystore_path() now resolves the preferences directory the way
AGP does instead of assuming ~/.android: ANDROID_USER_HOME as-is, then
ANDROID_PREFS_ROOT and ANDROID_SDK_HOME with .android appended, then
$XDG_CONFIG_HOME/.android when that directory exists, then $HOME/.android.
Measured by running real builds against AGP 32.3.2, including the
existence test on the XDG step, which the documentation does not mention.
An ordinary desktop 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 it could not happen here. The
failure now names a keystore found further down the list, since an inert
one and an absent one are otherwise identical from a phone.

Verified on the emulator (component Rebuild: press, that component's own
progress, back to resting with no sibling moving) and against a
throwaway server for both keystore branches.

Reported by the tdep-survey session, which found the two keystores on the
serving host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:31:59 -04:00
irisandClaude Opus 5 b67c70fa2f A component can name the files its build reads elsewhere
Freshness compared the commits under a component's own cwd, which is
right about where its files are and wrong about what its build reads.
tdep-survey's two clients each source one shared scripts/android-sdk-env.sh:
a fix committed there changed what every build does, moved no component's
subtree head, rebuilt nothing, and left every card correctly reporting
"current" while answering a narrower question than the reader was asking.

alsoWatch names the rest. The paths join the component's own in the same
subtree-head and dirty comparisons -- git takes several pathspecs, so it
stays one call each -- and it is part of the acceptance gate like any
other declared field. Declared rather than inferred: which files a build
reads is not knowable from here, and both wrong guesses are expensive.

Raised by the tdep-survey session, which traced why its dioxus client
could never become stale on the serving host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:11:07 -04:00
irisandClaude Opus 5 f00c3b970f Name the button that gets past a signature mismatch
The advice was "rebuild this app on this machine", which somebody can
follow and still be stuck: nothing about a changed keystore moves the
checkout, so the component is not stale and neither a pull nor Update
runs the build. Rebuild is the one control that builds regardless, so
the message says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:05:39 -04:00
irisandClaude Opus 5 141402bcd2 Refuse to serve a slim APK signed with the wrong key
strip.rs re-signs with this machine's ~/.android/debug.keystore on the
assumption it is the key the build was signed with, and never checked.
Where it isn't -- a keystore recreated after the APK was built signs as
an entirely unrelated certificate -- the phone gets an APK Android
refuses, reporting "App not installed" with no cause, which reads as the
download rather than the signing.

Both certificates are now read with `apksigner verify --print-certs`,
compared as sets of digests so a v2 source and a v3 output still match,
and a mismatch is refused with both digests and the keystore path in the
message. The slim copy is deleted on refusal: nothing would serve it
without its stamp, but serveable_now reports the size of whatever slim
file is on disk.

Verified both ways against tdep-survey's app-dioxus: with a fresh
keystore under a throwaway HOME the download 500s with the message and
leaves nothing behind, and with the real one it serves the same
52,685,076 bytes as before, signed by the same certificate as the raw
build.

Raised by the tdep-survey session, which measured that a fresh debug
keystore is unrelated to the existing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:51:46 -04:00
irisandClaude Opus 5 0e9c7842f7 Find the SDK without an environment, and say what broke
A service manager hands its process a scrubbed environment -- measured in
the Gentoo guest, an OpenRC user service gets 19 variables and neither
ANDROID_HOME nor ANDROID_NDK_HOME among them -- so the tool lookups have
to stand on their own. Two ways they did not:

  - $ANDROID_HOME was taken as given. This machine exports a system-wide
    /opt/android-sdk whose build-tools/36.0.0 holds nothing but
    package.xml, so the download failed with "failed to spawn
    .../zipalign", naming the tool rather than the root that was wrong.
    Each lookup now takes the first candidate that actually contains what
    it needs, and names every place it looked.

  - The NDK search took the newest child of ~/Android and filtered it
    afterwards, which can only reject that one directory. It worked by
    the luck of `Sdk` sorting before `android-ndk-r27c`; a lowercase
    neighbour would have hidden an NDK that was sitting right there.
    newest_child_where filters before taking the max.

And the failure had nowhere to go: ApiError::Internal answered with an
empty body, so a missing NDK reached the phone as "Server returned HTTP
500" while the message naming the path stayed in a log on a machine the
person holding the phone cannot see. It now answers with the same
{err:#} chain it logs. Every route is behind the bearer token of a
device somebody enrolled themselves, so there is no third party to
withhold it from.

Diagnosis from the tdep-survey session, which measured the OpenRC
environment and the /opt/android-sdk contents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:30:02 -04:00
irisandClaude Opus 5 c35f84a373 Withhold a component's freshness while it is being built
Nothing re-reads the manifest during a run, so the "out of date" note
beside a running progress bar is the answer from before the button was
pressed -- shown for the length of a build, next to the work that is
making it wrong.

The card now reads a busy component's freshness as unknown, in the one
place the list of components is built for the cards, so the row's note
and the sibling-mismatch warning both go quiet. Same condition as the
one that disables the Update button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:09:29 -04:00
iris aa8e2b97a5 Render the escapes in an error, and drop the manifest field nobody read
Two loose ends from the per-component work.

The card's error is the tail of what the build actually printed, and a
compiler marks its own errors in colour -- so it arrived as `[1;31merror`,
with punctuation welded onto the one line somebody is trying to read. It now
goes through the same `ansiAnnotated` the log dialog uses: the colour is
drawn, and the sequences with no meaning on a phone are consumed rather than
printed, so a cursor movement cannot arrive looking like corruption either.
One composable covers it because every failure already went through
OutputText. 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.

And `/manifest` carried a `build` object that nothing on the phone ever
parsed. It was going to be how a running build survived leaving the app;
that is not built, so it is gone rather than left looking finished.
`RunningBuild` went with it -- it was split out of `BuildStatus` for that
one reader and had become an indirection with one user, so `status()` fills
one flat struct again. `/status` is byte-identical either way, since the
split was flattened on the wire; checked against the running server rather
than assumed.

Looked at on the emulator against a failing build: bold red `error`, blue
`-->`, no escape text anywhere, and long-pressing it still raises the
handles and the Copy toolbar.
2026-09-01 03:48:23 -04:00
iris 3685ab107d Don't report a failure nobody was there to see
Leaving the app for a while and coming back to it greeted you with a
five-second read timeout. Work started before the screen went away keeps
running -- a build being followed, a download, the manifest poll -- and when
the device sleeps or the link drops, that work fails and reported itself.
The message said nothing about the server and there was nothing left to do
about it, because the thing it described was over before it was read.

Every catch now goes through one `failure(e)`, which answers null while the
screen is not resumed. 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` joins `setComponent` so
both levels are written the same way and a caller can hand the answer over
whichever it is.

That is only safe because the resume re-read can now run from any state.
It was guarded on the list being loaded, which meant a failure ruled out
the one thing that would have replaced it -- a card that had gone red
stayed red until somebody found the Refresh button. Arriving and resuming
are also one event now rather than a LaunchedEffect beside a resume effect,
with `loadingList` claimed before the coroutine launches so the two cannot
stack two reads in one frame.

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. The resume's read alone retries once. It costs nothing when the
server really is down, because a refused connection comes back at once
rather than waiting out a timeout -- and it is a retry, not a guess.

Checked all four ways on the emulator, including the two the change was not
written for. Killing the server while the app was backgrounded mid-poll and
returning: the list loads, no timeout. Pressing Refresh with the server
down: the failure is shown at once, in full. Returning while it is still
down: retried, then reported, no stuck spinner. Returning once it is back:
the list recovers, which the old guard would have prevented.
2026-09-01 03:39:00 -04:00
iris a7f7f4550e Output a command produced can be selected and copied
A build's error is the tail of what the compiler actually said, and on a
phone there was no way to get it out: the machine that produced it is not
the machine in your hand, so a message you cannot copy is one you have to
retype into whatever you are fixing it with.

Theme.kt's OutputText is the one place that decides this, and every failure
message now 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.
The log body gets a SelectionContainer directly rather than going through
OutputText, since it is an AnnotatedString the ANSI renderer coloured inside
its own scrolling panel; Copy stays beside it for taking the whole thing.

The app's own words are deliberately left alone. Selection handles on a
status word like "failed" are noise, and a card that starts a selection on
long-press fights the gestures it already has -- so the split is by where
the words came from, not by how important they look.

Checked on the emulator against a failing build: long-pressing the error on
the card raises handles and the Copy toolbar, the state word above it does
not, and the log dialog still scrolls in both directions with selection
enabled.
2026-09-01 03:22:38 -04:00
iris 90082bd286 Each component builds on its own, and stops reporting when it is done
A project's components were decoupled everywhere except the one place it
showed: there was a single build slot per project, and a single card state
in the app keyed by project alone. So pressing Update on one client of a
two-client project disabled the other client's button for the length of a
build it shares nothing with, drew this one's progress bar and download
percentage under the other's row, and -- had the button been pressable --
would have been a silent no-op on the server, since a second request while
one was running returned without starting anything.

The slot is now per component. `Inner` has no `building` flag; a
component's own `ComponentRun` with an open `step` is the answer, and
`claim` writes that entry synchronously under the lock the route answers
from, so nothing can read a just-claimed component as idle -- which the
phone would take for a build that had already finished. A failure is
recorded against the component whose command it was rather than in the
project's one error slot, which two components building at once cannot
share.

A pull stays exclusive with everything, because there is one checkout and
it rewrites the files every component builds from. Releasing it and
claiming what it decided to build happen under one lock: a phone polling in
the gap would find a project neither pulling nor building and call the run
over.

The app mirrors the split -- `ProjectState` for the pull and the
project-wide Rebuild, `ComponentState` keyed by component for everything
one component is asked to do. Two hierarchies rather than one keyed by a
pair, so a download has nowhere project-wide to be stored. A component's
failure is drawn in its own row beside the Retry that acts on it, which is
also where a failed service action now reports.

And a finished component shows nothing at all: the elapsed times are gone
from both halves of the wire, and its button simply goes back to being
pressable. A bar, a count and a last line all describe something happening
now, and left up they sit there looking live next to a sibling that
genuinely is.

Verified on the emulator against test-projects/two-clients, which exists
for this: while `tablet` built, its row alone carried the bar and its
button alone was disabled, `phone` stayed pressable and silent, and both
returned to normal with no timing left behind.
2026-09-01 03:09:33 -04:00
irisandClaude Opus 5 4641b9ec9b A test project with two Android clients
The two-APK case had no fixture: it was verified against a scratch project
that was not kept, so the shape three recent bugs came out of was covered
only by a tempfile unit test. test-projects/two-clients is two Apk
components in one checkout, each with its own cwd and its own package --
what tdep-survey looks like, and what service-and-app cannot stand in for,
since only one of its two components produces an APK.

tablet is slow on purpose (six seconds of pretending to cross-compile,
reporting its own progress) beside a phone that builds in two, because that
is what makes ?component= visible rather than merely asserted: building one
finishes while the other has not started.

Verified live against a throwaway server rather than reasoned about.
build?component=phone ran phone alone; afterwards phone reported built and
tablet did not, which is the sibling-masking bug -- under the root-anchored
"never built at all" check the tablet would have read as current and been
refused its own first build. prepare?component=tablet then ran tablet
alone and it did build. Each component resolved its own APK under its own
directory, with its own size and mtime, so nothing reports the first
match for both.

Also corrects a sentence in that README that went stale when discovery
moved per-component: find_apks is anchored at project.join(cwd), not at
the project path. The conclusion it supports is unchanged -- every other
fixture declares no cwd, so the anchor is still the project root for them,
and building into app/ is still what keeps a stub APK from being offered
as a build of Dev Updater itself. Re-ran the check command in that section:
only the updater's own APK is reachable from the repository root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 00:46:41 -04:00
irisandClaude Sonnet 5 7eaf79370e Build one component instead of every one with a command
Pressing Update on one client of a multi-client project ran every
component's build to get the one that was actually asked for -- cheap
while a project had one APK, expensive the moment it had two and one of
them was slow (an ARM cross-compile, say). /prepare and /build now take
?component= to restrict a run to one named component; absent still means
the whole project, which is what Pull & Build, the project-row Rebuild,
and every single-component project keep doing. There is deliberately no
component-scoped Rebuild -- forcing one component's build without
touching the rest happens by pressing Update on it, which now runs
/prepare scoped to that component.

Verified against a live server driving a scratch two-Apk project:
building one component leaves the other's marker untouched, an unknown
?component= answers 404 naming the project and the component, and naming
none still builds both.

That verification surfaced a second, sharper bug the scoping change had
not caused but did make newly visible: component_is_stale's "never built
at all" check still asked find_apks of the whole project root, left
behind when per-component discovery (c4e19c2) moved everywhere else to
each component's own directory. A project with two Apk components has
one's output sitting under the root-anchored patterns too, so the moment
either component had ever been built, the whole project read as
"something is built here" -- and the other, never built, silently stopped
being offered its own first build. /prepare saw a component with a
command and no output and declared it current. Fixed by scoping the same
check to the component's own directory, guarded to Apk components only:
a Server never has an APK to find under its directory by definition, and
asking would have reported every server "never built" forever, which
broke two existing tests before the guard was added. Component::dir is
now the one definition of what a component's directory is, used by the
build command, the staleness check, and discovery alike.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 00:28:36 -04:00
irisandClaude Opus 5 c4e19c2e2b A project can produce more than one APK
tdep-survey is one checkout with a backend and two independent Android
clients, so a project has to be able to declare two Apk components. It
could not: APK_PATTERNS was anchored at the project root, which reached
the first client and stopped, and package, strip, the variant list and
the download all came from AppEntry::apk_component()'s first match --
correct only while a project produced one APK.

Each component's builds are now found under its own cwd, which is what
cwd already meant everywhere else: the directory the build command runs
in, the subtree staleness is scoped to, a server's WorkingDirectory. A
component that declares none sits at the project root, so nothing about
the single-APK case changes. The alternative -- naming the file on the
component -- would have made a component a file path, and an app being a
project path rather than a file path is this project's central invariant.

Everything derived from an APK follows it onto the component: package,
previousPackage, strip, size, mtime, variants and the rename note, in a
nested `apk` block that a Server simply doesn't have. Two clients install
over different packages, so first-wins would have checked the installed
state of one and reported it as the other's -- which looks exactly like a
correct answer. For the same reason a download naming no component is
refused rather than guessed; naming none still answers for a project with
one, which is what lets the frozen /self/apk keep working.

On the phone the per-device state is keyed by project and component, so
the variant picker moved inside the component's own card, beside the
build it picks, and the installed state, size and icon are each their own
component's. A project building two clients shows no single icon of its
own rather than borrowing the first one's.

Measured against the real thing: pointed at tdep-survey, the two clients
resolve to their own builds (15 MB and 153 MB), strip applies only to the
one that asked for it (153 MB served as 52 MB), pressing Install on one
row installed that row's package and left the other row offering Install,
and a download with no component named answers 400 saying which flag to
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:00:32 -04:00
irisandClaude Opus 5 db47972a25 A component's build progress reads under its buttons, and an unrelated history can be overridden
Two things, both about a card being able to act on what it says.

The progress bar, its counts and the last line a component printed now
sit below that component's buttons rather than above them. A bar reports
on the press that started it, so it reads in the order it happened -- and
above, it pushed the buttons down the moment a build began, moving the row
somebody had just pressed out from under their finger.

A pull that cannot fast-forward because the checkout shares no history
with its upstream now offers a way past it. There is no fast-forward
between two unrelated histories and there never will be, so the card was
one that could never be pulled again, with the only remedy on the build
machine -- exactly where the person holding the phone isn't. The card
reports the failure as before and a dialog offers a forced pull, naming
the branch and the upstream it is about to overwrite; confirming sends
?force=true, which resets onto the upstream instead of merging.

Whether it *is* that failure is decided structurally, by `git merge-base`
finding no common ancestor, rather than by matching what git printed:
those messages are translated, and a button that appeared only on an
English build machine would be worse than no button. It travels to the
phone as its own field 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:03:44 -04:00
iris b0e83059a3 dev-updater: build an app on the machine, install it on the phone
A Rust backend that discovers Android projects under configured roots,
builds one on request, and serves the APK over pinned TLS on a WireGuard
interface; an Android client that lists what is buildable, watches a build,
and installs the result. Enrolment carries the token and the CA, so the
phone trusts exactly the machine that issued it and nothing else.

`AGENTS.md` is the working guide and `README.md` the configuration
reference. The shared tunnel-and-TLS code lives in `vendor/wg-app-link`,
which ai-app uses too.

History before this point was squashed away, and a stale `config.json` went
with it: nothing had read that file since the config moved to RON outside
the checkout, and what it still held was one machine's absolute paths and
the names of projects on it.
2026-08-31 20:31:08 -04:00