From 013d7116d700f064425051dd409567f01c7198c4 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 16:43:42 -0400 Subject: [PATCH] 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 `.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 --- AGENTS.md | 54 ++-- README.md | 67 +++++ app/androidApp/src/main/AndroidManifest.xml | 22 ++ .../kotlin/com/example/devupdater/AppsApi.kt | 21 ++ .../example/devupdater/ComponentLogDialog.kt | 151 ++++++++++- .../kotlin/com/example/devupdater/DevLog.kt | 250 ++++++++++++++++++ .../com/example/devupdater/UpdaterScreen.kt | 11 +- .../src/main/res/values/strings.xml | 11 + server/src/logs.rs | 143 +++++++++- server/src/registry.rs | 18 +- server/src/routes.rs | 68 ++++- 11 files changed, 772 insertions(+), 44 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/devupdater/DevLog.kt create mode 100644 app/androidApp/src/main/res/values/strings.xml diff --git a/AGENTS.md b/AGENTS.md index 9b2695c..7bc57ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/-/.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 + `.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/-.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 diff --git a/README.md b/README.md index 31899fe..255ab8a 100644 --- a/README.md +++ b/README.md @@ -534,6 +534,73 @@ an override anyway. It stays this server's business rather than the build script's because it is a property of *this* hop to a phone: a project built without dev-updater has no reason to strip. +## An app's own log + +A `Server` component's runtime log is what its service script reports. An +`Apk` runs on the phone instead, where Android forbids one app reading +another's `logcat` — so an app delivered here has no way to say what it +did to somebody holding the phone and nothing else. The way out is for the +app to keep a bounded copy of its own recent log and hand it over on the +device: the two are already on the same phone, so this needs no tunnel, no +token and no second enrolment. + +That is a **contract any app served here can implement**, not a feature +for one project. Implement it and that component gets a **Runtime** tab +beside its build log, showing the same view a service's runtime log gets. + +Expose a `ContentProvider`, exported and read-only: + +| | | +|---|---| +| authority | `.devlog` | +| read permission | `dev.updater.permission.READ_DEVLOG` (`android:readPermission`) | +| `content:///lines?since=` | every held line with `seq >= since`, ascending: `seq INTEGER, t_ms INTEGER, level TEXT, target TEXT, message TEXT` | +| `content:///status` | one row: `held INTEGER, dropped INTEGER, newest_seq INTEGER` | + +`insert`, `update` and `delete` throw — there is nothing for anyone else to +change. `t_ms` is unix milliseconds from the app's own clock, because a +line is timestamped when it happened rather than when it was read. +`dropped` is what the app's own bound discarded, counted rather than +inferred, so "the log starts here" and "the log was cut off here" can be +told apart. `newest_seq` is `-1` for a log nothing has been written to, +and it is also what makes a restart visible: an in-memory log starts again +at zero, and a reader whose stored cursor is now past the newest sequence +starts again from the beginning rather than silently skipping everything +since. + +The authority is derived from the `applicationId` rather than written +down, so a project's debug and release builds — installed side by side +under different ids — each get their own and cannot read each other's. + +The permission is declared by Dev Updater at `protectionLevel="normal"`. +`signature` is not available: the updater and the apps it delivers are +built on one machine but signed with *different* locally generated keys, +so a signature permission would be held by nothing at all. The cost of +`normal` is real and worth saying plainly — on a phone with Dev Updater +installed, any app that requests `dev.updater.permission.READ_DEVLOG` by +name can read another app's dev log. These are development builds on a +development phone, and the alternative was no log at all. + +Dev Updater's side of it: while the Runtime tab is open it asks the +provider once a second for what it has not seen, and forwards those lines +to this server, into that component's runtime log. It is a poll rather +than a `ContentObserver` because the contract does not oblige a provider +to call `notifyChange` — implementing it should be cheap. Forwarding +rather than rendering straight from the provider is what makes the history +outlive the phone and what lets one tab render both kinds. The lines +arrive here already formatted, one string each; this server appends them +and parses nothing, exactly as it does with a service's stdout, so an app +can change its own log format without anything here being taught about it. + +A component with no provider still gets the tab, and the tab says which of +the several reasons there is nothing to read — the app is not installed, +it exposes no devlog, its provider refused us, or it simply has not logged +anything yet. + +The reference implementation is iris's +`iris/android-app/app/src/main/java/dev/iris/android/demo/DevLogProvider.java` +in `~/repos/ai-app-2` (the `rustify` branch), over a ring in Rust. + ## Testing The Kotlin half has no unit tests, but it does have two checks that diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index 543eccf..cd03d5c 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -26,6 +26,28 @@ MainActivity.kt's runtime request. --> + + + + + Read a development app\'s own log + Lets this app read the recent log lines that + another locally-built app is keeping about itself, so they can be shown and sent to the + build machine. + diff --git a/server/src/logs.rs b/server/src/logs.rs index 7c58c74..deba102 100644 --- a/server/src/logs.rs +++ b/server/src/logs.rs @@ -88,6 +88,18 @@ fn build_log_dir() -> PathBuf { data_dir().join("builds") } +/// Where this server keeps the runtime lines a *phone* forwarded for an +/// APK component. +/// +/// Beside the build logs and under the same rule, because it is the same +/// kind of thing: generated state this server owns, which the uninstall +/// dialog's "remove logs" toggle already reaches through +/// [`data_dir`]. An APK does not run here, so there is no service script +/// to report a path and nothing else would ever write this file. +fn devlog_dir() -> PathBuf { + data_dir().join("devlogs") +} + /// Everything this server generates for itself, under `$XDG_DATA_HOME`. /// /// One answer to "where does generated state go", so the build logs and @@ -117,8 +129,12 @@ pub fn data_dir() -> PathBuf { pub enum LogKind { /// Written by this server while building the component. Build, - /// Written by the component itself while running, reported by its - /// service script. Never present for an APK, which does not run here. + /// Written by the component itself while running. For a `Server` + /// that is what its service script reports; for an `Apk` it is what + /// the phone forwarded out of that app's own devlog provider (see + /// [`append_devlog`]). Two sources, because the component runs in two + /// different places -- but one kind, so the tab and the route that + /// feeds it stay one mechanism. Runtime, } @@ -132,9 +148,17 @@ pub fn build_logs(key: &str, component: &str) -> Vec { } fn build_log_path(key: &str, component: &str) -> PathBuf { - // Both are already route-safe identifiers, but a component name comes - // from a project's own file, so anything that could climb out of the - // directory is flattened rather than trusted. + log_path(&build_log_dir(), key, component) +} + +/// `/-.log`. +/// +/// Both parts are already route-safe identifiers, but a component name +/// comes from a project's own file, so anything that could climb out of +/// the directory is flattened rather than trusted. One definition, so the +/// build store and the devlog store cannot come to disagree about what a +/// component's file is called. +fn log_path(dir: &Path, key: &str, component: &str) -> PathBuf { let safe = |text: &str| -> String { text.chars() .map(|c| { @@ -146,7 +170,68 @@ fn build_log_path(key: &str, component: &str) -> PathBuf { }) .collect() }; - build_log_dir().join(format!("{}-{}.log", safe(key), safe(component))) + dir.join(format!("{}-{}.log", safe(key), safe(component))) +} + +/// How large one component's devlog grows before the current generation is +/// rotated aside. +/// +/// The same two-generation split the build logs use, for the same reason: +/// what a reader wants is the recent end, and one file that only ever +/// grows would eventually be all this server keeps. Rotating at a size +/// rather than per run is the only thing available here -- a phone +/// forwards a stream, and there is no "run" for this server to notice the +/// start of. +const DEVLOG_ROTATE_BYTES: u64 = 4 * 1024 * 1024; + +/// The runtime log files a phone has forwarded for one APK component, +/// newest first. +/// +/// The same shape [`build_logs`] answers with, so the route that serves +/// either does not have to care which produced it. +pub fn devlog_logs(key: &str, component: &str) -> Vec { + let current = log_path(&devlog_dir(), key, component); + let previous = previous_of(¤t); + [current, previous] + .into_iter() + .filter(|path| path.is_file()) + .collect() +} + +/// Appends lines a phone read out of an installed app's devlog provider. +/// +/// **This server does not parse them.** What arrives is already one line +/// of text per log line, rendered on the phone from the provider's +/// columns, and what is stored is those bytes -- so nothing here knows +/// what a level or a target is, and a managed app is free to change its +/// own log format without this server being taught about it. That is the +/// same posture it takes towards a service's stdout, which is the other +/// thing this kind of log is. +pub fn append_devlog(key: &str, component: &str, lines: &[String]) -> Result<()> { + append_devlog_in(&devlog_dir(), key, component, lines) +} + +/// The half that does not know where the directory is, so a test can hand +/// it one. +fn append_devlog_in(dir: &Path, key: &str, component: &str, lines: &[String]) -> Result<()> { + let path = log_path(dir, key, component); + let parent = path.parent().expect("a log path has a directory"); + wg_app_link::private::create_dir(parent) + .with_context(|| format!("make {}", parent.display()))?; + if std::fs::metadata(&path).is_ok_and(|meta| meta.len() >= DEVLOG_ROTATE_BYTES) { + let _ = std::fs::rename(&path, previous_of(&path)); + } + // Owner-only on creation for the same reason every other file this + // server writes is: a phone's log is whatever that app wrote. + let mut file = wg_app_link::private::append_file(&path) + .with_context(|| format!("open {}", path.display()))?; + let mut body = String::new(); + for line in lines { + body.push_str(line); + body.push('\n'); + } + std::io::Write::write_all(&mut file, body.as_bytes()) + .with_context(|| format!("write {}", path.display())) } fn previous_of(path: &Path) -> PathBuf { @@ -232,6 +317,52 @@ mod tests { assert!(tail.truncated); } + /// The store an APK component's runtime log is: appended to, read + /// back through the same `tail` a service's log goes through. + #[test] + fn forwarded_lines_are_appended_and_read_back_in_order() { + let dir = tempfile::tempdir().expect("tempdir"); + let lines = |texts: &[&str]| texts.iter().map(|t| t.to_string()).collect::>(); + append_devlog_in(dir.path(), "ai-app", "app", &lines(&["one", "two"])).expect("append"); + append_devlog_in(dir.path(), "ai-app", "app", &lines(&["three"])).expect("append again"); + let path = log_path(dir.path(), "ai-app", "app"); + assert_eq!(tail(&path, 0).expect("tail").text, "one\ntwo\nthree"); + } + + /// Two components of one project keep their own, so a phone forwarding + /// for one cannot land in the other's tab. + #[test] + fn each_component_has_its_own_forwarded_log() { + let dir = tempfile::tempdir().expect("tempdir"); + append_devlog_in(dir.path(), "p", "one", &["a".to_string()]).expect("append"); + append_devlog_in(dir.path(), "p", "two", &["b".to_string()]).expect("append"); + assert_eq!( + tail(&log_path(dir.path(), "p", "one"), 0).unwrap().text, + "a" + ); + assert_eq!( + tail(&log_path(dir.path(), "p", "two"), 0).unwrap().text, + "b" + ); + } + + /// The half that is easy to leave out: without rotation the file only + /// ever grows, and a phone forwarding a stream has no run boundary for + /// this server to rotate on. + #[test] + fn a_devlog_past_the_bound_rotates_rather_than_growing_for_ever() { + let dir = tempfile::tempdir().expect("tempdir"); + let big = vec!["x".repeat(DEVLOG_ROTATE_BYTES as usize)]; + append_devlog_in(dir.path(), "p", "app", &big).expect("append"); + append_devlog_in(dir.path(), "p", "app", &["after".to_string()]).expect("append"); + let current = log_path(dir.path(), "p", "app"); + assert_eq!(tail(¤t, 0).expect("tail").text, "after"); + assert!( + previous_of(¤t).is_file(), + "and what was there is the previous generation, not gone" + ); + } + #[test] fn zero_means_everything_it_is_allowed_to_read() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/server/src/registry.rs b/server/src/registry.rs index e89a56c..88709b6 100644 --- a/server/src/registry.rs +++ b/server/src/registry.rs @@ -276,9 +276,21 @@ impl AppEntry { }; match kind { crate::logs::LogKind::Build => crate::logs::build_logs(&self.key, name), - crate::logs::LogKind::Runtime => crate::service::driver(&self.key, component) - .map(|script| crate::service::logs(&script, &self.project_path, component.cwd())) - .unwrap_or_default(), + // A component's runtime log comes from wherever that + // component runs. A `Server` runs on this machine and its + // script says where it writes; an `Apk` runs on a phone, and + // what this machine has is what the phone forwarded out of + // that app's devlog provider. One kind with two sources + // rather than two kinds, because the reader is asking the + // same question either way. + crate::logs::LogKind::Runtime => match component { + Component::Apk { .. } => crate::logs::devlog_logs(&self.key, name), + Component::Server { .. } => crate::service::driver(&self.key, component) + .map(|script| { + crate::service::logs(&script, &self.project_path, component.cwd()) + }) + .unwrap_or_default(), + }, } } diff --git a/server/src/routes.rs b/server/src/routes.rs index 2069f9a..8e43450 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -32,8 +32,13 @@ //! POST /apps/{key}/components/{name}/enroll-link //! run that component's `enroll:` and //! answer the URL it printed -//! GET /apps/{key}/components/{name}/logs[?lines=&generation=] +//! GET /apps/{key}/components/{name}/logs[?lines=&generation=&kind=] //! what that component wrote +//! POST /apps/{key}/components/{name}/runtime-log {lines} +//! lines a phone read out of an +//! installed app's devlog provider, +//! appended to that APK component's +//! runtime log //! POST /apps/{key}/components/{name}/{action} //! install|uninstall|start|stop|restart //! a server component, on the *build @@ -132,6 +137,15 @@ pub fn tls_router(state: Arc) -> Router { "/apps/{key}/components/{name}/enroll-link", post(enrollment_link), ) + // Ahead of `{action}` like its neighbours. The write half of the + // runtime log an APK component has: the component runs on the + // phone, so the phone is the only thing that can read it, and + // this is where what it read is kept so the tab renders from the + // same store a service's does and the history outlives the phone. + .route( + "/apps/{key}/components/{name}/runtime-log", + post(append_runtime_log), + ) .route( "/apps/{key}/components/{name}/{action}", post(service_action), @@ -422,7 +436,14 @@ impl ManifestComponent { // script costs a process and this path is fetched on every open, // resume and Refresh. let has_build_logs = !crate::logs::build_logs(key, &name).is_empty(); - let has_runtime_logs = is_server && !state.service_checks.logs(key, &name).is_empty(); + // A server's runtime log is its script's answer, cached by the + // background check; an APK's is what a phone has forwarded here, + // which is a stat like the build log beside it. + let has_runtime_logs = if is_server { + !state.service_checks.logs(key, &name).is_empty() + } else { + !crate::logs::devlog_logs(key, &name).is_empty() + }; // Only a server keeps anything on this machine, and only a // project that says where. Both halves have to be true before // there is a path to show. @@ -1386,6 +1407,49 @@ async fn enrollment_link( Ok(Json(EnrollmentLink { url })) } +/// Lines a phone read out of an installed app's devlog provider. +/// +/// Already rendered: one string per log line, formatted on the phone from +/// the provider's columns. This server appends the bytes and parses +/// nothing, which is the same thing it does with a service's stdout -- +/// what a level or a target looks like is the managed app's business, and +/// teaching this server about it would make every app's log format +/// something to keep in step here. +#[derive(Deserialize)] +struct RuntimeLogBody { + lines: Vec, +} + +/// Appends those lines to an APK component's runtime log. +/// +/// An APK runs on the phone, so the phone is the only thing that can read +/// what it wrote -- and a phone is replaced, wiped and reinstalled, where +/// this store is what makes the history outlive it. Refused for a +/// `Server`, whose runtime log is its own script's answer: two writers of +/// one file is two versions of the truth about what a service printed. +async fn append_runtime_log( + State(state): State>, + UrlPath((key, name)): UrlPath<(String, String)>, + Json(body): Json, +) -> Result { + let entry = state.entry(&key).ok_or(ApiError::UnknownApp(key.clone()))?; + let component = entry + .component(&name) + .ok_or_else(|| ApiError::UnknownComponent(key.clone(), name.clone()))?; + if !matches!(component, crate::config::Component::Apk { .. }) { + return Err(ApiError::BadRequest(format!( + "{name} runs on this machine, so its runtime log is its service script's" + ))); + } + if body.lines.is_empty() { + return Ok(StatusCode::NO_CONTENT); + } + tokio::task::spawn_blocking(move || crate::logs::append_devlog(&key, &name, &body.lines)) + .await + .context("appending a forwarded runtime log")??; + Ok(StatusCode::NO_CONTENT) +} + /// What somebody chose for one component on the settings sheet. /// /// `mode` absent means "no choice, take the first declared one", which is