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:
1 parent
3727c7c67b
commit
013d7116d7
11 files changed
+772
-44
No files matched your search
+137
-6
@@ -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<PathBuf> {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// `<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<PathBuf> {
|
||||
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::<Vec<_>>();
|
||||
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");
|
||||
|
||||
+15
-3
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+66
-2
@@ -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<AppState>) -> 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Arc<AppState>>,
|
||||
UrlPath((key, name)): UrlPath<(String, String)>,
|
||||
Json(body): Json<RuntimeLogBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user