Files
ai-app/iris/android-app/build.rs
T
irisandClaude Fable 5.1 5be9f1baac iris-android-app: keep the app's own log, put it in Copy report, upload it
`app_log` is the platform half: `android_logger` as the logger the ring
forwards to, and an optional destination baked in by `build.rs` from
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA -- the same
build-time trust boundary the transcript config and the Compose APK's CA
already use, so no token is committed and an APK is good for the server
that built it. All three or none: two of the three would be a build with
nowhere to send its log and no way to say so.

`Copy report` now appends the ring to what goes on the clipboard (not to
the pane, which is on screen and would be buried) and flushes the
uploader first, so the lines are on the server by the time the message
describing them arrives. The Diagnostics pane gains two lines: how many
lines are held and when the last arrived, and what the uploader last did
-- "not tried yet", "failing -- <why>", and "no server configured" are
each their own wording, because "nothing is arriving" has three causes
that look identical otherwise.

Also: the re-emitted lines carry the target `ai_server::client_log`, not
a bare `client_log`. `RUST_LOG=ai_server=debug` -- the filter AGENTS.md
tells people to run with -- drops a bare target, so every line a phone
sent vanished with nothing saying so. Found by running it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:01:30 -04:00

174 lines
7.7 KiB
Rust

// Only does anything under the `transcript-screen` feature (RUST.md's I5
// Android integration) -- the plain tabs build (I2/I4) needs none of this
// and stays untouched, same reasoning as the feature gate in Cargo.toml.
//
// Bakes the sandbox server's host, port, token and pinned CA in at build
// time, the same way `app/androidApp/build.gradle.kts`'s
// `GeneratePinnedCert` task bakes the CA for the Compose app -- see that
// file's comment for why reading the machine's own certificate at build
// time is the right trust boundary. This build additionally bakes the
// host/port/token, which the Compose app does not: that app enrolls at
// runtime from a scanned QR/deep link, and a from-scratch enrollment UI
// (Keystore-sealed token storage, a QR/link scanner) is real, separate
// scope this integration does not need to build to answer RUST.md's
// question -- there is nothing here yet resembling `ServerConfig.kt`. So
// this is a **deliberate simplification for this rig only**: an APK built
// this way is good for exactly the emulator/server pair that built it, and
// must never be treated as a template for a real enrollment flow. Recorded
// in RUST.md's I5 box rather than left to be rediscovered.
use std::path::PathBuf;
fn main() {
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
return;
}
// Where this build sends its own log ring, if anywhere. Emitted for
// every build that has `client-core` (so the `bench` build, which
// returns below, gets it too): the log route is the one thing a bench
// APK on a phone with no `logcat` needs a server for even though it
// opens a checked-in fixture and talks to nothing else.
//
// **Optional, unlike the transcript config below.** A build with none
// of these set still keeps its ring and still shows it in `Copy
// report`; it just has nowhere to send it. So the same `build-apk.sh`
// works on a machine that has not decided where logs go.
emit_log_config();
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
// server at all -- `bench_client.rs` never references the `pinned`
// module this generates, so requiring a live server's host/port/token/
// CA to build it (as plain `transcript-screen` does, below) would be a
// pointless requirement for a build that talks to nothing.
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
return;
}
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
println!("cargo:rerun-if-env-changed=AI_APP_CA");
println!("cargo:rerun-if-env-changed=XDG_CONFIG_HOME");
let host = require_env(
"AI_APP_TRANSCRIPT_HOST",
"the sandbox server's host as the emulator reaches it, e.g. 10.0.2.2",
);
let port = require_env(
"AI_APP_TRANSCRIPT_PORT",
"the sandbox server's port -- app/ui-sandbox.sh's start banner prints it",
);
let token = require_env(
"AI_APP_TRANSCRIPT_TOKEN",
"the bearer token -- ~/.config/ai-app/sandbox-token, or the start banner's enrollment link",
);
let ca_pem = read_pinned_ca();
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let generated = format!(
"// Generated by build.rs from {host}:{port}. Do not edit.\n\
pub const HOST: &str = {host_lit:?};\n\
pub const PORT: u16 = {port};\n\
pub const TOKEN: &str = {token_lit:?};\n\
pub const CA_PEM: &str = {ca_lit:?};\n",
host = host,
port = port
.parse::<u16>()
.unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")),
host_lit = host,
token_lit = token,
ca_lit = ca_pem,
);
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
}
/// Writes `log_config.rs` into `OUT_DIR`: the server this build's log ring
/// uploads to, or `None`.
///
/// Read from the environment at build time rather than from anything in
/// the repository, which is the same trust boundary the CA below uses and
/// the reason no token is ever committed. On the host, Dev Updater builds
/// this APK on the machine `ai-server` runs on, so the values are that
/// machine's own -- an APK is good for the server that built it, which is
/// already true of the pinned CA.
fn emit_log_config() {
println!("cargo:rerun-if-env-changed=AI_APP_LOG_HOST");
println!("cargo:rerun-if-env-changed=AI_APP_LOG_PORT");
println!("cargo:rerun-if-env-changed=AI_APP_LOG_TOKEN");
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let host = std::env::var("AI_APP_LOG_HOST").ok();
let port = std::env::var("AI_APP_LOG_PORT").ok();
let token = std::env::var("AI_APP_LOG_TOKEN").ok();
let generated = match (host, port, token) {
(Some(host), Some(port), Some(token)) => {
let port: u16 = port
.parse()
.unwrap_or_else(|e| panic!("AI_APP_LOG_PORT={port:?} is not a u16: {e}"));
let ca_pem = read_pinned_ca();
format!(
"// Generated by build.rs. Do not edit.\n\
pub const LOG_SERVER: Option<LogServer> = Some(LogServer {{\n\
\x20 host: {host:?},\n\
\x20 port: {port},\n\
\x20 token: {token:?},\n\
\x20 ca_pem: {ca_pem:?},\n\
}});\n"
)
}
// All three or none: two of the three is a half-configured build
// that would fail at runtime with nothing on screen saying why.
(host, port, token) => {
assert!(
host.is_none() && port.is_none() && token.is_none(),
"AI_APP_LOG_HOST, AI_APP_LOG_PORT and AI_APP_LOG_TOKEN are set together \
or not at all -- a build with some of them has nowhere to send its log \
and no way to say so"
);
"// Generated by build.rs. Do not edit.\n\
pub const LOG_SERVER: Option<LogServer> = None;\n"
.to_string()
}
};
std::fs::write(out_dir.join("log_config.rs"), generated).unwrap();
}
/// The CA this machine's `ai-server` signs with: `AI_APP_CA`, else
/// `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. One reader for both generated
/// configs -- the log destination and the transcript destination are the
/// same server's certificate, and two copies of this would be two ways to
/// disagree about which one was pinned.
fn read_pinned_ca() -> String {
let ca_path = std::env::var_os("AI_APP_CA")
.map(PathBuf::from)
.unwrap_or_else(|| {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").expect("HOME must be set");
PathBuf::from(home).join(".config")
});
base.join("ai-app").join("certs").join("ca.pem")
});
let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| {
panic!(
"no CA certificate at {} ({e}).\n\
Start ai-server once on this machine first -- it generates the CA this \
build pins. Set AI_APP_CA=/path/to/ca.pem to build against a different one.",
ca_path.display()
)
});
let ca_pem = ca_pem.trim().to_string();
assert!(
ca_pem.starts_with("-----BEGIN CERTIFICATE-----"),
"{} is not a PEM certificate.",
ca_path.display()
);
ca_pem
}
fn require_env(name: &str, what: &str) -> String {
std::env::var(name).unwrap_or_else(|_| {
panic!("{name} must be set to build the transcript-screen feature -- {what}")
})
}