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