605f353f4b9d4c508d117d8da31a122036f47e75
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
605f353f4b |
An app's devlog poll forwards at most 500 lines at a time
A ring holds thousands, and two things routinely hand this the whole of it at once: the first poll after the tab opens, and every restart of the app being read, since an in-memory ring starts again at zero. Unbounded that is one request, one append and one redraw of the whole log per second -- measured on the emulator as an ANR in this app while the app it was reading crash-looped. The cursor advances by what was sent, so a backlog drains over a few polls and nothing is lost. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
013d7116d7 |
An app's own log: a devlog contract, and a Runtime tab for an APK component
Android forbids one app reading another's logcat, so an APK this server delivers has had no way to say what it did to somebody holding the phone and nothing else. It can now expose its own bounded log through a ContentProvider at `<applicationId>.devlog`, guarded by a permission declared here; README.md's "An app's own log" is the whole contract, and any project this server delivers can implement it. The phone reads that provider while the Runtime tab is open and forwards what is new into the component's runtime log on this machine, so the tab renders from the same store a service's does and the history outlives the phone. `LogKind::Runtime` stays one kind with two sources rather than growing a third, and this server parses nothing -- what arrives is one line of text each, appended, exactly as a service's stdout is. The log button is now unconditional, like the gear beside it: with the tab able to say which of several reasons there is nothing to read, its absence was the one thing that could not say anything at all. Supersedes ai-app posting its ring to ai-server over the tunnel, which put a phone's lines under the wrong component and only ever worked for that one project. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
3727c7c67b |
wg-app-link: follow the enrollment link's new optional CA parameter
The shared enrollment_uri/print_enrollment now take the CA the link should carry, for ai-app's iris client -- an APK cross-compiled somewhere other than the machine its server runs on, which therefore cannot pin at build time. This app is built on the machine it talks to, so it passes None and its QR is unchanged; the reason is written at the call site. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c8545e70a6 |
AGENTS.md: a runtime log can carry a phone app's own lines
ai-app's phone has no adb and Android forbids one app reading another's logcat, so its app posts its own log ring to ai-server, whose stdout this project's Managed service already redirects and reports. The lines land in the server component's Runtime tab with nothing here changed. Recorded so the next reader of this file does not build a device-log store on the TLS surface for a case the existing runtime log covers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
283b971186 |
Offer the rollback, and call it Downgrade
Picking an old commit from the phone detaches HEAD, so every rollback is parked by construction -- and a parked checkout withheld the commit comparison entirely. The card answered the move with "up to date" and nothing to press, which left a rollback unfinishable from the phone. The withholding was right for one direction only. A checkout parked past what was built is somebody looking, and nagging about it is nagging about a decision. A checkout parked *behind* it is somebody rolling back, and there the point of the move is to get that commit built and installed. So `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 was 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 stays older than the 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. Verified end to end against a throwaway checkout driven through a scratch server: `current` on the built commit, `builtAhead` once parked back on the one before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuRzBWJuEGaWmMhDuX3X3p |
||
|
|
c41c36f25d |
Claiming the whole project means what's stale, not everything built here
after_moving (Pull and Update's shared machinery) gated 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 regardless of whether it was the one that moved. `trigger_if_needed(None, ...)` had the same shape one level up: check is_stale, then hand off to build_now, which is unconditional by design. So any pull that fetched anything rebuilt every sibling beside the one that actually changed -- "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; the bug was building past that answer once any one component tripped it. MismatchedPairNote also had its own word for the same "behind" freshness freshnessNote already calls "out of date" -- "older than this build" was a second name for one measurement, which is what "pulling says something weird about the compose apk" turned out to be. Reused the existing word. Regression test: a_pull_touching_one_component_does_not_rebuild_its_sibling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
663626345a |
Build a component this server has never built
"Not built here" was the one state nothing could clear. With no recorded commit a component was not stale, so Update built nothing, so no build ever wrote a record -- the card said it, and Pull, Update and the project's own button all did nothing about it, for ever. Worst for a server component, which the output check beside this one skips by design, having no APK to look for: that is why ai-app's backend sat there saying it after an Update that otherwise worked. Measured on a two-component fixture rather than argued. Before: /prepare answers `components: []` and the freshness is unchanged afterwards. After: the same call reports `step: "building"`, the card reads `current`, and `builtFrom` is on disk. So `nothing_built` asks the record as well as the output. The cost is one rebuild per component on a machine whose records were lost, which was the objection when this was written the other way round -- and is the right price now that the state is visible on the card. It is self-clearing: the first build writes the record and the question is never asked again. That leaves NotBuiltHere meaning exactly one thing, a component with no build command, where nothing was ever going to measure anything and no button would change it. Quiet and unflagged, as against NeverBuilt, which is flagged and has a Build beside it. One word had been covering both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ce1f02b98f |
Drop an unused Default on CarriedOver
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3b058f9332 |
Notes: one carry-across, and the built-in card taking its own update
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a8f75be190 |
Keep what a build recorded when a declaration is accepted
Accepting a project's declaration replaced its components with the declared ones and carried across two of the three things this machine knows about them -- the package read out of an APK and the mode somebody picked here -- while dropping `builtFrom`. So every card of that project went back to saying it had never been built here: no freshness, nothing flagged, and Update with nothing to do, which is exactly what "I switched branches on ai-app and nothing was flagged" looks like from the phone. Silent, too, because "never built here" is a true sentence about plenty of projects. The same field went missing from the built-in row's startup reconciliation last week; two carry-across pairs remembered separately is what produced both, so there is now one `CarriedOver` for all three and both callers take it whole. The built-in card's own update no longer goes through the self-update dialog. Its Update pulls, builds and installs this app like any other card's, downloading over the frozen /self/apk route -- the build that produced the APK rebuilt this server too, so the one download that has to survive the server changing underneath it takes the route nothing can rename. It waits for the server to be answering again both before fetching and before handing anything to the installer, since the restart lands somewhere in that window either way. The dialog stays for what it was written for: it is offered when the built-in card is missing from the list, or when there is no list at all because this app is too old to read what the server now sends. That is the one case where nothing on screen can offer the update itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f359cd3edf |
Freshness says which answer it is, and the self row keeps what it built
"Nothing was flagged as out of date" could not be answered from the phone, because the card drew every way of not knowing as nothing at all: a component this server had never built looked exactly like one whose build was current. Freshness now names the state. behind, 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, because "we could not check" is a difference in kind from "there is something here", and a colour cannot carry that. Only the measured three make a card read as out of date, sit above the "Up to date" heading, or light up Update. Saying it about something nobody measured is a nag with nothing behind it. And the bug the new words turned up on the first look: reconcile_self rebuilds this server's own row from its declaration at every startup, and built_from/built_mode were not carried across. The commit recorded 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. Its own card had no freshness ever again: never behind, never current, and its Update with nothing to do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
07639ed14d |
Follow a card that moved, and wait for the answer a move invalidated
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. The list now scrolls to it -- only when it is actually off screen, since moving something the reader can already see is a jump nobody asked for. The two frames before reading the position are load-bearing: the entry has just been applied, so the first frame carries the composition and the second reports the layout it produced, and reading sooner gives where the card was. The other half is why a switched branch showed no commits and no Pull. The build machine drops what it knew about that checkout's remote and asks again, but the answer lands after the response -- and startCheckout read the entry exactly once, so the card kept a pending check for ever. It waits on awaitCheck now, the same poll the card's own Refresh uses. Reproduced against a clone of ai-app before changing anything: immediately after the move newCommits false with checkPending true, three seconds later newCommits true, and nothing asking again in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
af605211e2 |
Pull, Update, Remove -- and a card that says when its build is behind
Iris's call: three buttons on a project's row, where Pull takes the
commits and stops and Update goes the whole way -- pull, build what that
brought in, install every APK it produced. Splitting them is the same
reasoning that stopped a checkout building: bringing a checkout up to
date is cheap and something you do while looking, and the expensive half
belongs to the button that says it does it.
Update is composed from the two things the app already does rather than
given a route of its own, so each half reports where it already did. It
skips a component whose build just failed, and hands the installer one
APK at a time: an install intent is modal, so two fired together means
the second replaces the first and a component is silently never
installed. The rest wait in ComponentState.ReadyToInstall, offered again
by continuePendingInstalls -- the same machinery as a download waiting
for a wrongly-signed copy to be removed. Deriving "is the installer
free?" from the component states could not work, because handing a file
over clears the state it would have read; caught by pressing Update on a
project that builds two APKs.
The project-wide Rebuild goes with it, and ProjectState collapses to one
Working(what, status): Pull, Update and moving the checkout differ in
nothing but the label, and the old one said "Pulling and building" for
all three.
Then the two things behind "nothing was flagged as out of date, and Pull
is disabled" after switching ai-app onto another branch:
- hasWorkWaiting is now one predicate for both the "Up to date" heading
and whether Update can be pressed, and it counts a component whose
build no longer matches the checkout. Without that a branch switch
left the card filed under "Up to date" with only a small note in one
component's row.
- A moved checkout re-asks its remote (RemoteChecks::recheck, on top of
a new Checks::forget). The cached answer was about the branch just
left and was reported with checkPending false, so a branch one commit
behind its upstream read as one with nothing to pull. Measured: git
said [behind 1] while the card said newCommits false.
A component's install button says Install rather than Build where nothing
is built yet, since the button beside it now says Build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9efe08e4ce |
A component with nothing built is not current with the checkout
`freshness` compared two commits and never asked whether there was an output at all, so a component built once and then cleaned reported the commit it was last built from and read as `current`: the card said nothing, filed it under "up to date", and offered Rebuild for a build that did not exist. Found on test-projects/two-clients, whose tablet component had a recorded builtFrom and no APK under it. The staleness rules already knew -- `component_is_stale` has asked `find_apks` under the component's own directory since per-component discovery landed -- so the two readers of "is this build current" disagreed, which is the shape worth removing rather than the one wrong answer. Both now go through `nothing_built`. Only for an Apk, since a Server has none to find, and only for a component this server builds, since with no command there is nothing to press and saying it would be a nag about a project built by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
25c069909b |
Fetch is a button, and moving the checkout builds nothing
Nothing here ever wrote a remote-tracking ref except the fetch 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 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.
POST /apps/{key}/fetch answers with the whole ref list, so the pickers
repopulate from after the fetch in one round trip. It is the only call
here that asks for --prune: making the picker say what the remote says is
its job, where a pull should change as little as it can. Still not on a
timer and not on opening the sheet, because a fetch mutates the checkout.
Picking a remote-only branch then had to work: `git checkout origin/topic`
detaches HEAD, since git's DWIM fires on the bare name, so the control
that says it is picking a branch produced the state picking a commit is
meant to produce -- and printed its detached-HEAD advice and succeeded.
Moving the checkout no longer builds. A pull is somebody taking new work;
a move is somebody looking, and charging a full build for a look starts a
minute of work an accidental tap cannot call back. What it leaves behind
is a component whose build no longer matches the checkout, so the build
button's word now follows the state: Rebuild only where every component
it covers is known current, Build otherwise.
The sheet loses its Checkout heading and its paragraph, both pickers sit
in a weighted row so a long branch name cannot wrap the label one letter
per line, and the failure text moved below them -- above, it shoved the
pickers down the screen as somebody reached for one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f983db154d |
Say a build is signed with the wrong key before the installer does
Three things. A download whose signing certificate does not match the installed copy is stopped with a dialog naming both digests and offering the one thing that gets past it: removing the old app. Android's own answer is "App not installed" with no cause, which reads as the download having failed. Where either certificate cannot be read the answer is "don't know" and the install goes ahead as before. The download is carried on the component's state so that the removal is followed by the install it was for rather than by a second download. Pressing that revealed that ACTION_DELETE now needs REQUEST_DELETE_PACKAGES, and fails invisibly without it -- so the "Remove the old app" offer for a renamed package had presumably never worked either. The build mode and the installed variant are one dropdown, not two. They answer to the same words, so two pickers offering debug and release read as one choice asked twice. Where a component declares modes the mode is the whole answer, and the server serves the build named after it rather than the newest. Every dropdown now hangs from one outlined pill with a chevron. And a checkout parked on a chosen commit is no longer called out of date, with HEAD in the commit picker as the way back to following the branch. The commit list comes from that branch rather than from HEAD, so parking no longer hides the commits after it -- the same one-way door the tracked-only dirty check closed, in a place that check did not reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3aa6f2f290 |
Move a project's checkout to a branch or commit from the phone
The project settings sheet now lists the checkout's branches and its last fifty commits, and picking one moves the checkout on the build machine and builds what that leaves behind. It is the same act as a pull on the same single checkout, so it goes through the same machinery and reports in the same place: `after_moving` is the half the two share, which is not the git command but handing the run over under one lock, so nothing can observe the moment between the move ending and the builds it decided on starting. The two lists are read when the sheet opens rather than riding on the manifest, since both spawn git and the manifest is fetched on every open, resume and Refresh. Local reads only, so opening the sheet cannot stall on a network round trip: what the remote has and this checkout has not fetched is Pull's business, and the card already says when there is any. Listing branches was wrong twice in ways only a real checkout showed. `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 filtering names that end in `/HEAD` matches nothing and a phantom branch called `origin` reached the phone; it is dropped by being a symref instead. The test written to cover the second asserted the same wrong thing and passed. Moving is refused over tracked modifications only, where a pull refuses over any dirt at all. The strict check makes this a one-way door: going back to a commit from before the .gitignore that covers this project's build output leaves that output untracked, so the tree is dirty and every move afterwards is refused -- back but never forward, from a phone, with the way out being the build machine. Nothing is lost by relaxing it, because git refuses to overwrite an untracked file itself and carries across the ones it would not, and its refusal arrives as the error the card already shows. Found by moving a checkout back and forth rather than by reading it: the first version passed its tests and trapped the checkout on the second move. Picking a commit leaves the checkout on no branch, which the card now says in those words rather than showing git's literal `HEAD` -- beside a branch icon that reads as a branch somebody named HEAD. Worth saying now that the sheet can produce the state, where before it was reachable only on the build machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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 (
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |