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>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 16:43:42 -04:00
1 parent 3727c7c67b
commit 013d7116d7
11 files changed
+772 -44

No files matched your search

+38 -16
View File
@@ -574,22 +574,44 @@ mutable at runtime from the phone.
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.
- **A runtime log can carry a *phone app's* own lines, and nothing here
had to change for it.** ai-app (`~/repos/ai-app-2`, the `rustify`
branch) has a phone this project delivers APKs to whose owner has no
`adb`; Android forbids one app reading another's `logcat`, so the app
keeps a bounded in-memory copy of its own log and posts it to
`ai-server` over the tunnel it is already enrolled against.
`ai-server` re-emits each line into its own `tracing` output, which is
this server's `Managed` service redirecting stdout to
`$XDG_DATA_HOME/dev-updater/services/<key>-<component>/<same>.log` --
so the lines arrive in the *server* component's Runtime tab, tagged
`ai_server::client_log` and carrying the app's own clock. Worth knowing
before adding a device-log store here: this route was chosen over one
(a new authenticated write endpoint on the TLS surface, a per-app log
store, `hasRuntimeLogs` for an APK component, and a second enrollment
for that app) precisely because the existing runtime log already
reaches the reader. Their reasoning is in ai-app's
- **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