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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 16:45:51 -04:00
1 parent 22210a42f5
commit d8562d96a3
9 files changed
+340 -105

No files matched your search

+16 -1
View File
@@ -404,12 +404,27 @@ done
# Percent-encoded because the app URL-decodes the deep link's query: a # 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. # 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") 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 <<INFO cat <<INFO
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB) sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
enrol the emulator (once; it survives sandbox restarts): enrol the emulator (once; it survives sandbox restarts):
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'" adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc&ca=$ca'"
drive it: drive it:
./ui-sandbox.sh spawn [title] an echo session; prints its id ./ui-sandbox.sh spawn [title] an echo session; prints its id
+1
View File
@@ -744,6 +744,7 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
name = "client-core" name = "client-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"event-model", "event-model",
"log", "log",
"pulldown-cmark", "pulldown-cmark",
@@ -24,6 +24,21 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- The enrollment link Dev Updater's Enroll button opens
(what `ai-server` mints), the same one the Compose app
in `app/` registers: which app answers it is the phone
owner's choice at the moment of the tap, and both being
offered is the intended behaviour rather than a clash.
BROWSABLE so a link tapped in another app reaches here,
and `android:host` so this app is not offered for every
aiapp:// URI a future route invents. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiapp" android:host="enroll" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="main" /> <meta-data android:name="android.app.lib_name" android:value="main" />
</activity> </activity>
</application> </application>
@@ -1,6 +1,8 @@
package dev.iris.android.demo; package dev.iris.android.demo;
import android.app.Activity; import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.view.WindowInsets; import android.view.WindowInsets;
@@ -20,9 +22,28 @@ public final class MainActivity extends Activity {
System.loadLibrary("main"); 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 @Override
public void onCreate(Bundle state) { public void onCreate(Bundle state) {
super.onCreate(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); IrisView view = new IrisView(this);
view.setLayoutParams(new FrameLayout.LayoutParams( view.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); 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 /** Read one `WindowInsets` and hand it to the Rust side. The only
* place that reads these fields, so the static dispatch and the * place that reads these fields, so the static dispatch and the
* animation callback above cannot come to report different things. */ * animation callback above cannot come to report different things. */
+18 -79
View File
@@ -2,82 +2,29 @@
// Android integration) -- the plain tabs build (I2/I4) needs none of this // Android integration) -- the plain tabs build (I2/I4) needs none of this
// and stays untouched, same reasoning as the feature gate in Cargo.toml. // 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 // **Nothing about the server this app talks to is baked in any more.** It
// time, the same way `app/androidApp/build.gradle.kts`'s // used to be (`AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN` plus the machine's
// `GeneratePinnedCert` task bakes the CA for the Compose app -- see that // own CA), which made an APK good for exactly the emulator/server pair
// file's comment for why reading the machine's own certificate at build // that built it -- and useless for the case that matters, an APK
// time is the right trust boundary. This build additionally bakes the // cross-compiled in this VM and run against the server on the host. The
// host/port/token, which the Compose app does not: that app enrolls at // destination arrives at runtime instead, from an `aiapp://enroll` link
// runtime from a scanned QR/deep link, and a from-scratch enrollment UI // carrying the CA with it (`src/enrollment.rs`), the same way the Compose
// (Keystore-sealed token storage, a QR/link scanner) is real, separate // app and `desktop-app` are told.
// scope this integration does not need to build to answer RUST.md's //
// question -- there is nothing here yet resembling `ServerConfig.kt`. So // What is left here is the log upload's own destination, which is on its
// this is a **deliberate simplification for this rig only**: an APK built // way out for a different reason (Dev Updater is growing a runtime-log
// this way is good for exactly the emulator/server pair that built it, and // view of its own, 2026-09-07) and is left untouched for that change.
// 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; use std::path::PathBuf;
fn main() { fn main() {
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() { if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
return; return;
} }
// Where this build sends its own log ring, if anywhere. Emitted for // Where this build sends its own log ring, if anywhere -- the one
// every build that has `client-core` (so the `bench` build, which // thing left that a build is told rather than enrolled with. A build
// returns below, gets it too): the log route is the one thing a bench // told none of it still keeps its ring and still shows it in `Copy
// APK on a phone with no `logcat` needs a server for even though it // report`; it just has nowhere to send 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(); 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 /// 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 /// 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 /// `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. Only the log upload pins this
/// configs -- the log destination and the transcript destination are the /// now; the screen's own server arrives with its CA at enrolment time.
/// 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 { fn read_pinned_ca() -> String {
let ca_path = std::env::var_os("AI_APP_CA") let ca_path = std::env::var_os("AI_APP_CA")
.map(PathBuf::from) .map(PathBuf::from)
@@ -165,9 +110,3 @@ fn read_pinned_ca() -> String {
); );
ca_pem 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}")
})
}
+6 -1
View File
@@ -559,8 +559,13 @@ impl BenchClient {
// composer up" cannot be told from "the listener never fired" // composer up" cannot be told from "the listener never fired"
// without it (`AndroidUiState::insets_report`). // without it (`AndroidUiState::insets_report`).
format!( format!(
"{renderer}\n{}\n{}", "{renderer}\n{}\n{}\n{}",
self.android_state().insets_report(), 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()) crate::app_log::diagnostics_line(self.log_upload.as_ref())
) )
} }
+133
View File
@@ -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<PathBuf> = 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<EnrollmentStore> {
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<EnrolledServer, String> {
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<UreqTransport, String> {
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)
}
+83
View File
@@ -40,6 +40,7 @@ use android_view::{
Context, View, Context, View,
jni::{ jni::{
JNIEnv, JavaVM, JNIEnv, JavaVM,
objects::{JClass, JString},
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong}, sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
}, },
register_view_class, register_view_class,
@@ -60,6 +61,11 @@ mod app_log;
mod bench_client; mod bench_client;
#[cfg(feature = "bench")] #[cfg(feature = "bench")]
mod bench_jni; 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")))] #[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
mod transcript_client; 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); iris::android::register_native_methods(&mut env, VIEW_CLASS);
JNI_VERSION_1_6 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<String> {
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
}
}
}
+18 -24
View File
@@ -7,15 +7,15 @@
//! //!
//! **Deliberate simplification, recorded rather than left to be //! **Deliberate simplification, recorded rather than left to be
//! rediscovered (RUST.md's I5 box has the full account)**: there is no //! 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 //! session list here -- the first session `ApiClient::fetch_sessions`
//! pinned CA are baked in at build time (`build.rs`'s //! returns is opened automatically, since there is nothing to tap to get
//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first //! there, which is what `transcript-bench.sh` and `ui-trace` need to land
//! session `ApiClient::fetch_sessions` returns is opened automatically -- //! straight on the screen under test.
//! 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 //! Which server it opens it against is no longer baked in: it is the
//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's //! enrollment an `aiapp://enroll` link left behind (`crate::enrollment`,
//! Keystore-sealed `ServerConfig.kt`; building a second one of those was //! and `desktop-app`'s identical `--link`), because an APK
//! not this pass's job. //! cross-compiled here cannot pin the CA of a server on the host.
//! //!
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** -- //! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from //! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
@@ -46,10 +46,6 @@ use iris::prelude::*;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
mod pinned {
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
}
pub struct TranscriptClient { pub struct TranscriptClient {
ui_state: AndroidUiState, ui_state: AndroidUiState,
/// The screen's own content -- everything under the fixed /// 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 /// Builds one `UreqTransport` from the stored enrollment. Called twice per
/// twice per session load, same as `desktop-app`'s `build_transport` /// session load, same as `desktop-app`'s `build_transport` closure --
/// closure -- `ApiClient` and the live-stream follow each need their own, /// `ApiClient` and the live-stream follow each need their own, since
/// since `UreqTransport` holds its own `ureq::Agent`. /// `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<UreqTransport, String> { fn build_transport() -> Result<UreqTransport, String> {
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT); crate::enrollment::transport()
UreqTransport::new(
base_url,
pinned::TOKEN.to_string(),
pinned::CA_PEM.as_bytes(),
)
.map_err(|e| e.to_string())
} }
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget { fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {