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:
1 parent
22210a42f5
commit
d8562d96a3
9 files changed
+340
-105
No files matched your search
+16
-1
@@ -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
|
||||||
|
|||||||
Generated
+1
@@ -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
@@ -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}")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user