From d8562d96a3eebc92feaf69aa0bb1f94b0b41adb0 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 16:45:51 -0400 Subject: [PATCH] iris android app: told which server by an enrol link, not by its build The APK is cross-compiled here and run against the server on the host, so everything build.rs baked in (AI_APP_TRANSCRIPT_HOST/_PORT/_TOKEN and this machine's CA) was good for exactly the pair that built it -- and a token in a delivered artifact besides. MainActivity registers aiapp://enroll, hands the URI and the app's private files directory to Rust, and client_core::config stores it 0600; transcript_client reads it afresh per transport, so opening a new link repoints a running app. Diagnostics says which of three things is true, because they want different actions: 'enrolled: host:port', 'not enrolled -- open the enrol link from Dev Updater', and 'enrolment unreadable: ...' for the case nothing could be found out. The last is why status() has an Unknown arm at all. ui-sandbox.sh's printed enrol command now carries the CA, which is what makes it work for an app with no baked copy. Verified on this checkout's emulator: fresh install reads 'not enrolled', the intent enrols (log: 'enrolled with 10.0.2.2:8519', enrollment.json -rw-------), Diagnostics then reads 'enrolled: 10.0.2.2:8519', and the CA reconstructed from that link is byte-identical to the machine's ca.pem and validates the server over curl. Android offered the chooser between this app and the Compose one, which is the intended behaviour. Co-Authored-By: Claude Fable 5.1 --- app/ui-sandbox.sh | 17 ++- iris/android-app/Cargo.lock | 1 + .../app/src/main/AndroidManifest.xml | 15 ++ .../dev/iris/android/demo/MainActivity.java | 50 +++++++ iris/android-app/build.rs | 97 +++---------- iris/android-app/src/bench_client.rs | 7 +- iris/android-app/src/enrollment.rs | 133 ++++++++++++++++++ iris/android-app/src/lib.rs | 83 +++++++++++ iris/android-app/src/transcript_client.rs | 42 +++--- 9 files changed, 340 insertions(+), 105 deletions(-) create mode 100644 iris/android-app/src/enrollment.rs diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh index 8da69d0..789ef47 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -404,12 +404,27 @@ done # Percent-encoded because the app URL-decodes the deep link's query: a # token with '+' in it enrols as one with a space, and nothing reports it. enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN") +# The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of +# the DER, which needs no percent-encoding). The Compose app ignores it and +# pins the copy its APK was built with; the iris app has no baked copy at +# all -- it is cross-compiled and could be pointed at any machine -- so +# without this it enrols and then trusts nothing. Minted here rather than by +# `--enroll-link` because this token is the sandbox's own, carried across +# restarts so the emulator stays enrolled (see the top of this file). +ca=$(python3 - "$CERTS/ca.pem" <<'CA' +import base64, sys +pem = open(sys.argv[1]).read() +body = pem.split("-----BEGIN CERTIFICATE-----")[1].split("-----END CERTIFICATE-----")[0] +der = base64.b64decode("".join(body.split())) +print(base64.urlsafe_b64encode(der).decode().rstrip("=")) +CA +) cat < + + + + + + + + diff --git a/iris/android-app/app/src/main/java/dev/iris/android/demo/MainActivity.java b/iris/android-app/app/src/main/java/dev/iris/android/demo/MainActivity.java index de2cbda..e843c38 100644 --- a/iris/android-app/app/src/main/java/dev/iris/android/demo/MainActivity.java +++ b/iris/android-app/app/src/main/java/dev/iris/android/demo/MainActivity.java @@ -1,6 +1,8 @@ package dev.iris.android.demo; import android.app.Activity; +import android.content.Intent; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.view.WindowInsets; @@ -20,9 +22,28 @@ public final class MainActivity extends Activity { System.loadLibrary("main"); } + /** + * The app's private directory, where the Rust side keeps its enrollment + * (`src/enrollment.rs`). Handed over before the view is built, because + * the client the view creates reads the enrollment as it starts. + */ + private static native void nativeSetFilesDir(String path); + + /** + * One `aiapp://enroll?host=&port=&token=&ca=` link, as Dev Updater's + * Enroll button opens it. Parsed and stored on the Rust side, which is + * where the enrollment lives for the desktop app too -- nothing about + * the link's format is known here. + */ + private static native void nativeEnroll(String uri); + @Override public void onCreate(Bundle state) { super.onCreate(state); + // Before the view: creating it starts the Rust client, which asks + // straight away which server it is enrolled with. + nativeSetFilesDir(getFilesDir().getAbsolutePath()); + handleEnrollmentIntent(getIntent()); IrisView view = new IrisView(this); view.setLayoutParams(new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); @@ -99,6 +120,35 @@ public final class MainActivity extends Activity { }); } + /** + * A link that arrives while the activity is already up. `singleTop` is + * not set, so this is the resumed case only -- the fresh-launch case + * goes through `onCreate`'s `getIntent`. `setIntent` so a later + * `getIntent` reports the one actually being acted on rather than the + * one this activity started with. + */ + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleEnrollmentIntent(intent); + } + + /** + * Hands a VIEW intent's URI to the Rust side, which decides whether it + * is an enrollment link -- the scheme is checked here only so a launch + * intent (which carries no data) costs nothing. + */ + private static void handleEnrollmentIntent(Intent intent) { + if (intent == null) { + return; + } + Uri data = intent.getData(); + if (data != null) { + nativeEnroll(data.toString()); + } + } + /** Read one `WindowInsets` and hand it to the Rust side. The only * place that reads these fields, so the static dispatch and the * animation callback above cannot come to report different things. */ diff --git a/iris/android-app/build.rs b/iris/android-app/build.rs index fe8ea50..8def610 100644 --- a/iris/android-app/build.rs +++ b/iris/android-app/build.rs @@ -2,82 +2,29 @@ // 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. +// **Nothing about the server this app talks to is baked in any more.** It +// used to be (`AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN` plus the machine's +// own CA), which made an APK good for exactly the emulator/server pair +// that built it -- and useless for the case that matters, an APK +// cross-compiled in this VM and run against the server on the host. The +// destination arrives at runtime instead, from an `aiapp://enroll` link +// carrying the CA with it (`src/enrollment.rs`), the same way the Compose +// app and `desktop-app` are told. +// +// What is left here is the log upload's own destination, which is on its +// way out for a different reason (Dev Updater is growing a runtime-log +// view of its own, 2026-09-07) and is left untouched for that change. 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. + // Where this build sends its own log ring, if anywhere -- the one + // thing left that a build is told rather than enrolled with. A build + // told none of it still keeps its ring and still shows it in `Copy + // report`; it just has nowhere to send it. 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::() - .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 @@ -133,10 +80,8 @@ fn emit_log_config() { } /// 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. +/// `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. Only the log upload pins this +/// now; the screen's own server arrives with its CA at enrolment time. fn read_pinned_ca() -> String { let ca_path = std::env::var_os("AI_APP_CA") .map(PathBuf::from) @@ -165,9 +110,3 @@ fn read_pinned_ca() -> String { ); 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}") - }) -} diff --git a/iris/android-app/src/bench_client.rs b/iris/android-app/src/bench_client.rs index dc732f1..2a36d06 100644 --- a/iris/android-app/src/bench_client.rs +++ b/iris/android-app/src/bench_client.rs @@ -559,8 +559,13 @@ impl BenchClient { // composer up" cannot be told from "the listener never fired" // without it (`AndroidUiState::insets_report`). format!( - "{renderer}\n{}\n{}", + "{renderer}\n{}\n{}\n{}", self.android_state().insets_report(), + // Which server this build talks to, and what to do when the + // answer is "none" -- the bench itself opens a checked-in + // fixture and needs no server, so this pane is the only place + // an enrolment can be seen to have taken. + crate::enrollment::status_line(), crate::app_log::diagnostics_line(self.log_upload.as_ref()) ) } diff --git a/iris/android-app/src/enrollment.rs b/iris/android-app/src/enrollment.rs new file mode 100644 index 0000000..83c6c14 --- /dev/null +++ b/iris/android-app/src/enrollment.rs @@ -0,0 +1,133 @@ +//! Which `ai-server` this app talks to, and how it was told. +//! +//! The parsing, the file and its owner-only mode are +//! `client_core::config` (`EnrolledServer`/`EnrollmentStore`), shared with +//! the desktop app. What is genuinely this platform's, and all that is +//! here, is the intent plumbing: Android hands an `aiapp://enroll?...` +//! link to `MainActivity`, which passes it and the app's private files +//! directory across JNI (see `lib.rs`'s two exported functions). +//! +//! **Why the app is told at runtime rather than at build time.** The APK +//! is cross-compiled in a VM and run against the server on the host, whose +//! CA and token are not this machine's -- so nothing about the destination +//! can be baked in, and no token or CA may sit in a repo or a delivered +//! artifact either way. The CA arrives with the link (`ca` parameter, +//! `wg_app_link::enroll::ca_param`), which is what makes an APK built +//! anywhere able to pin the server it is pointed at. +//! +//! The files directory is process-wide state, which this project otherwise +//! avoids: it arrives from the activity, and `AndroidAppState::new` -- the +//! first thing that wants the enrollment -- has no parameter it could come +//! in through. Same shape, and the same reason, as +//! `client_core::log_ring`'s process ring. + +#[cfg(not(feature = "bench"))] +use client_core::api::UreqTransport; +use client_core::config::{EnrolledServer, EnrollmentStore}; +use std::path::PathBuf; +use std::sync::OnceLock; + +/// `Context.getFilesDir()`, handed over by `MainActivity` before it builds +/// the view. Set once per process; a second call with a different path is +/// a programmer error rather than something to recover from, and a second +/// call with the same one is what a re-created activity does. +static FILES_DIR: OnceLock = OnceLock::new(); + +pub fn set_files_dir(dir: PathBuf) { + if let Err(existing) = FILES_DIR.set(dir.clone()) { + assert_eq!( + existing, dir, + "the app's files directory was set twice with different paths" + ); + } +} + +/// `None` before `MainActivity` has handed the directory over -- which is +/// **not** the same as "not enrolled", and is why [`status`] has a state +/// for it (UI_RULES: design the unknown state first). +fn store() -> Option { + FILES_DIR.get().map(EnrollmentStore::new) +} + +/// What this app has been told, or why it has not been. +pub enum Status { + Enrolled(EnrolledServer), + /// Nothing has been enrolled yet: the ordinary first-run state. + NotEnrolled, + /// The question could not be answered -- the activity never handed a + /// files directory over, or the file is there and unreadable. Kept + /// apart from `NotEnrolled` because the two want different actions + /// from whoever is looking. + Unknown(String), +} + +pub fn status() -> Status { + let Some(store) = store() else { + return Status::Unknown("the activity never handed over a files directory".to_string()); + }; + match store.load() { + Ok(Some(server)) => Status::Enrolled(server), + Ok(None) => Status::NotEnrolled, + Err(error) => Status::Unknown(error.to_string()), + } +} + +/// One line for the diagnostics pane. The three states read differently on +/// purpose: "not enrolled" says what to do about it, and "couldn't tell" +/// must not be mistaken for it. +/// +/// Only the bench build has a pane to put this in -- same gate, and the +/// same reason, as `app_log::diagnostics_line`. The transcript build says +/// the same things where they matter to it, in the message +/// [`transport`]'s error becomes on screen. +#[cfg(feature = "bench")] +pub fn status_line() -> String { + match status() { + Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port), + Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(), + Status::Unknown(why) => format!("enrolment unreadable: {why}"), + } +} + +/// Parses an `aiapp://enroll?...` link and saves it, replacing whatever +/// was enrolled before -- opening a link is how somebody says "this server +/// now", including after the old one's token was rotated. +/// +/// The returned `Err` is the message for a person: this is called from a +/// tap on a link, and a link that did nothing with nothing said is the +/// failure the UI rules are most insistent about. +pub fn apply_link(uri: &str) -> Result { + let server = EnrolledServer::parse_link(uri)?; + let store = store().ok_or("the app has no files directory to save an enrollment in")?; + store + .save(&server) + .map_err(|error| format!("couldn't save the enrollment: {error}"))?; + Ok(server) +} + +/// A transport for the enrolled server, pinning the CA the link carried. +/// +/// Gated to the same builds as `transcript_client`, its only caller: the +/// bench build opens a checked-in fixture and reaches no server, so +/// compiling this into it would be a warning about dead code that is +/// dead on purpose. +/// +/// Every failure here is a sentence a screen can show, because there is +/// nowhere else for it to go: this app has no `logcat` on the phone it is +/// built for. +#[cfg(not(feature = "bench"))] +pub fn transport() -> Result { + let server = match status() { + Status::Enrolled(server) => server, + Status::NotEnrolled => { + return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string()); + } + Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")), + }; + let ca_pem = server.ca_pem.as_ref().ok_or( + "The enrollment link carried no CA, so there is nothing to pin. \ + Enrol again with a link minted by this server.", + )?; + UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes()) + .map_err(|error| error.message) +} diff --git a/iris/android-app/src/lib.rs b/iris/android-app/src/lib.rs index a3c7a2d..0ef81e0 100644 --- a/iris/android-app/src/lib.rs +++ b/iris/android-app/src/lib.rs @@ -40,6 +40,7 @@ use android_view::{ Context, View, jni::{ JNIEnv, JavaVM, + objects::{JClass, JString}, sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong}, }, register_view_class, @@ -60,6 +61,11 @@ mod app_log; mod bench_client; #[cfg(feature = "bench")] mod bench_jni; +/// Which server this app talks to, told to it at runtime by an +/// `aiapp://enroll` link. Only where `client-core` is linked -- the plain +/// tabs demo makes no network call and has nothing to enrol against. +#[cfg(feature = "transcript-screen")] +mod enrollment; #[cfg(all(feature = "transcript-screen", not(feature = "bench")))] mod transcript_client; @@ -142,3 +148,80 @@ pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> iris::android::register_native_methods(&mut env, VIEW_CLASS); JNI_VERSION_1_6 } + +/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed +/// over before the view exists because that is where the enrollment is +/// read from and written to (`enrollment`'s module doc). +/// +/// Exported by name rather than registered through `RegisterNatives`: the +/// view's methods are registered because `android-view` owns that class +/// and hands out one function pointer, whereas these two are this app's +/// own activity and the mangled name is the whole of what is needed. +/// +/// Declared in every build, including the tabs demo that has no +/// `client-core` to store anything -- a `native` method Java declares and +/// the library does not export is an `UnsatisfiedLinkError` when the class +/// loads, which would take down a build that merely shares the activity. +/// +/// # Safety +/// Called by the JVM with the arguments its `native` declaration names. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir( + mut env: JNIEnv, + _class: JClass, + dir: JString, +) { + let Some(dir) = jstring(&mut env, dir) else { + return; + }; + #[cfg(feature = "transcript-screen")] + enrollment::set_files_dir(std::path::PathBuf::from(&dir)); + log::debug!("iris app: files directory is {dir}"); +} + +/// `MainActivity.nativeEnroll` -- one `aiapp://enroll?...` link, from the +/// VIEW intent that started or resumed the activity. +/// +/// Logged either way rather than answered: the activity has nothing to do +/// with the result, and where the enrollment shows up is the diagnostics +/// pane (`enrollment::status_line`), which reads the stored answer rather +/// than being told it. +/// +/// # Safety +/// Called by the JVM with the arguments its `native` declaration names. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll( + mut env: JNIEnv, + _class: JClass, + uri: JString, +) { + let Some(uri) = jstring(&mut env, uri) else { + return; + }; + #[cfg(feature = "transcript-screen")] + match enrollment::apply_link(&uri) { + // Never the token: `wg-app-link`'s enroll module forbids logging + // it, and this line would otherwise be the one place it leaked. + Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port), + Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"), + } + #[cfg(not(feature = "transcript-screen"))] + log::warn!("iris app: {uri} arrived, but this build has no server to enrol with"); +} + +/// A `JString` as a Rust `String`, or `None` for a null or non-UTF-8 one -- +/// neither is worth taking the app down for, and both are logged where +/// they happen. +fn jstring(env: &mut JNIEnv, value: JString) -> Option { + if value.is_null() { + log::warn!("iris app: the activity passed a null string across JNI"); + return None; + } + match env.get_string(&value) { + Ok(value) => Some(value.into()), + Err(error) => { + log::warn!("iris app: couldn't read a string from the activity -- {error}"); + None + } + } +} diff --git a/iris/android-app/src/transcript_client.rs b/iris/android-app/src/transcript_client.rs index e6a382c..b38340a 100644 --- a/iris/android-app/src/transcript_client.rs +++ b/iris/android-app/src/transcript_client.rs @@ -7,15 +7,15 @@ //! //! **Deliberate simplification, recorded rather than left to be //! rediscovered (RUST.md's I5 box has the full account)**: there is no -//! session list and no enrollment UI here. The server, port, token and -//! pinned CA are baked in at build time (`build.rs`'s -//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first -//! session `ApiClient::fetch_sessions` returns is opened automatically -- -//! there is nothing to tap to get there, which is what `transcript-bench.sh` -//! and `ui-trace` need to land straight on the screen under test. A real -//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's -//! Keystore-sealed `ServerConfig.kt`; building a second one of those was -//! not this pass's job. +//! session list here -- the first session `ApiClient::fetch_sessions` +//! returns is opened automatically, since there is nothing to tap to get +//! there, which is what `transcript-bench.sh` and `ui-trace` need to land +//! straight on the screen under test. +//! +//! Which server it opens it against is no longer baked in: it is the +//! enrollment an `aiapp://enroll` link left behind (`crate::enrollment`, +//! and `desktop-app`'s identical `--link`), because an APK +//! cross-compiled here cannot pin the CA of a server on the host. //! //! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** -- //! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from @@ -46,10 +46,6 @@ use iris::prelude::*; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -mod pinned { - include!(concat!(env!("OUT_DIR"), "/pinned_config.rs")); -} - pub struct TranscriptClient { ui_state: AndroidUiState, /// The screen's own content -- everything under the fixed @@ -89,18 +85,16 @@ impl HasAndroidUiState for TranscriptClient { } } -/// Builds one `UreqTransport` from the config `build.rs` baked in. Called -/// twice per session load, same as `desktop-app`'s `build_transport` -/// closure -- `ApiClient` and the live-stream follow each need their own, -/// since `UreqTransport` holds its own `ureq::Agent`. +/// Builds one `UreqTransport` from the stored enrollment. Called twice per +/// session load, same as `desktop-app`'s `build_transport` closure -- +/// `ApiClient` and the live-stream follow each need their own, since +/// `UreqTransport` holds its own `ureq::Agent`. +/// +/// Read afresh each time rather than held: opening a new enrolment link +/// while the app is running is how somebody points it at another server, +/// and a cached transport would keep talking to the old one. fn build_transport() -> Result { - let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT); - UreqTransport::new( - base_url, - pinned::TOKEN.to_string(), - pinned::CA_PEM.as_bytes(), - ) - .map_err(|e| e.to_string()) + crate::enrollment::transport() } fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget {