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 {