Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4274b8b8d0 | ||
|
|
73f956f8e0 | ||
|
|
038f6a3832 | ||
|
|
1121d7cc83 | ||
|
|
232de0ec53 | ||
|
|
e430880cde | ||
|
|
a999bd106a | ||
|
|
6840edf61e | ||
|
|
333220196e | ||
|
|
7f4ea7e8fd | ||
|
|
591128eef1 | ||
|
|
ba0f2ea93f | ||
|
|
ed04d4c735 | ||
|
|
ba2afbaedb | ||
|
|
10267dec27 | ||
|
|
7e7cbb5402 | ||
|
|
a200ddbddd | ||
|
|
b332873894 | ||
|
|
a4809b3026 | ||
|
|
1ad2f9ec6e | ||
|
|
33e8ab83a2 | ||
|
|
f5b88932b4 | ||
|
|
9079276ec8 | ||
|
|
3cb18ac5c2 | ||
|
|
69525bd131 | ||
|
|
64f64b54e5 | ||
|
|
20303e0b4c | ||
|
|
6973a89815 | ||
|
|
c3cfc67bb3 | ||
|
|
155d899e55 | ||
|
|
e63e923d44 | ||
|
|
a56a928b0c | ||
|
|
0449a324ef | ||
|
|
e1030d69f6 | ||
|
|
167862ca1b | ||
|
|
d73db97629 | ||
|
|
fb6b459c2c | ||
|
|
76b1f99277 | ||
|
|
3e72a4ef19 | ||
|
|
c02152a4f4 | ||
|
|
d9872989fa | ||
|
|
2fed8b34b3 | ||
|
|
1f379e8384 | ||
|
|
bf3479f5c4 | ||
|
|
312455956d | ||
|
|
73251d6b8b | ||
|
|
2e00e71552 | ||
|
|
f802de94b5 | ||
|
|
9717d1c4b0 | ||
|
|
9458f443ad | ||
|
|
e12c708246 | ||
|
|
543f6d92f0 | ||
|
|
27ca5b2349 | ||
|
|
20b12255e1 | ||
|
|
71a3fae655 | ||
|
|
c3984da623 | ||
|
|
2e3f4ada38 | ||
|
|
03c6be80a3 | ||
|
|
4afc453faa | ||
|
|
1aab61bf26 | ||
|
|
dc01f88d75 | ||
|
|
c589a75fa0 | ||
|
|
4b62cc642e | ||
|
|
80c2eadec9 | ||
|
|
0b587629e6 | ||
|
|
3163256d2c | ||
|
|
6102e0d4d9 | ||
|
|
f0da383e28 | ||
|
|
2d3695a1d3 | ||
|
|
f06ee259b4 | ||
|
|
560a74caf8 | ||
|
|
fd7e17523d | ||
|
|
c7682297fa | ||
|
|
27511302f2 | ||
|
|
184a6c5b33 | ||
|
|
5b2ca039f1 | ||
|
|
a8d24553d5 | ||
|
|
3b80a88f3b | ||
|
|
d8e6bc6e9b | ||
|
|
b887a96765 | ||
|
|
2aaa3733c3 | ||
|
|
46246ea511 | ||
|
|
a27fbdb029 | ||
|
|
c07d544aeb | ||
|
|
46d3a6fd41 | ||
|
|
5655fa8093 | ||
|
|
b3b1d47dd6 | ||
|
|
50fe4828a2 | ||
|
|
800da46188 | ||
|
|
00767eed4d | ||
|
|
683db4908a | ||
|
|
8d23a20792 | ||
|
|
d01c105037 | ||
|
|
88631f5e8b | ||
|
|
e6924298bc | ||
|
|
68b48cfd14 | ||
|
|
e6c884a0cd | ||
|
|
a6cb9a9082 | ||
|
|
0be6a571c4 | ||
|
|
bfe93c4188 | ||
|
|
5b7dc0e4e2 | ||
|
|
621f08d725 | ||
|
|
e49d0e606f | ||
|
|
e2a1fadbec | ||
|
|
0e4629361b | ||
|
|
1e7b1cddb7 | ||
|
|
470f8e5019 | ||
|
|
7ae53ad797 | ||
|
|
d17040b601 | ||
|
|
bf5087a598 | ||
|
|
aa3d11471f | ||
|
|
78aff64844 | ||
|
|
9a33cb5384 | ||
|
|
45ced405f3 | ||
|
|
62199aa3a7 | ||
|
|
b133d85943 | ||
|
|
ba6817fee5 | ||
|
|
73ee63bc1b | ||
|
|
8f0aec449a | ||
|
|
6d5fd64bb0 | ||
|
|
e5880c33f4 | ||
|
|
a853eb5a4d | ||
|
|
22d5c6585a | ||
|
|
b063fbd7f9 | ||
|
|
3f25e7ebca | ||
|
|
0af4c88d08 | ||
|
|
ceabd00805 | ||
|
|
32a5256a0d | ||
|
|
4cfe0ef6e6 | ||
|
|
c9b273ff16 | ||
|
|
8adda94a7a | ||
|
|
3a9208f38b | ||
|
|
e898370bf4 | ||
|
|
6e0bd06e4d | ||
|
|
03da47e550 | ||
|
|
a2cd119985 | ||
|
|
19c36e37f2 | ||
|
|
e2873df92e | ||
|
|
ea13889a21 | ||
|
|
6317685d1a | ||
|
|
f79bd7ca71 | ||
|
|
9c935f8ce8 | ||
|
|
982449293d | ||
|
|
288853c094 | ||
|
|
fba572427d | ||
|
|
85ec5416b6 | ||
|
|
643daf5637 | ||
|
|
8db0184384 | ||
|
|
1a6599e1b2 | ||
|
|
0a2f4fa1fe | ||
|
|
237886c11e | ||
|
|
e8dbcaa7db | ||
|
|
26163b25b2 | ||
|
|
762c1290a1 | ||
|
|
62dd6b7912 | ||
|
|
bc3db183e3 | ||
|
|
e0a473e090 | ||
|
|
1c937e2f48 | ||
|
|
d194d73439 | ||
|
|
4400966928 | ||
|
|
6e49ce8c92 | ||
|
|
79b9cd789a | ||
|
|
c70a670356 | ||
|
|
43743ba171 | ||
|
|
1a97d0ef5c | ||
|
|
ff7e9c0435 | ||
|
|
68a7f41ed0 | ||
|
|
9b331a5e93 | ||
|
|
3fc224b584 | ||
|
|
10500ae8aa | ||
|
|
8d441d3d59 | ||
|
|
e0ee7d6e94 | ||
|
|
b6b0928087 | ||
|
|
12221ea025 | ||
|
|
5e23c8b0c0 | ||
|
|
caaa733caa | ||
|
|
4ab26f068e | ||
|
|
0f8ba49f4a |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||
# every crate in this repo into one workspace -- they are deliberately
|
||||
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
||||
# from the repo root runs xtask/src/main.rs directly.
|
||||
[alias]
|
||||
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
||||
@@ -55,4 +55,21 @@ components: [
|
||||
// the terminal the QR would be printed on.
|
||||
enroll: "server/enroll-link.sh",
|
||||
),
|
||||
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
|
||||
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
|
||||
// with the same release key as "app" above so the two can install
|
||||
// over each other -- a separate component, not a mode of "app" above,
|
||||
// because it is a different applicationId (com.example.aiapp.shell)
|
||||
// built by a different tool from different sources. No `cwd`: it
|
||||
// defaults to this checkout's root, which both the `cargo xtask`
|
||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
||||
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
||||
// both need.
|
||||
Apk(
|
||||
name: "shell",
|
||||
modes: ["release", "debug"],
|
||||
build: "cargo xtask apk",
|
||||
),
|
||||
],
|
||||
@@ -1,12 +1,20 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/androidApp/build/
|
||||
app/shellApp/build/
|
||||
local.properties
|
||||
.kotlin/
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
server/target/
|
||||
event-model/target/
|
||||
client-core/target/
|
||||
android-shell/target/
|
||||
|
||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
||||
app/shellApp/src/main/jniLibs/
|
||||
|
||||
# Server logs from a development run (ai-server.log by convention,
|
||||
# wg-test.log from ./test-wg-tunnel.sh).
|
||||
@@ -21,3 +29,14 @@ certs/
|
||||
config.ron
|
||||
config.json
|
||||
sessions/
|
||||
|
||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||
iris/target/
|
||||
iris/android-app/target/
|
||||
|
||||
# E5's packaging xtask (RUST.md). `build/` above already covers
|
||||
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
||||
# The repo root has no Cargo workspace, so this is xtask's own
|
||||
# intermediate working files (target/xtask/apk/...), not a shared one.
|
||||
xtask/target/
|
||||
/target/
|
||||
@@ -5,48 +5,45 @@ replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||
between them.
|
||||
|
||||
**`PLAN.md` is the design source of truth** — every decision with its date,
|
||||
its rationale, and what was rejected. Read it before changing anything
|
||||
**`docs/PLAN.md` is the design source of truth** — every decision with its
|
||||
date, its rationale, and what was rejected. Read it before changing anything
|
||||
structural, and update it in place when a decision changes rather than
|
||||
letting this file and the plan become two versions of the truth. This file is
|
||||
the working notes layer: layout, commands, and things that have bitten.
|
||||
|
||||
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
|
||||
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
|
||||
ssh here, how importing behaves, and the measurements not worth re-taking.
|
||||
They moved there on 2026-09-04 because they are 12 KB that only matter once
|
||||
you are actually running one, and this file is sent with every request. Read
|
||||
it before writing or running a benchmark, driving the UI from a script, or
|
||||
touching the import screen.
|
||||
the working notes layer: layout, commands, rigs, and things that have bitten.
|
||||
The design and working documents live under `docs/` — everything except this
|
||||
file and `CLAUDE.md`, which stay at the root because that is where Claude
|
||||
Code and other agent harnesses look for them.
|
||||
|
||||
The central design point, worth not undoing by accident: **a session is a
|
||||
child process, translated into one common event model.** A new session type
|
||||
is a new driver — never a session-type branch in shared code (routes,
|
||||
transcript, app screens).
|
||||
|
||||
The second one, for the Rust port on the `rustify` branch: **the phone app
|
||||
and a planned desktop app share almost all of their code.** Screens, widgets,
|
||||
folding, paging, config and the network client live in the shared crates
|
||||
(`iris`, `client-core`, `transcript-ui`, `tabs-ui`); `android-app` and
|
||||
`desktop-app` are thin entry points that own only what the platform forces
|
||||
(JNI and the IME on one side, winit and argv on the other). The two
|
||||
*layouts* will differ, to suit a phone's screen and a finger against a
|
||||
desktop's screen and a mouse -- but the widgets a layout is made of (a
|
||||
button, a text field, a list, a card) and the styling (colours, spacing,
|
||||
type) are one implementation with no per-platform copy. Anything that could
|
||||
work on both goes in a shared crate the first time it is written, and a
|
||||
platform crate growing a widget or a colour is a defect to move, not a
|
||||
convenience to keep. Iris said this on 2026-09-07; docs/RUST.md carries the
|
||||
details.
|
||||
|
||||
## Layout
|
||||
|
||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
|
||||
`:androidApp` module), same cert scheme, same registry pattern. Read
|
||||
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
||||
Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
||||
|
||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||
comment is the HTTP table and the surface's source of truth.
|
||||
**A llama.cpp session runs on whatever machine its setup names** (built
|
||||
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
|
||||
port the server binds *there* and the port that reaches it *here*, and
|
||||
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
||||
the command. Three things fell out of it and are easy to get wrong again —
|
||||
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
|
||||
because `llama-server` never reads the stdin whose closing ends a CLI and
|
||||
the same kill left it loaded on the far machine; the model is looked for on
|
||||
the machine that will serve it, so the spawn screen offers
|
||||
`GET /setups/{id}/models` rather than the backend's own downloads; and the
|
||||
readiness poll watches the process as well as the port, since a model that
|
||||
will not load exits in a second and was being reported as "gave up after
|
||||
300s". See PLAN.md's "Transport" and "llama-server management".
|
||||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||
@@ -61,16 +58,23 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
projects version-locked to the commit this repo pins. What deliberately did
|
||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||
sessions and setups are what makes this project itself.
|
||||
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
||||
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
||||
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
||||
choices made there that are still awaiting review.
|
||||
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
||||
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
||||
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
||||
and stream effects in `SessionScreen.kt`.
|
||||
- `TODO.md` — the working list.
|
||||
- `docs/` — every design and working document except this file and
|
||||
`CLAUDE.md`:
|
||||
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
|
||||
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
||||
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
|
||||
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||||
the opening and stream effects in `SessionScreen.kt`.
|
||||
- `docs/TODO.md` — the working list.
|
||||
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
||||
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||||
framework decision, and the ordered experiments with their pass
|
||||
conditions. Read it before touching anything under that branch.
|
||||
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
|
||||
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
|
||||
own public API log, working list, decisions log, layout/render design,
|
||||
and texture-atlas design, and the client-core crate's design,
|
||||
respectively.
|
||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||
rather than a script kept here) and the APK, in parallel. It points at
|
||||
@@ -107,7 +111,11 @@ two icon buttons the same width without either being given one — and why
|
||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
||||
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
||||
pure logic with no Android in it.
|
||||
pure logic with no Android in it. Touching anything under `BenchFixture.kt`,
|
||||
`BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs
|
||||
`:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build
|
||||
type compiles separately and lint has caught real bugs debug alone never
|
||||
would (see "Android Lint" below).
|
||||
- **Android Lint is not optional and is not run by a build.** It found a
|
||||
crash that had been shipping (`java.time` on a minSdk-24 app with
|
||||
desugaring off) and later a permission check that silently dropped every
|
||||
@@ -163,6 +171,195 @@ two icon buttons the same width without either being given one — and why
|
||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||
involved. That is how to verify the wg0-only posture.
|
||||
|
||||
## The rigs
|
||||
|
||||
Each exists because something was invisible without it.
|
||||
|
||||
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
||||
and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris
|
||||
asked for before porting continues: a deterministic, checked-in synthetic
|
||||
transcript (`app/bench-fixture/generate.py`, never a real one) that both
|
||||
this app and iris open with no server, so a frame-time comparison
|
||||
measures the renderer rather than the data. `./build-apk.sh bench` builds
|
||||
it — own application id (`com.example.aiapp.bench`) and label ("AI
|
||||
Sessions bench") so it installs beside a real enrollment rather than
|
||||
replacing it. Opening it goes straight to a session screen holding the
|
||||
fixture (no enrollment, no permission prompts) with a "Run benchmark"
|
||||
control beside "Copy" in session settings: it drives the same scroll loop
|
||||
and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over
|
||||
`ui-trace`, but in-process, since a real phone has no usable system
|
||||
tracing and no agent can drive one (this-machine-android's skill).
|
||||
`BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a
|
||||
`URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s
|
||||
requests from an in-memory copy of the fixture instead of opening a
|
||||
socket — so the fold, the paging and `uniqueItems` under test are the
|
||||
screen's real ones, never a shortcut built just for this. The report
|
||||
gains a `bench:` section (process CPU time, peak RSS, battery current) on
|
||||
every build, empty except when `BenchRun.kt` filled it in.
|
||||
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
|
||||
and data directory, holding eight invented Claude Code transcripts and a
|
||||
`claude` that is two lines of shell. **That isolation is the point**: the
|
||||
import screen lists whatever is in `~/.claude/projects`, which in this VM is
|
||||
real agent transcripts, so exercising *delete* against the ordinary server
|
||||
deletes somebody's conversation and exercising *import* starts a real
|
||||
`--resume` on the owner's account.
|
||||
Its port and root derive from the checkout's name, so two checkouts'
|
||||
sandboxes cannot reach each other, and its token is generated once into
|
||||
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
|
||||
the enrolment flow appended — so the emulator app is enrolled **once** (the
|
||||
start banner prints the command) and stays enrolled. It shares the real TLS
|
||||
certificates, because the installed APK pins that CA.
|
||||
Driving verbs, so none of this is re-derived per session:
|
||||
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||||
`./ui-sandbox.sh send SID text|@file`, and
|
||||
`./ui-sandbox.sh api /path [curl args]`.
|
||||
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
||||
enrolment already there — for when the fixture under test was expensive to
|
||||
build; plain `start` wipes them, which is right for the list-screen
|
||||
fixtures and wrong for that.
|
||||
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
|
||||
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
|
||||
fake CLI slow to start. Both exist because operations that finish in
|
||||
milliseconds have states on the way that nothing can observe, and an
|
||||
unobservable state is one where broken and working look identical.
|
||||
It also builds a fixture tree at the sandbox home's `~/files` for the
|
||||
explorer, holding the states otherwise only reachable by finding a real
|
||||
machine in one: an empty directory, a name with a tab and one with an
|
||||
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
|
||||
symlink to a directory and a broken one, a source file per language, and
|
||||
the three sizes the limits were measured against (`edit-32k.rs`,
|
||||
`edit-128k.rs`, `big-source.rs`). Point a session at it with
|
||||
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||||
The explorer's 409 is produced by editing the file on the machine
|
||||
(`printf … > file`) between pressing the pencil and pressing save.
|
||||
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
|
||||
echo driver is the right rig for most things and the wrong one for anything
|
||||
whose cost scales with what was actually written: a real reply is longer,
|
||||
is real markdown, and carries tool calls whose input and output are
|
||||
kilobytes. Two faults were invisible until a real transcript was loaded — a
|
||||
page of history landing mid-fling threw the reader back to the newest end,
|
||||
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
|
||||
`-b` takes the biggest conversation on the machine rather than the newest,
|
||||
which is what a scrolling test wants; `--stop` takes it down.
|
||||
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
||||
own, so the import can only see the copy — importing spawns `claude
|
||||
--resume`, and against the real file that is a second CLI writing to a
|
||||
conversation somebody may still be in. **A transcript never goes in this
|
||||
repository**: they hold whatever was said, read and written in that
|
||||
session, and `~/repos` is shared with the host besides.
|
||||
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||||
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||||
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
||||
it holds the fifo open, records a real pid, writes nothing, and dies on a
|
||||
signal. So adopt, stop, restart and start are all drivable without a real
|
||||
`--resume` and without spending a turn on somebody's account. Reach for
|
||||
this when what is under test is *whether a process is running*, and for
|
||||
`debug-transcript.sh` when it is *what the transcript draws*.
|
||||
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||||
the first session (or `-k` keeps the current screen), scrolls a fixed
|
||||
gesture loop, and prints the app's render report — the same one the in-app
|
||||
copy button produces, whose `on screen:` line names what the viewport was
|
||||
holding. Compare two runs with the same gestures; the emulator's absolute
|
||||
frame times transfer nothing, the report's accounting does. Run it either
|
||||
side of any change under `Markdown*.kt`, `Transcript*.kt` or
|
||||
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
|
||||
that move first are the worst `record: one block`, the reparse mean while
|
||||
streaming, and the draw phase's accounting line.
|
||||
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
|
||||
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
|
||||
resets the report, sends FILE, waits for the transcript to stop growing,
|
||||
and prints. Both of those are corrections to a first version that measured
|
||||
nothing: a transcript parked further back never redraws while a reply
|
||||
streams into it, and a session is idle at *both* ends of a turn, so polling
|
||||
for idle answers before the turn has started.
|
||||
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
||||
framework, from `atrace` text output with no trace processor needed. It is
|
||||
how the cost of a layout node per link was attributed to the framework
|
||||
rather than guessed at.
|
||||
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
|
||||
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
|
||||
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
|
||||
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
|
||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
||||
report -- written so the P0 build/install/tap/read-report cycle stops
|
||||
being retyped by hand each time (docs/RUST.md's P0 box).
|
||||
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
|
||||
the commands and what each cannot answer): test at the cheapest one
|
||||
that can answer the question. `cargo test -p transcript-fixture` runs
|
||||
the real transcript screen over the bench fixture with **no window, no
|
||||
compositor and no GPU** (`iris::harness`), on a clock the test owns and
|
||||
a gesture replayed from a `t_ms action x y` file under
|
||||
`iris/transcript-fixture/touch/` -- which is how the batched 120Hz
|
||||
flick a finger actually makes is testable at all, since a `ui-trace`
|
||||
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
|
||||
--shot …` opens the same screen in a window at the phone's own size and
|
||||
density for looking at, and `--replay FILE` drives the same recording
|
||||
into it. The emulator is for JNI, the IME, insets, the surface
|
||||
lifecycle and one verification run before a build goes to the phone --
|
||||
not for iterating on layout.
|
||||
|
||||
### Driving the UI
|
||||
|
||||
**No script that drives this app's UI presses a coordinate.** Every control
|
||||
is found by the name it already carries for assistive technology —
|
||||
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
|
||||
against the screen at the moment of the gesture and fails the whole run when
|
||||
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
|
||||
coordinate is a position measured once by hand, and anything that moves the
|
||||
control makes the tap land on whatever now sits there — the bench then
|
||||
reports a number that was never measured, which reads exactly like a result.
|
||||
Both bench scripts pressed the render report at `tap 723 205` until that
|
||||
button moved into the session settings dialog on 2026-09-03. The check that
|
||||
none has crept back:
|
||||
|
||||
grep -n "tap [0-9]" app/*.sh
|
||||
|
||||
Swipes are still coordinates, deliberately: a gesture across a scrolling area
|
||||
is a distance rather than a control.
|
||||
|
||||
**Two traps in the emulator bench loop**, each of which cost a run.
|
||||
`adb shell pm clear` removes the enrolment and the notification permission
|
||||
along with the saved anchors, so the next run measures a permission dialog —
|
||||
re-enrol with the command `ui-sandbox.sh` prints, and
|
||||
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
|
||||
so the only way two builds start a scroll from the same place is a *fresh
|
||||
session for each*.
|
||||
|
||||
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
|
||||
`emu up` creates and boots the AVD named after this checkout — whatever `emu
|
||||
name` prints, never a name typed out here, since this file is the same in
|
||||
every clone. `run-android.sh` is that plus a build and an install. The `adb`
|
||||
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
|
||||
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
|
||||
script from `emulator-tools` runs `emu check` before `installDebug`,
|
||||
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
|
||||
to every attached device; when it refuses, say which device you mean at the
|
||||
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
|
||||
|
||||
### Testing llama.cpp and ssh here
|
||||
|
||||
The prebuilt CPU llama.cpp lives outside the repo at
|
||||
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
|
||||
own directory on `LD_LIBRARY_PATH`, so start the server as
|
||||
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
|
||||
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
|
||||
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
||||
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||||
broken driver — `llama-cli` produces the same from the file directly, which
|
||||
is how to tell the two apart in a hurry.
|
||||
|
||||
There is no second machine, so **ssh this VM to itself**: generate a
|
||||
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
|
||||
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
|
||||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
|
||||
nothing real. Point a provider's `command` at something harmless like
|
||||
`/bin/echo` rather than at `claude`: the transport is what is under test, the
|
||||
process exiting immediately is the signal, and it costs no tokens. **Take the
|
||||
key back out afterwards.** The remote login shell here is **fish**; the
|
||||
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
|
||||
both, but that is luck rather than design, and a shell that is neither is the
|
||||
thing to suspect first if a remote spawn ever mangles an argument.
|
||||
|
||||
## Where things run (host vs this VM)
|
||||
|
||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
||||
@@ -186,7 +383,7 @@ means here:
|
||||
## Sessions outlive the backend
|
||||
|
||||
Since 2026-08-29 a session's process is deliberately left running when
|
||||
`ai-server` stops, and adopted again when it starts. PLAN.md has the design;
|
||||
`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design;
|
||||
day to day:
|
||||
|
||||
- **Stopping the server no longer stops the sessions.** After `pkill
|
||||
@@ -209,24 +406,42 @@ day to day:
|
||||
in `process.json`; removing either by hand while the session is live loses
|
||||
output or replays it.
|
||||
|
||||
## Auto-resume
|
||||
## Importing
|
||||
|
||||
**A session switched to it sends itself a message once the account's usage
|
||||
limit lifts** — off by default, per session, in the session settings dialog.
|
||||
PLAN.md's "Auto-resume" is the design; day to day:
|
||||
The import list reports each session's **size as well as its line count**,
|
||||
because the two disagree in the way that matters: these transcripts embed
|
||||
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
|
||||
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
|
||||
count tells you what continuing a session will cost. Shown, not warned about;
|
||||
importing a large session is a choice somebody is entitled to make.
|
||||
|
||||
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
|
||||
asks `GET /usage`'s meter for that machine and provider, and only sends when
|
||||
it answers `ok` with nothing at 100%. Anything else — still spent, logged
|
||||
out, unreachable — is a longer wait, and a still-spent window reschedules to
|
||||
the reset time the *meter* now gives.
|
||||
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
|
||||
the same `limitReached` event a real driver does, and `/usage 100 5` sets
|
||||
what the meter answers. They are deliberately separate: the two disagreeing
|
||||
is the case the design exists for. `/usage 20` is the limit lifting.
|
||||
- The wait is on the session in `config.ron` (`resume`), so it survives a
|
||||
backend restart. A day after the limit was hit it gives up and says so in
|
||||
the transcript.
|
||||
**Never import a Claude Code session that is open in a terminal.** The app
|
||||
refuses it — see docs/PLAN.md for the incident that made that a refusal rather
|
||||
than a warning.
|
||||
|
||||
**One Claude Code session id can name two files, and the listing offers it
|
||||
once.** Resuming from a different working directory makes the CLI write a
|
||||
second transcript with the same id under that directory's project folder — an
|
||||
ordinary state of a machine, not corruption. Everything downstream addresses
|
||||
a session by id, and the phone keyed its list on it, so two rows sharing one
|
||||
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
|
||||
the copy with the most lines, because the other is usually a few-hundred-byte
|
||||
stub and is often the *newer* of the two, so recency is the wrong key.
|
||||
Deleting removes every copy rather than the first, or the row came back after
|
||||
a delete that reported success. The phone's half is `uniqueItems`, which
|
||||
every list keyed on a server-chosen id goes through: a repeat there must
|
||||
never be able to close the app, whatever produced it.
|
||||
|
||||
**Deleting a session offers to take the machine's own transcript with it** —
|
||||
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||||
confirmation, and only where the driver keeps a record of its own
|
||||
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
||||
because leaving that copy is what makes an ordinary delete recoverable — and
|
||||
the dialog's paragraph is rewritten when it is on rather than appended to,
|
||||
since the sentence promising the conversation "should still be there to
|
||||
import again" is exactly the one the switch makes false. The server deletes
|
||||
the machine's copy *first*, so a machine it cannot reach leaves the session
|
||||
where it was instead of half-deleted.
|
||||
|
||||
## Shared appearance
|
||||
|
||||
@@ -239,17 +454,6 @@ PLAN.md's "Auto-resume" is the design; day to day:
|
||||
swallowed the drag along with the tap, so a list could not be scrolled
|
||||
while anything in it was busy.
|
||||
|
||||
- **A rate-limit bar belongs to a session's provider, not to its machine.**
|
||||
One machine offers echo, the Claude CLI and a local model at once and only
|
||||
the CLI spends anything, so a session says which meter reports on it
|
||||
(`usageProvider`, from `DriverKind::usage_provider`, which
|
||||
`usage::providers_for` reads too so the two lists cannot disagree) and the
|
||||
phone matches a snapshot on machine *and* provider. Nothing meters a llama
|
||||
or echo session, and the phone draws **nothing** for one — not a zero, and
|
||||
not "unknown". Nothing while the first fetch is out either: "checking"
|
||||
under a session that turns out to meter nothing is a row the screen then
|
||||
has to withdraw.
|
||||
|
||||
## Things that have bitten
|
||||
|
||||
Project-specific only — a lesson that would bite any project on this machine
|
||||
@@ -355,3 +559,37 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
||||
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
||||
that wraps the *fetch* and leaves the work done with the result outside it.
|
||||
|
||||
## Measurements worth not re-taking
|
||||
|
||||
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
|
||||
emulator against a real imported transcript with the server at
|
||||
`--delay 120`. Settled and flinging fast, both into fresh history and back
|
||||
through rows already drawn: **5.2–5.9% janky frames, 99th percentile
|
||||
29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same
|
||||
device is 3.3% and 38ms, so this is at the platform floor. The number that
|
||||
is *not* at the floor is the first few seconds after opening a session,
|
||||
where every row on the way is being composed for the first time; that is
|
||||
inherent to a lazy list and it is why a measurement taken before the screen
|
||||
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||||
- **The reset path is not reachable by reopening a session.** Measured
|
||||
2026-09-04 against a session streaming at 20 events a second: reopening one
|
||||
with an anchor 1,800 events back connects **87–119 events behind**, well
|
||||
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
|
||||
opening page, then one span covering the whole distance. To exercise the
|
||||
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
|
||||
the app takes the reset on a live connection, clears, refills and carries
|
||||
on without reconnecting.
|
||||
- **The session screen's stream survives backgrounding here** — 20 seconds at
|
||||
the launcher while 415 events were produced brought no reconnect at all,
|
||||
which is not what the comment above that loop expects, and is most likely
|
||||
this emulator being headless rather than the phone's behaviour.
|
||||
- **Reopening a cached session costs one request for one event** (the probe),
|
||||
and scrolling the whole conversation back costs nothing more; a cold open
|
||||
of the same 500-event session is two pages, 100 events. Measured
|
||||
2026-09-04 on the emulator against the sandbox.
|
||||
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
|
||||
28,000-line file because it draws one row per line; the editor is one
|
||||
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
|
||||
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
|
||||
screen. If you make the editor faster, that number is what to move.
|
||||
docs/EXPLORER.md's "What the measurements said" has the rest.
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "android-shell"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# The JNI bridge behind E3's two Java stub classes (`MainActivity`,
|
||||
# `NotificationService` -- see RUST.md's "How much Java is unavoidable" for
|
||||
# why those two classes cannot be anything but Java/Kotlin, registered from
|
||||
# the manifest by name). Everything they would otherwise have done in
|
||||
# Kotlin -- the SSE follow loop, deciding where a notification is shown,
|
||||
# picking a session for a share -- is here instead, built on `client-core`
|
||||
# so the networking and parsing are not duplicated a third time next to the
|
||||
# server and the Kotlin app.
|
||||
#
|
||||
# `cdylib` for `System.loadLibrary`; `lib` too so `cargo test`/`clippy` run
|
||||
# on a normal host target without an Android NDK toolchain, the same
|
||||
# posture `client-core` and `server` already have.
|
||||
|
||||
[lib]
|
||||
name = "android_shell"
|
||||
crate-type = ["cdylib", "lib"]
|
||||
|
||||
[dependencies]
|
||||
client-core = { path = "../client-core" }
|
||||
jni = "0.22"
|
||||
log = "0.4"
|
||||
|
||||
# `LogErrorAndDefault` (the `native_method!` error policy this crate uses
|
||||
# throughout, see lib.rs) logs through the `log` facade, which is a no-op
|
||||
# without a backend installed -- so without this, every recoverable error
|
||||
# at a native entry point would be silently dropped rather than reaching
|
||||
# logcat. Android-only: nothing else here needs it, and it does not build
|
||||
# off-device (see `notify::ensure_logger`'s call site, the only place this
|
||||
# is used).
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.15"
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
||||
//! (a class name, a method name and a signature, all as plain `&str`).
|
||||
//!
|
||||
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
||||
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
||||
//! here uniformly rather than switching to the compile-time `jni_str!`
|
||||
//! literal macro call by call -- these are a handful of short, one-off
|
||||
//! lookups, not a hot loop, so the difference is not worth two code paths
|
||||
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
||||
//! `FieldSignature`, which is why those go through
|
||||
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
||||
//! parsed form is what lets these calls skip re-validating the signature
|
||||
//! against the arguments on every call, which is the whole reason `jni`
|
||||
//! moved to it.
|
||||
//!
|
||||
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
||||
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
||||
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
||||
//! `call_method` on an object it already has, which needs no such lookup)
|
||||
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
||||
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
||||
//! That default is fine on a thread the JVM itself started -- an
|
||||
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
||||
//! from `android-shell`'s own background thread (the notification
|
||||
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
||||
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
||||
//! never gave an app classloader. Framework classes
|
||||
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
||||
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
||||
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
||||
//! packaged inside this app's own APK. The failure was
|
||||
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
||||
//! as "failed to resolve Java class ... (class not found or linkage
|
||||
//! error)" -- on a real device this reads as "the notification silently
|
||||
//! never arrives," since the whole call is inside the follow loop and the
|
||||
//! ongoing foreground notification (built on the main thread, in
|
||||
//! `try_start`, before the background thread exists) posts fine either
|
||||
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
||||
//! first time any entry point has a `Context` to ask, and every class
|
||||
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
||||
//! rather than the thread-dependent default -- so it is correct on the
|
||||
//! main thread and on this crate's own background threads alike.
|
||||
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
||||
use jni::refs::{Global, LoaderContext};
|
||||
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
||||
use jni::strings::JNIString;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
||||
|
||||
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
||||
/// Cheap to call from every entry point that has a `Context` on hand
|
||||
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
||||
/// a `OnceLock::get` and nothing else.
|
||||
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
if CLASS_LOADER.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
||||
// real objects throughout, so this needs no class-name lookup of its
|
||||
// own and has nothing to bootstrap.
|
||||
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
||||
let loader_obj = call_method(
|
||||
env,
|
||||
&class_obj,
|
||||
"getClassLoader",
|
||||
"()Ljava/lang/ClassLoader;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
||||
let global = env.new_global_ref(&loader)?;
|
||||
// Lost the race with another entry point calling this concurrently --
|
||||
// both loaders name the same app, so either one is fine and there is
|
||||
// nothing to reconcile.
|
||||
let _ = CLASS_LOADER.set(global);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
||||
/// through the cached app classloader when one has been remembered, and
|
||||
/// through the ordinary default otherwise -- which is every call made
|
||||
/// before any entry point has run, and is also correct for a main-thread
|
||||
/// caller, so there is no case this makes worse.
|
||||
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||
match CLASS_LOADER.get() {
|
||||
Some(loader) => {
|
||||
let binary_name = name.replace('/', ".");
|
||||
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
||||
}
|
||||
None => env.find_class(JNIString::new(name)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
||||
resolve_class(env, name)
|
||||
}
|
||||
|
||||
/// A new Java string as a plain `JObject` -- what every call site here
|
||||
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
||||
/// `JString: Into<JObject>` is the documented way across).
|
||||
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
||||
Ok(env.new_string(text)?.into())
|
||||
}
|
||||
|
||||
pub fn new_object<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JObject<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.new_object(class, sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn call_method<'local>(
|
||||
env: &mut Env<'local>,
|
||||
obj: &JObject,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn call_static_method<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeMethodSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
||||
}
|
||||
|
||||
pub fn get_static_field<'local>(
|
||||
env: &mut Env<'local>,
|
||||
class: &str,
|
||||
field: &str,
|
||||
sig: &str,
|
||||
) -> Result<JValueOwned<'local>> {
|
||||
let sig = RuntimeFieldSignature::from_str(sig)?;
|
||||
let class = resolve_class(env, class)?;
|
||||
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
|
||||
//! package comment for what this crate is and RUST.md's E3 entry for the
|
||||
//! design decisions.
|
||||
//!
|
||||
//! Each native method is declared with `jni`'s [`native_method!`] macro
|
||||
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
|
||||
//! the macro derives the mangled export name and the JNI signature from the
|
||||
//! Rust function itself, so the two cannot drift apart the way a
|
||||
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
|
||||
//! routinely do. `error_policy = LogErrorAndDefault` matches
|
||||
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
|
||||
//! JNI call that threw) is reported to logcat, not thrown back into Java
|
||||
//! as an exception that would crash the app over something recoverable.
|
||||
//!
|
||||
//! Each `const _: NativeMethod = native_method! { ... };` binding is
|
||||
//! otherwise unused by name -- `_` is the idiomatic way to keep a
|
||||
//! side-effecting const (here, generating the `#[export_name]`d function
|
||||
//! the JVM resolves by the JNI naming convention) without a `dead_code`
|
||||
//! warning for a binding nothing reads.
|
||||
|
||||
mod jcall;
|
||||
mod notify;
|
||||
mod settings;
|
||||
mod share;
|
||||
|
||||
use jni::errors::LogErrorAndDefault;
|
||||
use jni::objects::{JClass, JObject};
|
||||
use jni::sys::jint;
|
||||
use jni::{Env, NativeMethod, native_method};
|
||||
|
||||
/// Installs the `log` backend that routes to logcat, once per process.
|
||||
/// Without it, `LogErrorAndDefault` (every native method below) and any
|
||||
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
|
||||
/// call into the `log` facade's default no-op logger, and a real failure
|
||||
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
|
||||
/// crashing, since nothing on screen or in the log says a notification was
|
||||
/// dropped. Called from every entry point below rather than a Java-side
|
||||
/// `Application.onCreate`, since this crate deliberately has no such class
|
||||
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
|
||||
fn ensure_logger() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
#[cfg(target_os = "android")]
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Debug)
|
||||
.with_tag("android-shell"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The parameters are spelled as their Java types, not as `JObject`: the
|
||||
// macro encodes each argument into the exported symbol's JNI signature
|
||||
// (and JNI resolves `Java_...` names *by* that signature), so a generic
|
||||
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
|
||||
// method actually declared `(Landroid/app/Activity;...)` -- two different
|
||||
// symbols that never resolve to each other, silently, with no compiler
|
||||
// error on either side. `android.app.Activity` etc. have no dedicated
|
||||
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
|
||||
// implementation functions below (the "Built-in Types" note in
|
||||
// `native_method!`'s docs).
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.MainActivity",
|
||||
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
|
||||
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
|
||||
fn native_handle_intent<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
activity: JObject<'local>,
|
||||
intent: JObject<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &activity)?;
|
||||
share::handle_intent(env, &activity, &intent)
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_sync(context: android.content.Context) -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
|
||||
/// enrollment may have just landed) and from `NotificationService.sync`
|
||||
/// itself. See `notify::sync`.
|
||||
fn native_sync<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
context: JObject<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &context)?;
|
||||
notify::sync(env, &context)
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_on_start_command(service: android.app.Service) -> jint,
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
||||
fn native_on_start_command<'local>(
|
||||
env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
service: JObject<'local>,
|
||||
) -> Result<jint, jni::errors::Error> {
|
||||
ensure_logger();
|
||||
jcall::remember_class_loader(env, &service)?;
|
||||
Ok(notify::on_start_command(env, service))
|
||||
}
|
||||
|
||||
const _: NativeMethod = native_method! {
|
||||
java_type = "com.example.aiapp.shell.NotificationService",
|
||||
static extern fn native_on_destroy() -> (),
|
||||
error_policy = LogErrorAndDefault,
|
||||
};
|
||||
|
||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
||||
fn native_on_destroy<'local>(
|
||||
_env: &mut Env<'local>,
|
||||
_class: JClass<'local>,
|
||||
) -> Result<(), jni::errors::Error> {
|
||||
ensure_logger();
|
||||
notify::on_destroy();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
//! Where a notification is said, and the foreground service that keeps
|
||||
//! the connection open while the app is closed. Ported from
|
||||
//! `Notifications.kt`'s `NotificationService`, minus the "session on
|
||||
//! screen" / "hand to the app as a banner" branches: those read
|
||||
//! process-wide state that only exists because a screen is drawn to
|
||||
//! register against, and this experiment draws no screen yet (that is
|
||||
//! E4's job, on iris). So every notification here takes the third branch
|
||||
//! Kotlin's `show` already had -- the platform's own drawer -- which is
|
||||
//! also exactly the case E3's pass condition asks for: **a notification
|
||||
//! arrives with the app closed.**
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use client_core::api::UreqTransport;
|
||||
use client_core::notifications::{SessionNotification, follow_notifications};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JValue};
|
||||
use jni::sys::{JNI_TRUE, jint};
|
||||
|
||||
use crate::settings::{self, ServerSettings};
|
||||
|
||||
const ALERT_CHANNEL: &str = "sessions";
|
||||
const ONGOING_CHANNEL: &str = "connection";
|
||||
const ONGOING_ID: i32 = 1;
|
||||
const ALERT_ID: i32 = 2;
|
||||
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||
|
||||
/// Whether the follow-loop thread is already running. **A deviation from
|
||||
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
|
||||
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
|
||||
/// on *every* call, with nothing to notice a previous one is still going --
|
||||
/// and `sync()` calling `startForegroundService` when the service is
|
||||
/// already running is an ordinary Android start, not a restart, so
|
||||
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
|
||||
/// `sync` once itself, then again inside `handle_enrollment` after saving
|
||||
/// the token) hits exactly this path and was observed opening **two**
|
||||
/// concurrent connections to `/notifications` from one process -- caught
|
||||
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
|
||||
/// thread ai-app-notifications` lines for one enrollment. Guarded here
|
||||
/// rather than left to match Kotlin's behaviour exactly, since duplicating
|
||||
/// a live connection is a resource leak with no upside; worth carrying the
|
||||
/// same guard back to `Notifications.kt` separately.
|
||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Set by `nativeOnDestroy`, checked by the follow loop between
|
||||
/// reconnects. **Known gap, recorded rather than hidden**: unlike
|
||||
/// `HttpURLConnection.disconnect()` in the Kotlin original, nothing here
|
||||
/// can interrupt a `ureq` read already blocked inside one connection --
|
||||
/// `Transport::stream` hands back a plain `Read` with no cancellation
|
||||
/// handle. So a stop lands at the next reconnect, not mid-read. `/notifications`
|
||||
/// is idle between events (a keep-alive, per `server/src/routes.rs`), so in
|
||||
/// practice this is a bounded wait rather than a hang; closing that gap
|
||||
/// for real means adding a cancellation point to `client_core::Transport`,
|
||||
/// which is a decision affecting every caller of that trait, not just this
|
||||
/// one -- left for whoever next depends on prompt shutdown.
|
||||
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
||||
}
|
||||
|
||||
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"from",
|
||||
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
|
||||
&[JValue::Object(context)],
|
||||
)?
|
||||
.l()
|
||||
}
|
||||
|
||||
fn create_channel(
|
||||
env: &mut Env,
|
||||
manager: &JObject,
|
||||
id: &str,
|
||||
name: &str,
|
||||
importance: i32,
|
||||
) -> Result<()> {
|
||||
let id_j = crate::jcall::jstr_obj(env, id)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||
"(Ljava/lang/String;I)V",
|
||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||
)?;
|
||||
let name_j = crate::jcall::jstr_obj(env, name)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"setName",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||
&[JValue::Object(&name_j)],
|
||||
)?;
|
||||
let channel = crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"build",
|
||||
"()Landroidx/core/app/NotificationChannelCompat;",
|
||||
&[],
|
||||
)?
|
||||
.l()?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
manager,
|
||||
"createNotificationChannel",
|
||||
"(Landroidx/core/app/NotificationChannelCompat;)V",
|
||||
&[JValue::Object(&channel)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two channels, because they are two different things to be told -- see
|
||||
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
|
||||
/// importances here are copied from it exactly, since a phone that has
|
||||
/// seen both apps should not learn two different vocabularies for the
|
||||
/// same fact.
|
||||
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
let default = static_int(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"IMPORTANCE_DEFAULT",
|
||||
)?;
|
||||
let min = static_int(
|
||||
env,
|
||||
"androidx/core/app/NotificationManagerCompat",
|
||||
"IMPORTANCE_MIN",
|
||||
)?;
|
||||
create_channel(
|
||||
env,
|
||||
&manager,
|
||||
ALERT_CHANNEL,
|
||||
"Sessions needing attention",
|
||||
default,
|
||||
)?;
|
||||
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_intent_for<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
class_name: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let target_class = crate::jcall::find_class(env, class_name)?;
|
||||
crate::jcall::new_object(
|
||||
env,
|
||||
"android/content/Intent",
|
||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||
&[JValue::Object(context), JValue::Object(&target_class)],
|
||||
)
|
||||
}
|
||||
|
||||
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
|
||||
/// `sessionIntent`, including building the URI through `Uri.Builder`
|
||||
/// rather than string concatenation, for the same reason: an id needing
|
||||
/// escaping must survive the round trip.
|
||||
fn session_intent<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
session_id: &str,
|
||||
) -> Result<JObject<'l>> {
|
||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setAction",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&action_view)],
|
||||
)?;
|
||||
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"scheme",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&scheme)],
|
||||
)?;
|
||||
let authority = crate::jcall::jstr_obj(env, "session")?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"authority",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&authority)],
|
||||
)?;
|
||||
let path = crate::jcall::jstr_obj(env, session_id)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&builder,
|
||||
"appendPath",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||
&[JValue::Object(&path)],
|
||||
)?;
|
||||
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&intent,
|
||||
"setData",
|
||||
"(Landroid/net/Uri;)Landroid/content/Intent;",
|
||||
&[JValue::Object(&uri)],
|
||||
)?;
|
||||
Ok(intent)
|
||||
}
|
||||
|
||||
fn pending_activity<'l>(
|
||||
env: &mut Env<'l>,
|
||||
context: &JObject,
|
||||
intent: &JObject,
|
||||
) -> Result<JObject<'l>> {
|
||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"android/app/PendingIntent",
|
||||
"getActivity",
|
||||
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
|
||||
&[
|
||||
JValue::Object(context),
|
||||
JValue::Int(0),
|
||||
JValue::Object(intent),
|
||||
JValue::Int(update_current | immutable),
|
||||
],
|
||||
)?
|
||||
.l()
|
||||
}
|
||||
|
||||
fn builder_call<'l>(
|
||||
env: &mut Env<'l>,
|
||||
builder: &JObject<'l>,
|
||||
method: &str,
|
||||
sig: &str,
|
||||
args: &[JValue],
|
||||
) -> Result<()> {
|
||||
crate::jcall::call_method(env, builder, method, sig, args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The type Android 14+ requires a foreground service to declare, and
|
||||
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
|
||||
fn foreground_type(env: &mut Env) -> Result<i32> {
|
||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
|
||||
if sdk >= upside_down_cake {
|
||||
static_int(
|
||||
env,
|
||||
"android/content/pm/ServiceInfo",
|
||||
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
|
||||
)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&channel)],
|
||||
)?;
|
||||
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentTitle",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&title)],
|
||||
)?;
|
||||
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setSmallIcon",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(icon)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setOngoing",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setPriority",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(priority_min)],
|
||||
)?;
|
||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
|
||||
}
|
||||
|
||||
/// Starts the service if there is a server to connect to, and stops it
|
||||
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
|
||||
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
||||
let service_intent =
|
||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||
if settings::load(env, context)?.is_none() {
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
context,
|
||||
"stopService",
|
||||
"(Landroid/content/Intent;)Z",
|
||||
&[JValue::Object(&service_intent)],
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
create_channels(env, context)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"startForegroundService",
|
||||
"(Landroid/content/Context;Landroid/content/Intent;)V",
|
||||
&[JValue::Object(context), JValue::Object(&service_intent)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `Service.onStartCommand` body -- loads settings, starts the
|
||||
/// foreground notification, and spawns the follow-loop thread. Answers the
|
||||
/// platform's `START_STICKY`/`START_NOT_STICKY` constant, read from the
|
||||
/// framework rather than hardcoded so a wrong guess at their values cannot
|
||||
/// silently pick the other behaviour.
|
||||
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
||||
match try_start(env, &service) {
|
||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||
Ok(false) => {
|
||||
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||
}
|
||||
Err(e) => {
|
||||
log_error(env, "onStartCommand", &e);
|
||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
||||
let Some(settings) = settings::load(env, service)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let ca = settings::load_pinned_ca(env)?;
|
||||
let notification = ongoing_notification(env, service)?;
|
||||
let fg_type = foreground_type(env)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/app/ServiceCompat",
|
||||
"startForeground",
|
||||
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
|
||||
&[
|
||||
JValue::Object(service),
|
||||
JValue::Int(ONGOING_ID),
|
||||
JValue::Object(¬ification),
|
||||
JValue::Int(fg_type),
|
||||
],
|
||||
)?;
|
||||
|
||||
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
|
||||
// the first is still going -- the ordinary case for this service,
|
||||
// since `sync()` is called from more than one place -- must not open
|
||||
// a second connection.
|
||||
if RUNNING.swap(true, Ordering::SeqCst) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let vm = env.get_java_vm()?;
|
||||
let context = env.new_global_ref(service)?;
|
||||
STOPPING.store(false, Ordering::SeqCst);
|
||||
std::thread::Builder::new()
|
||||
.name("ai-app-notifications".to_string())
|
||||
.spawn(move || {
|
||||
// Requests a *permanent* attachment (detached only when this thread
|
||||
// exits), matching the Kotlin original's `thread(isDaemon = true)`:
|
||||
// this is the long-lived follow loop, not a one-shot callback.
|
||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||
follow_loop(env, &context, settings, &ca);
|
||||
Ok(())
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Follows the backend's notification stream, reconnecting until stopped
|
||||
/// -- mirrors `Notifications.kt`'s `follow`. A dropped connection is the
|
||||
/// ordinary case, so it retries quietly and forever; nothing is shown when
|
||||
/// it cannot connect, for the same reason as the Kotlin original: a
|
||||
/// notification saying "I could not tell you whether anything happened" is
|
||||
/// noise about a condition nobody can act on.
|
||||
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||
while !STOPPING.load(Ordering::SeqCst) {
|
||||
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||
let _ = follow_notifications(&transport, |notification| {
|
||||
if let Err(e) = show(env, context, ¬ification) {
|
||||
log_error(env, "show", &e);
|
||||
}
|
||||
!STOPPING.load(Ordering::SeqCst)
|
||||
});
|
||||
}
|
||||
if STOPPING.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
/// One notification per session, replacing that session's previous one --
|
||||
/// mirrors `Notifications.kt`'s `show`, minus the on-screen/banner
|
||||
/// branches this module's doc comment explains.
|
||||
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
||||
let manager = notification_manager(env, context)?;
|
||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
|
||||
let allowed = if sdk < tiramisu {
|
||||
true
|
||||
} else {
|
||||
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||
let granted = static_int(
|
||||
env,
|
||||
"android/content/pm/PackageManager",
|
||||
"PERMISSION_GRANTED",
|
||||
)?;
|
||||
let result = crate::jcall::call_static_method(
|
||||
env,
|
||||
"androidx/core/content/ContextCompat",
|
||||
"checkSelfPermission",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)I",
|
||||
&[JValue::Object(context), JValue::Object(&permission)],
|
||||
)?
|
||||
.i()?;
|
||||
result == granted
|
||||
};
|
||||
let enabled =
|
||||
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
||||
if !allowed || !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||
let pending = pending_activity(env, context, &intent)?;
|
||||
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||
let builder = crate::jcall::new_object(
|
||||
env,
|
||||
"androidx/core/app/NotificationCompat$Builder",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&channel)],
|
||||
)?;
|
||||
let title = crate::jcall::jstr_obj(env, ¬ification.title)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentTitle",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&title)],
|
||||
)?;
|
||||
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentText",
|
||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&text)],
|
||||
)?;
|
||||
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setSmallIcon",
|
||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Int(icon)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setContentIntent",
|
||||
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Object(&pending)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setAutoCancel",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let when = (notification.at * 1000.0) as i64;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setWhen",
|
||||
"(J)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Long(when)],
|
||||
)?;
|
||||
builder_call(
|
||||
env,
|
||||
&builder,
|
||||
"setShowWhen",
|
||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||
&[JValue::Bool(JNI_TRUE)],
|
||||
)?;
|
||||
let built =
|
||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||
.l()?;
|
||||
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&manager,
|
||||
"notify",
|
||||
"(Ljava/lang/String;ILandroid/app/Notification;)V",
|
||||
&[
|
||||
JValue::Object(&tag),
|
||||
JValue::Int(ALERT_ID),
|
||||
JValue::Object(&built),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
||||
/// the gap this module's `STOPPING` doc explains.
|
||||
pub fn on_destroy() {
|
||||
STOPPING.store(true, Ordering::SeqCst);
|
||||
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
||||
// comment): the old thread may still be inside a blocked read when a
|
||||
// new `onStartCommand` follows immediately, which would spawn a
|
||||
// second one before the first has actually stopped. Narrower than not
|
||||
// resetting at all -- a service destroyed and never restarted would
|
||||
// otherwise wedge `RUNNING` true forever -- and no worse than the
|
||||
// known gap already accepted above.
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
||||
let message = format!("android-shell: {where_}: {error}");
|
||||
let _ = (|| -> Result<()> {
|
||||
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
||||
let msg = crate::jcall::jstr_obj(env, &message)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"android/util/Log",
|
||||
"e",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)I",
|
||||
&[JValue::Object(&tag), JValue::Object(&msg)],
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Enrollment: where the backend is, and the Keystore-sealed token to
|
||||
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
|
||||
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
|
||||
//! class the production app already uses (see `ServerConfig.kt`), through
|
||||
//! JNI, for two reasons: that code is shared with Dev Updater and already
|
||||
//! tested, and the sealed value on a real phone is keyed to the exact
|
||||
//! Keystore alias that class already uses -- reimplementing the crypto
|
||||
//! here would either duplicate it or invalidate an existing enrollment.
|
||||
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
/// Where the backend is and how to authenticate to it -- the Rust twin of
|
||||
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
|
||||
/// rather than kept as a live JNI reference, so it can cross a thread
|
||||
/// boundary (a `JObject` is tied to one `Env`/thread).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerSettings {
|
||||
pub host: String,
|
||||
pub port: i32,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl ServerSettings {
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("https://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// This experiment's own scheme and Keystore alias -- distinct from the
|
||||
/// production app's (`aiapp` / `aiapp-token-key`) so the two can be
|
||||
/// installed side by side on the same development device without
|
||||
/// colliding over which one a scanned QR or a deep link resolves to. See
|
||||
/// RUST.md's E3 entry for why they are not the same value.
|
||||
pub(crate) const SCHEME: &str = "aiappshell";
|
||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||
|
||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
||||
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||
crate::jcall::new_object(
|
||||
env,
|
||||
STORE_CLASS,
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[JValue::Object(&scheme), JValue::Object(&alias)],
|
||||
)
|
||||
}
|
||||
|
||||
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
||||
let host = get_string(env, settings_obj, "getHost")?;
|
||||
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
||||
let token = get_string(env, settings_obj, "getToken")?;
|
||||
Ok(ServerSettings { host, port, token })
|
||||
}
|
||||
|
||||
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
||||
let value = crate::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
jstr.try_to_string(env)
|
||||
}
|
||||
|
||||
/// The stored enrollment, or `None` when there is not one -- mirrors
|
||||
/// `ServerConfig.kt`'s `loadServerSettings`.
|
||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"load",
|
||||
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
|
||||
&[JValue::Object(context)],
|
||||
)?
|
||||
.l()?;
|
||||
if settings_obj.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_settings(env, &settings_obj)?))
|
||||
}
|
||||
|
||||
/// Seals and stores `settings` -- mirrors `ServerConfig.kt`'s `saveServerSettings`.
|
||||
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||
let store = new_store(env)?;
|
||||
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
||||
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
||||
let settings_obj = crate::jcall::new_object(
|
||||
env,
|
||||
SETTINGS_CLASS,
|
||||
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(&host),
|
||||
JValue::Int(settings.port),
|
||||
JValue::Object(&token),
|
||||
],
|
||||
)?;
|
||||
crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"save",
|
||||
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
|
||||
&[JValue::Object(context), JValue::Object(&settings_obj)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses an `aiappshell://enroll?...` URI -- mirrors `ServerConfig.kt`'s
|
||||
/// `parseEnrollmentUri`, asking the same Kotlin code that already owns the
|
||||
/// query-parameter rules rather than re-deriving them here.
|
||||
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||
let store = new_store(env)?;
|
||||
let settings_obj = crate::jcall::call_method(
|
||||
env,
|
||||
&store,
|
||||
"parseEnrollmentUri",
|
||||
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
|
||||
&[JValue::Object(uri)],
|
||||
)?
|
||||
.l()?;
|
||||
if settings_obj.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(read_settings(env, &settings_obj)?))
|
||||
}
|
||||
|
||||
/// The CA this build pins, generated at build time the same way
|
||||
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
|
||||
/// but into a plain Java constant, since this module has no Kotlin of its
|
||||
/// own to generate into.
|
||||
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
||||
let value = crate::jcall::get_static_field(
|
||||
env,
|
||||
"com/example/aiapp/shell/PinnedCa",
|
||||
"PINNED_CA_PEM",
|
||||
"Ljava/lang/String;",
|
||||
)?
|
||||
.l()?;
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
Ok(jstr.try_to_string(env)?.into_bytes())
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
||||
//! `handleIntent`/`onNewIntent` and `Share.kt`'s `sharedContent`.
|
||||
//!
|
||||
//! **Scope cut, recorded rather than silent**: only shared *text*
|
||||
//! (`Intent.EXTRA_TEXT`) is attached to a session. `Attachments.kt`'s
|
||||
//! upload path -- `ContentResolver` reads of a shared file/photo URI,
|
||||
//! bitmap downscaling, EXIF rotation -- is real work of its own and is not
|
||||
//! ported here, because `client-core`'s `ApiClient` does not have the
|
||||
//! `/sessions/{id}/attachments` route yet either (see `CLIENT_CORE.md`'s
|
||||
//! "not covered" list). So `ACTION_SEND`/`ACTION_SEND_MULTIPLE` with a
|
||||
//! `content://` stream and no text falls through to a toast saying so,
|
||||
//! rather than silently doing nothing. Closing this gap is the same
|
||||
//! `client-core` work whichever caller needs it next.
|
||||
//!
|
||||
//! **Which session a share lands in** is also a placeholder: with no
|
||||
//! screen drawn yet (E4's job), there is no picker to ask, so this attaches
|
||||
//! to whichever session has the latest `last_activity` -- the one most
|
||||
//! likely to be what somebody meant. Worth revisiting once a real screen
|
||||
//! exists to ask instead of guessing.
|
||||
|
||||
use client_core::api::{ApiClient, UreqTransport};
|
||||
use jni::Env;
|
||||
use jni::errors::Result;
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
use crate::notify;
|
||||
use crate::settings;
|
||||
|
||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
||||
const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
||||
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
||||
|
||||
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
||||
let value = crate::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||
Ok(Some(jstr.try_to_string(env)?))
|
||||
}
|
||||
|
||||
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||
let message = crate::jcall::jstr_obj(env, message)?;
|
||||
crate::jcall::call_static_method(
|
||||
env,
|
||||
"com/example/aiapp/shell/MainActivity",
|
||||
"toast",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
&[JValue::Object(context), JValue::Object(&message)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The one place an incoming intent is sorted into what it means -- mirrors
|
||||
/// `MainActivity.kt`'s `handleIntent`.
|
||||
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||
let action = get_string_method(env, intent, "getAction")?;
|
||||
if matches!(
|
||||
action.as_deref(),
|
||||
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
|
||||
) {
|
||||
return handle_share(env, activity, intent);
|
||||
}
|
||||
if action.as_deref() != Some(ACTION_VIEW) {
|
||||
return Ok(());
|
||||
}
|
||||
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
||||
if uri.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let scheme = get_string_method(env, &uri, "getScheme")?;
|
||||
if scheme.as_deref() != Some(settings::SCHEME) {
|
||||
return Ok(());
|
||||
}
|
||||
match get_string_method(env, &uri, "getHost")?.as_deref() {
|
||||
Some("session") => handle_session_open(env, activity, &uri),
|
||||
Some("enroll") => handle_enrollment(env, activity, &uri),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||
return Ok(());
|
||||
};
|
||||
// There is no session screen yet (E4's job); the toast is this
|
||||
// experiment's stand-in proof that the tap was routed to the right
|
||||
// session id.
|
||||
toast(env, activity, &format!("Opened session {session_id}"))
|
||||
}
|
||||
|
||||
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
|
||||
match settings::parse_enrollment_uri(env, uri)? {
|
||||
Some(parsed) => {
|
||||
settings::save(env, activity, &parsed)?;
|
||||
notify::sync(env, activity)?;
|
||||
toast(
|
||||
env,
|
||||
activity,
|
||||
&format!("Enrolled with {}", parsed.base_url()),
|
||||
)
|
||||
}
|
||||
None => toast(env, activity, "Not a valid enrollment code"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The share sheet -- mirrors `Share.kt`'s `sharedContent` for what counts
|
||||
/// as a share, and `AttachmentButton`'s upload-then-message pattern for
|
||||
/// what happens to it, minus attachments per this module's doc comment.
|
||||
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||
let text = crate::jcall::call_method(
|
||||
env,
|
||||
intent,
|
||||
"getStringExtra",
|
||||
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||
&[JValue::Object(&extra_text)],
|
||||
)?
|
||||
.l()?;
|
||||
let text = if text.is_null() {
|
||||
None
|
||||
} else {
|
||||
let jstr: JString = env.cast_local::<JString>(text)?;
|
||||
Some(jstr.try_to_string(env)?)
|
||||
};
|
||||
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
|
||||
return toast(
|
||||
env,
|
||||
activity,
|
||||
"Nothing to share -- only shared text is supported so far",
|
||||
);
|
||||
};
|
||||
|
||||
// Network I/O must not run on the calling thread: `handle_intent` is
|
||||
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
|
||||
// blocking socket read there is a `NetworkOnMainThreadException`. So
|
||||
// the actual send happens on a JNI-attached background thread, the
|
||||
// same shape `notify::try_start`'s follow loop uses; `toast` from that
|
||||
// thread is safe because `MainActivity.toast` itself hops back to the
|
||||
// main looper (see that method).
|
||||
let vm = env.get_java_vm()?;
|
||||
let activity_ref = env.new_global_ref(activity)?;
|
||||
std::thread::spawn(move || {
|
||||
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||
share_in_background(env, &activity_ref, text);
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
|
||||
let outcome = attach_to_a_session(env, activity, &text);
|
||||
let message = match outcome {
|
||||
Ok(title) => format!("Shared into \"{title}\""),
|
||||
Err(message) => message,
|
||||
};
|
||||
let _ = toast(env, activity, &message);
|
||||
}
|
||||
|
||||
fn attach_to_a_session(
|
||||
env: &mut Env,
|
||||
activity: &JObject,
|
||||
text: &str,
|
||||
) -> std::result::Result<String, String> {
|
||||
let settings = settings::load(env, activity)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Not enrolled yet".to_string())?;
|
||||
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
|
||||
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let client = ApiClient::new(transport);
|
||||
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
|
||||
let target = sessions
|
||||
.into_iter()
|
||||
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
|
||||
.ok_or_else(|| "No session to share into".to_string())?;
|
||||
client
|
||||
.send_message(&target.id, text, &[])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(target.title)
|
||||
}
|
||||
@@ -102,6 +102,16 @@ android {
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
|
||||
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
|
||||
// the `bench` build type below, which overrides it.
|
||||
buildConfigField("boolean", "FIXTURE_MODE", "false")
|
||||
}
|
||||
buildFeatures {
|
||||
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
|
||||
buildConfig = true
|
||||
// Only for the bench build type's resValue("string", "app_name", ...) below.
|
||||
resValues = true
|
||||
}
|
||||
packaging {
|
||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
@@ -131,6 +141,35 @@ android {
|
||||
isMinifyEnabled = false
|
||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
|
||||
// optimisations so a frame time measured here means what release means everywhere else in
|
||||
// this project, its own application id so it installs beside a real enrollment rather than
|
||||
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
|
||||
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
|
||||
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
|
||||
// be one more secret to keep off this machine's shared mount for no benefit.
|
||||
create("bench") {
|
||||
initWith(getByName("release"))
|
||||
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
|
||||
// with dev-updater and has no reason to know this project invented one -- so this says
|
||||
// which of its build types to link against instead.
|
||||
matchingFallbacks += listOf("release")
|
||||
applicationIdSuffix = ".bench"
|
||||
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
|
||||
// Settings): this resValue overrides res/values/strings.xml's app_name for this
|
||||
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
|
||||
// rather than a literal so a build type can override it without touching the
|
||||
// manifest.
|
||||
resValue("string", "app_name", "AI Sessions bench")
|
||||
buildConfigField("boolean", "FIXTURE_MODE", "true")
|
||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
// The fixture both bench builds (this one and iris's) open with; see
|
||||
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
|
||||
// into androidApp/src -- one file to keep in sync with the generator, not two.
|
||||
getByName("bench").assets.directories.add("../bench-fixture/assets")
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
the fix is a judgement about how this app should look. Drop this
|
||||
suppression when a real icon lands. -->
|
||||
<application
|
||||
android:label="AI Sessions"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
tools:ignore="MissingApplicationIcon">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.content.Context
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process
|
||||
* fake of the backend, so the `bench` build type can drive a real session screen -- the real
|
||||
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
|
||||
*
|
||||
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
|
||||
* in this build compiles it in but never calls it, since Kotlin has no per-build-type source set
|
||||
* that both [MainActivity] (which every variant compiles) and this can share without one.
|
||||
*
|
||||
* The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through
|
||||
* ordinary `java.net.URL`, exactly as they would talk to a real server. A
|
||||
* [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every
|
||||
* `https://` connection to that host and answers from this object's in-memory event log instead of
|
||||
* opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen,
|
||||
* the fold, uniqueItems) never learns the difference.
|
||||
*/
|
||||
object BenchFixture {
|
||||
const val FIXTURE_HOST = "bench.fixture.invalid"
|
||||
const val FIXTURE_PORT = 1
|
||||
|
||||
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
||||
private const val BACKLOG_COUNT = 3200
|
||||
|
||||
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
||||
|
||||
/** The session id every bench run opens; nothing else in this build ever mints one. */
|
||||
const val SESSION_ID = "bench-fixture-session"
|
||||
|
||||
/**
|
||||
* The whole transcript, seq order, growing as [pushLive] is called during the streaming phase.
|
||||
* Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a
|
||||
* live frame agree on what has "already happened" -- the same thing a real server's own
|
||||
* transcript file guarantees.
|
||||
*/
|
||||
private val log = CopyOnWriteArrayList<Pair<String, SeqEvent>>()
|
||||
|
||||
/** The events not yet appended to [log] -- the streaming phase's own source. */
|
||||
private var streamTail: List<Pair<String, SeqEvent>> = emptyList()
|
||||
|
||||
private val images = mutableMapOf<String, ByteArray>()
|
||||
|
||||
@Volatile private var loaded = false
|
||||
|
||||
/**
|
||||
* Parses the bundled fixture once. Safe to call more than once; only the first does anything.
|
||||
*/
|
||||
@Synchronized
|
||||
fun ensureLoaded(context: Context) {
|
||||
if (loaded) return
|
||||
val lines =
|
||||
context.assets.open("transcript.jsonl").bufferedReader().readLines().filter {
|
||||
it.isNotBlank()
|
||||
}
|
||||
val parsed = lines.map { it to parseSeqEvent(it) }
|
||||
log.addAll(parsed.take(BACKLOG_COUNT))
|
||||
streamTail = parsed.drop(BACKLOG_COUNT)
|
||||
for (name in listOf("bench1.png", "bench2.png")) {
|
||||
images[name] = context.assets.open(name).readBytes()
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
/** The events the streaming phase has left to send. */
|
||||
fun remainingStreamEvents(): Int = streamTail.size
|
||||
|
||||
/** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */
|
||||
fun pushNextLiveEvent(): Boolean {
|
||||
val next = streamTail.firstOrNull() ?: return false
|
||||
streamTail = streamTail.drop(1)
|
||||
log.add(next)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */
|
||||
@Synchronized
|
||||
fun resetToBacklog(context: Context) {
|
||||
loaded = false
|
||||
log.clear()
|
||||
ensureLoaded(context)
|
||||
}
|
||||
|
||||
fun fileBytes(name: String): ByteArray? = images[name]
|
||||
|
||||
/**
|
||||
* Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays.
|
||||
*/
|
||||
fun linesAfter(after: Long): List<String> =
|
||||
log.filter { it.second.seq > after }.map { it.first }
|
||||
|
||||
/**
|
||||
* One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores
|
||||
* `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and
|
||||
* what the benchmark exercises is the fold and the paging, not the server's row-joining, which
|
||||
* client-core's own port tracks separately (CLIENT_CORE.md).
|
||||
*/
|
||||
fun page(before: Long?, limit: Int, after: Long?): List<String> {
|
||||
val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L)
|
||||
val candidates = log.filter {
|
||||
it.second.seq < upper && (after == null || it.second.seq > after)
|
||||
}
|
||||
return candidates.takeLast(limit).map { it.first }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.PipedInputStream
|
||||
import java.io.PipedOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLStreamHandler
|
||||
import java.net.URLStreamHandlerFactory
|
||||
import java.security.Principal
|
||||
import java.security.cert.Certificate
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLPeerUnverifiedException
|
||||
import org.json.JSONArray
|
||||
|
||||
/**
|
||||
* Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more
|
||||
* than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once
|
||||
* per process, and a second real call throws -- so this guards it rather than relying on every
|
||||
* caller to remember.
|
||||
*
|
||||
* Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's
|
||||
* ordinary handler, so this only ever changes behaviour for the one host the bench build invents.
|
||||
*/
|
||||
@Synchronized
|
||||
fun installFixtureNetworkOnce() {
|
||||
if (installed) return
|
||||
installed = true
|
||||
URL.setURLStreamHandlerFactory(
|
||||
URLStreamHandlerFactory { protocol ->
|
||||
if (protocol != "https") null
|
||||
else
|
||||
object : URLStreamHandler() {
|
||||
override fun openConnection(url: URL): HttpURLConnection =
|
||||
if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url)
|
||||
else
|
||||
// The bench build makes no other https call -- this factory is
|
||||
// installed only in FIXTURE_MODE (MainActivity) -- so there is
|
||||
// deliberately no delegate to a platform handler here: once a
|
||||
// URLStreamHandlerFactory is installed there is no supported way to
|
||||
// ask the JDK for its own default handler back, and re-entering this
|
||||
// same factory for the fallback would recurse forever rather than
|
||||
// reach one.
|
||||
throw java.io.IOException(
|
||||
"bench build's fixture network has no route to https host " +
|
||||
"${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var installed = false
|
||||
|
||||
/**
|
||||
* Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of
|
||||
* [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call
|
||||
* `connect`/`disconnect`, set a handful of request properties they never need answered, and read
|
||||
* `responseCode` and `inputStream`.
|
||||
*/
|
||||
private class FixtureConnection(url: URL) : HttpsURLConnection(url) {
|
||||
private var input: InputStream? = null
|
||||
private var writer: Thread? = null
|
||||
|
||||
override fun connect() {
|
||||
if (input != null) return
|
||||
input = route(url.path, url.query)
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
writer?.interrupt()
|
||||
try {
|
||||
input?.close()
|
||||
} catch (_: IOException) {}
|
||||
}
|
||||
|
||||
override fun usingProxy() = false
|
||||
|
||||
override fun getResponseCode(): Int {
|
||||
connect()
|
||||
return 200
|
||||
}
|
||||
|
||||
override fun getInputStream(): InputStream {
|
||||
connect()
|
||||
return input!!
|
||||
}
|
||||
|
||||
override fun getErrorStream(): InputStream? = null
|
||||
|
||||
// Nothing here reads any of these; implemented only because HttpsURLConnection declares them
|
||||
// abstract. A fixture never negotiates real TLS, so each says exactly that rather than
|
||||
// fabricating a plausible-looking certificate.
|
||||
override fun getCipherSuite() = "none (bench fixture, no TLS)"
|
||||
|
||||
override fun getLocalCertificates(): Array<Certificate>? = null
|
||||
|
||||
override fun getServerCertificates(): Array<Certificate> =
|
||||
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
|
||||
|
||||
override fun getPeerPrincipal(): Principal =
|
||||
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
|
||||
|
||||
override fun getLocalPrincipal(): Principal? = null
|
||||
|
||||
/**
|
||||
* [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug.
|
||||
*/
|
||||
private fun route(path: String, query: String?): InputStream {
|
||||
val params =
|
||||
(query ?: "")
|
||||
.split("&")
|
||||
.filter { it.contains('=') }
|
||||
.associate {
|
||||
val (k, v) = it.split("=", limit = 2)
|
||||
k to java.net.URLDecoder.decode(v, "UTF-8")
|
||||
}
|
||||
return when {
|
||||
path.endsWith("/transcript") -> {
|
||||
val lines =
|
||||
BenchFixture.page(
|
||||
before = params["before"]?.toLongOrNull(),
|
||||
limit = params["limit"]?.toIntOrNull() ?: 80,
|
||||
after = params["after"]?.toLongOrNull(),
|
||||
)
|
||||
val body = JSONArray(lines.map { org.json.JSONObject(it) })
|
||||
ByteArrayInputStream(body.toString().toByteArray())
|
||||
}
|
||||
path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L)
|
||||
path.contains("/files/") -> {
|
||||
val name = path.substringAfterLast("/files/")
|
||||
val bytes =
|
||||
BenchFixture.fileBytes(name)
|
||||
?: throw IOException("bench fixture has no file named $name")
|
||||
ByteArrayInputStream(bytes)
|
||||
}
|
||||
else -> throw IOException("bench fixture has no route for $path")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for
|
||||
* anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's
|
||||
* backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business
|
||||
* rather than something worth a condition variable for.
|
||||
*/
|
||||
private fun openEventsStream(after: Long): InputStream {
|
||||
val pipeIn = PipedInputStream(1 shl 16)
|
||||
val pipeOut = PipedOutputStream(pipeIn)
|
||||
var sent = after
|
||||
val thread = Thread {
|
||||
try {
|
||||
while (!Thread.currentThread().isInterrupted) {
|
||||
val fresh = BenchFixture.linesAfter(sent)
|
||||
for (line in fresh) {
|
||||
pipeOut.write("data: $line\n\n".toByteArray())
|
||||
pipeOut.flush()
|
||||
sent = org.json.JSONObject(line).getLong("seq")
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
// disconnect() -- the ordinary way this ends.
|
||||
} catch (_: IOException) {
|
||||
// The reader side (Sse) closed its end.
|
||||
} finally {
|
||||
try {
|
||||
pipeOut.close()
|
||||
} catch (_: IOException) {}
|
||||
}
|
||||
}
|
||||
.also {
|
||||
it.isDaemon = true
|
||||
it.start()
|
||||
}
|
||||
writer = thread
|
||||
return pipeIn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import android.os.Process
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable
|
||||
* system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop
|
||||
* and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced
|
||||
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
||||
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
||||
* [BenchFixture]'s doc comment gives.
|
||||
*
|
||||
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
|
||||
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
|
||||
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
|
||||
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
|
||||
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
|
||||
* **keyboard** (five show/hide cycles). The exact constants below are also written into
|
||||
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
|
||||
* spec -- changing a number here without updating that box makes the two apps measure different
|
||||
* things while looking like the same benchmark.
|
||||
*/
|
||||
object BenchRun {
|
||||
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
|
||||
private const val CYCLES = 6
|
||||
private const val SWIPE_PX = 900f
|
||||
private const val SWIPE_MS = 200
|
||||
private const val SWIPE_PAUSE_MS = 500L
|
||||
|
||||
/**
|
||||
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
|
||||
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
|
||||
* never exceed the distance it is told to cover in the time it is given, while a real fling
|
||||
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
|
||||
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
|
||||
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
|
||||
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
|
||||
*/
|
||||
private const val FLING_VELOCITY_PX_S = 12_000f
|
||||
|
||||
private const val FLING_COUNT = 8
|
||||
private const val FLING_SETTLE_CAP_MS = 3_000L
|
||||
private const val FLING_PAUSE_MS = 300L
|
||||
|
||||
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
|
||||
private const val STREAM_EVENTS_PER_SEC = 20
|
||||
private const val STREAM_SECONDS = 20
|
||||
|
||||
/**
|
||||
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
|
||||
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
|
||||
* own height grows over several frames, pushing the transcript above it upward the same way a
|
||||
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
|
||||
* half types the identical content.
|
||||
*/
|
||||
const val TYPE_TEXT =
|
||||
"Benchmarking this transcript screen requires unusually long, multisyllabic words so " +
|
||||
"wrapping and reflow are properly exercised: internationalization, " +
|
||||
"counterproductiveness, disproportionately, incomprehensibility, " +
|
||||
"deinstitutionalization, uncharacteristically, overenthusiastically, " +
|
||||
"misunderstanding, straightforwardness, telecommunications, and interdisciplinary " +
|
||||
"collaboration all push a narrow composer field to wrap across several lines while " +
|
||||
"the transcript above is pushed upward by the growing keyboard-adjacent box, which " +
|
||||
"is exactly what a real reader typing a long message sees happening now!!!"
|
||||
|
||||
private const val TYPE_CHAR_DELAY_MS = 50L
|
||||
|
||||
/**
|
||||
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
|
||||
* transition is ever actually observed rather than being a one-off fluke either way.
|
||||
*/
|
||||
private const val KEYBOARD_CYCLES = 5
|
||||
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
|
||||
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
|
||||
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
|
||||
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
|
||||
* resets them, so the two accountings cover the same stretch of work.
|
||||
*/
|
||||
suspend fun run(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
listState: LazyListState,
|
||||
flingBehavior: FlingBehavior,
|
||||
composerFocus: FocusRequester,
|
||||
setComposerText: (String) -> Unit,
|
||||
view: View,
|
||||
): List<String> {
|
||||
FrameStats.reset()
|
||||
DebugStats.reset()
|
||||
val cpuStartMs = Process.getElapsedCpuTime()
|
||||
|
||||
val battery = BatterySampler(context)
|
||||
// Launched in the caller's scope rather than a fresh coroutineScope{} here, which would
|
||||
// suspend this function until the sampler job ended -- and it only ends when told to.
|
||||
val samplerJob = scope.launch {
|
||||
while (isActive) {
|
||||
battery.sample()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val travel = runFlingPhase(listState, flingBehavior)
|
||||
val sent = runStreamPhase()
|
||||
runTypePhase(listState, composerFocus, setComposerText, view)
|
||||
val keyboard = runKeyboardPhase(context, view)
|
||||
|
||||
samplerJob.cancel()
|
||||
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
||||
val rssLine = peakRssLine()
|
||||
val batteryLine = battery.finish()
|
||||
|
||||
return listOf(
|
||||
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
|
||||
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
|
||||
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
|
||||
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
|
||||
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
|
||||
" ${TYPE_CHAR_DELAY_MS}ms",
|
||||
keyboard,
|
||||
" process CPU time over this run: ${cpuMs}ms",
|
||||
rssLine,
|
||||
batteryLine,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
|
||||
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
|
||||
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
|
||||
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
|
||||
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
|
||||
* the pre-v2 swipe loop below already used for its first two swipes.
|
||||
*/
|
||||
private suspend fun runFlingPhase(
|
||||
listState: LazyListState,
|
||||
flingBehavior: FlingBehavior,
|
||||
): String {
|
||||
FrameStats.markPhase("fling")
|
||||
listState.scrollToItem(0)
|
||||
val start = position(listState)
|
||||
repeat(FLING_COUNT) {
|
||||
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
|
||||
waitForSettle(listState)
|
||||
delay(FLING_PAUSE_MS)
|
||||
}
|
||||
val outward = position(listState)
|
||||
repeat(FLING_COUNT) {
|
||||
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
|
||||
waitForSettle(listState)
|
||||
delay(FLING_PAUSE_MS)
|
||||
}
|
||||
val back = position(listState)
|
||||
return "start=$start outward=$outward end=$back"
|
||||
}
|
||||
|
||||
private fun position(listState: LazyListState) =
|
||||
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
|
||||
|
||||
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
|
||||
private suspend fun waitForSettle(listState: LazyListState) {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
while (
|
||||
listState.isScrollInProgress &&
|
||||
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
|
||||
) {
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
|
||||
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
|
||||
* arrives off-screen and the report would show nothing happened.
|
||||
*/
|
||||
private suspend fun runStreamPhase(): Int {
|
||||
FrameStats.markPhase("stream")
|
||||
var sent = 0
|
||||
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
|
||||
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
|
||||
BenchFixture.pushNextLiveEvent()
|
||||
sent++
|
||||
delay(1000L / STREAM_EVENTS_PER_SEC)
|
||||
}
|
||||
// Lets the last few deltas land and draw before the next phase starts.
|
||||
delay(300)
|
||||
return sent
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
|
||||
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
|
||||
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
|
||||
* being pushed upward, not a single big write.
|
||||
*/
|
||||
private suspend fun runTypePhase(
|
||||
listState: LazyListState,
|
||||
composerFocus: FocusRequester,
|
||||
setComposerText: (String) -> Unit,
|
||||
view: View,
|
||||
) {
|
||||
FrameStats.markPhase("type")
|
||||
listState.scrollToItem(0)
|
||||
composerFocus.requestFocus()
|
||||
showIme(view.context, view)
|
||||
// Lets focus and the keyboard's opening animation land before typing starts, so the frames
|
||||
// this phase records are the wrap/reflow it is measuring, not the keyboard opening.
|
||||
delay(300)
|
||||
var typed = ""
|
||||
for (ch in TYPE_TEXT) {
|
||||
typed += ch
|
||||
setComposerText(typed)
|
||||
delay(TYPE_CHAR_DELAY_MS)
|
||||
}
|
||||
delay(200)
|
||||
while (typed.isNotEmpty()) {
|
||||
typed = typed.dropLast(1)
|
||||
setComposerText(typed)
|
||||
delay(TYPE_CHAR_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
|
||||
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
|
||||
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
|
||||
* never present an inferred value as a measured one. If the platform never shows it even once,
|
||||
* this says so in words rather than reporting a phase with no keyboard in it.
|
||||
*/
|
||||
private suspend fun runKeyboardPhase(context: Context, view: View): String {
|
||||
FrameStats.markPhase("keyboard")
|
||||
var shown = 0
|
||||
var hidden = 0
|
||||
repeat(KEYBOARD_CYCLES) {
|
||||
showIme(context, view)
|
||||
delay(KEYBOARD_SHOW_WAIT_MS)
|
||||
if (imeVisible(view)) shown++
|
||||
hideIme(context, view)
|
||||
delay(KEYBOARD_HIDE_WAIT_MS)
|
||||
if (!imeVisible(view)) hidden++
|
||||
}
|
||||
return if (shown == 0) {
|
||||
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
|
||||
} else {
|
||||
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
|
||||
" (confirmed via isImeVisible)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
|
||||
val window = context.activity()?.window ?: return null
|
||||
return WindowInsetsControllerCompat(window, view)
|
||||
}
|
||||
|
||||
private fun showIme(context: Context, view: View) {
|
||||
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
|
||||
private fun hideIme(context: Context, view: View) {
|
||||
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
|
||||
private fun imeVisible(view: View): Boolean =
|
||||
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
|
||||
|
||||
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
|
||||
private fun peakRssLine(): String {
|
||||
val kb =
|
||||
try {
|
||||
File("/proc/self/status")
|
||||
.readLines()
|
||||
.firstOrNull { it.startsWith("VmHWM:") }
|
||||
?.trim()
|
||||
?.removePrefix("VmHWM:")
|
||||
?.trim()
|
||||
?.removeSuffix("kB")
|
||||
?.trim()
|
||||
?.toLongOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
return " peak RSS: " +
|
||||
(kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of
|
||||
* a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most
|
||||
* emulators -- and that is reported as "unavailable" rather than folded into an average with the
|
||||
* real samples, which would silently understate every number after it. See UI_RULES: never present
|
||||
* an inferred value as a measured one.
|
||||
*/
|
||||
private class BatterySampler(context: Context) {
|
||||
private val manager = context.getSystemService(BatteryManager::class.java)
|
||||
private val samples = mutableListOf<Int>()
|
||||
|
||||
fun sample() {
|
||||
val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
|
||||
if (value != null && value != Int.MIN_VALUE) samples.add(value)
|
||||
}
|
||||
|
||||
fun finish(): String {
|
||||
if (samples.isEmpty()) return " battery current: unavailable on this device"
|
||||
val meanUa = samples.sum() / samples.size
|
||||
return " battery current: mean ${meanUa}µA over ${samples.size} samples" +
|
||||
" (min ${samples.min()}, max ${samples.max()})"
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,20 @@ fun debugReport(
|
||||
frames: List<String>,
|
||||
accounting: List<String>,
|
||||
crash: String?,
|
||||
/**
|
||||
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
|
||||
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
|
||||
* heading only appears when there is something to put under it, so an ordinary copy from the
|
||||
* render-report button reads exactly as it did before this existed.
|
||||
*/
|
||||
extra: List<String> = emptyList(),
|
||||
/**
|
||||
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
|
||||
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
|
||||
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
|
||||
* [extra].
|
||||
*/
|
||||
phaseFrames: List<String> = emptyList(),
|
||||
): String = buildString {
|
||||
appendLine("ai-app render report")
|
||||
appendLine(device)
|
||||
@@ -141,6 +155,11 @@ fun debugReport(
|
||||
appendLine("transcript:")
|
||||
transcript.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
if (phaseFrames.isNotEmpty()) {
|
||||
appendLine("per phase:")
|
||||
phaseFrames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
}
|
||||
appendLine("frames:")
|
||||
frames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
@@ -152,6 +171,11 @@ fun debugReport(
|
||||
appendLine("work since this was last copied:")
|
||||
val work = DebugStats.lines()
|
||||
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
||||
if (extra.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("bench:")
|
||||
extra.forEach { appendLine(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
||||
|
||||
@@ -42,6 +42,21 @@ object FrameStats {
|
||||
private val gpu = ArrayList<Long>()
|
||||
private var since = System.currentTimeMillis()
|
||||
|
||||
/**
|
||||
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
|
||||
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
|
||||
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
|
||||
*/
|
||||
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
|
||||
|
||||
private val phaseMarks = ArrayList<PhaseMark>()
|
||||
|
||||
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
|
||||
@Synchronized
|
||||
fun markPhase(name: String) {
|
||||
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun add(metrics: FrameMetrics) {
|
||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
||||
@@ -69,6 +84,7 @@ object FrameStats {
|
||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||
it.clear()
|
||||
}
|
||||
phaseMarks.clear()
|
||||
since = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@@ -95,6 +111,38 @@ object FrameStats {
|
||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
||||
}
|
||||
|
||||
/**
|
||||
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
|
||||
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
|
||||
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
|
||||
* that finished before a frame was drawn) still get a line rather than being silently dropped
|
||||
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
|
||||
*/
|
||||
@Synchronized
|
||||
fun phaseLines(refreshHz: Float): List<String> {
|
||||
if (phaseMarks.isEmpty()) return emptyList()
|
||||
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
|
||||
val lines = ArrayList<String>()
|
||||
phaseMarks.forEachIndexed { i, mark ->
|
||||
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
|
||||
val endMs =
|
||||
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
|
||||
else System.currentTimeMillis()
|
||||
val samples = total.subList(mark.startIndex, endIndex)
|
||||
val seconds = (endMs - mark.startMs) / 1000.0
|
||||
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
|
||||
if (samples.isEmpty()) {
|
||||
lines += " no frames recorded in this phase"
|
||||
} else {
|
||||
val late = samples.count { it / 1_000_000.0 > budget }
|
||||
lines += " late: $late (${percent(late, samples.size)})"
|
||||
lines += " " + phase("total ", samples)
|
||||
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
||||
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||
|
||||
|
||||
@@ -66,6 +66,35 @@ class MainActivity : ComponentActivity() {
|
||||
// Transparent status bar on every version; the Surface below paints through underneath it
|
||||
// and content insets itself. Same reasoning as dev-updater's MainActivity.
|
||||
enableEdgeToEdge()
|
||||
|
||||
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
|
||||
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
|
||||
// permission, and no notification prompt -- none of them mean anything with no server and
|
||||
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
|
||||
// to talk to a real backend is made to talk to this instead. Still needs the same
|
||||
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
|
||||
// platform's own default from Android 15 on this app's targetSdk, with or without the call
|
||||
// above, so skipping the padding here put the header's own buttons under the status bar --
|
||||
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
|
||||
if (BuildConfig.FIXTURE_MODE) {
|
||||
installFixtureNetworkOnce()
|
||||
BenchFixture.ensureLoaded(this)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = AiAppColors) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
|
||||
SessionScreen(
|
||||
settings = BenchFixture.settings,
|
||||
summary = benchSessionSummary(),
|
||||
onBack = { finish() },
|
||||
onFiles = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Dark status-bar icons only over a light background, decided from the scheme rather than
|
||||
// fixed. It was hardcoded to `true`, which was right against the default light surface and
|
||||
// became unreadable the moment the app wore Catppuccin Mocha.
|
||||
@@ -147,6 +176,33 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */
|
||||
private fun benchSessionSummary() =
|
||||
SessionSummary(
|
||||
id = BenchFixture.SESSION_ID,
|
||||
setup = "bench",
|
||||
setupName = "bench",
|
||||
provider = "bench",
|
||||
title = "P0 benchmark",
|
||||
model = null,
|
||||
keepsOwnTranscript = false,
|
||||
permissionMode = null,
|
||||
effort = null,
|
||||
takesEffort = false,
|
||||
imported = false,
|
||||
notify = false,
|
||||
autoResume = false,
|
||||
autoResumeMessage = "",
|
||||
resumeAt = null,
|
||||
cwd = null,
|
||||
contextTokens = null,
|
||||
maxImageEdge = null,
|
||||
usageProvider = null,
|
||||
status = "idle",
|
||||
lastActivity = 0.0,
|
||||
subagents = 0,
|
||||
)
|
||||
|
||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
||||
// lands here rather than in a second activity instance.
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -64,12 +65,15 @@ import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
@@ -354,6 +358,17 @@ fun SessionScreen(
|
||||
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
|
||||
// fight over the first frame.
|
||||
val listState = remember(address) { LazyListState() }
|
||||
// The list's own fling path -- what a real flick decays through -- captured here so BenchRun's
|
||||
// fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this
|
||||
// screen's
|
||||
// `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior`
|
||||
// argument, so this is the same default it gets).
|
||||
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
// Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on
|
||||
// -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold
|
||||
// unconditionally rather than behind a second code path only the bench build compiles.
|
||||
val composerFocus = remember { FocusRequester() }
|
||||
val view = LocalView.current
|
||||
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
||||
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
||||
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
||||
@@ -1228,7 +1243,12 @@ fun SessionScreen(
|
||||
// the bench scripts keep working when this moves again. They pressed it at a hand-measured
|
||||
// coordinate until 2026-09-03, and anything that moved the header made that tap land on
|
||||
// whatever now sat there -- reporting a number that was never measured.
|
||||
val copyRenderReport = {
|
||||
// Shared by the ordinary "Copy" button and (bench build only) "Run benchmark": what differs
|
||||
// between them is only whether there is a [extra] section, built by BenchRun.run beforehand --
|
||||
// everything about assembling, copying and logging the report is exactly the same act either
|
||||
// way, and a second copy of it beside `onRunBenchmark` below would be the two silently
|
||||
// disagreeing about what "the report" contains the first time either one changed.
|
||||
fun buildAndCopyReport(extra: List<String> = emptyList()) {
|
||||
val report =
|
||||
debugReport(
|
||||
device =
|
||||
@@ -1250,6 +1270,11 @@ fun SessionScreen(
|
||||
accounting =
|
||||
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||
crash = lastCrash(context),
|
||||
extra = extra,
|
||||
// Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears
|
||||
// the
|
||||
// marks along with everything else, so an ordinary copy never has any to show.
|
||||
phaseFrames = FrameStats.phaseLines(context.refreshHz()),
|
||||
)
|
||||
context.copyToClipboard("ai-app render report", report)
|
||||
// Also to the log, so a session driving the app over adb can read the same report the
|
||||
@@ -1263,6 +1288,29 @@ fun SessionScreen(
|
||||
DebugStats.reset()
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val copyRenderReport = { buildAndCopyReport() }
|
||||
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
|
||||
// the fixture session opened below instead of a real server. Null everywhere else -- see
|
||||
// [SessionSettingsDialog]'s onRunBenchmark.
|
||||
val runBenchmark: (() -> Unit)? =
|
||||
if (BuildConfig.FIXTURE_MODE) {
|
||||
{
|
||||
settingsOpen = false
|
||||
scope.launch {
|
||||
val extra =
|
||||
BenchRun.run(
|
||||
context = context,
|
||||
scope = scope,
|
||||
listState = listState,
|
||||
flingBehavior = flingBehavior,
|
||||
composerFocus = composerFocus,
|
||||
setComposerText = { text -> input = atEnd(text) },
|
||||
view = view,
|
||||
)
|
||||
buildAndCopyReport(extra)
|
||||
}
|
||||
}
|
||||
} else null
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
@@ -1757,7 +1805,10 @@ fun SessionScreen(
|
||||
input = it
|
||||
saveDraft(context, summary.id, it.text)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
// BenchRun's type phase requests focus on this exact field
|
||||
// (`composerFocus`)
|
||||
// so it types through the real composer rather than a stand-in.
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
|
||||
// No longer "(+image)": the images are on screen above this, and a
|
||||
// placeholder saying so said it in words beside the thing itself.
|
||||
placeholder = { Text("Message") },
|
||||
@@ -1952,6 +2003,7 @@ fun SessionScreen(
|
||||
},
|
||||
onDismiss = { settingsOpen = false },
|
||||
onCopyRenderReport = copyRenderReport,
|
||||
onRunBenchmark = runBenchmark,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,13 @@ fun SessionSettingsDialog(
|
||||
* measures is that screen's own state.
|
||||
*/
|
||||
onCopyRenderReport: () -> Unit,
|
||||
/**
|
||||
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
|
||||
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
|
||||
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
|
||||
* control with nothing behind it on every other build is not a state worth drawing.
|
||||
*/
|
||||
onRunBenchmark: (() -> Unit)? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember(sessionId) { mutableStateOf(title) }
|
||||
@@ -508,6 +515,21 @@ fun SessionSettingsDialog(
|
||||
Text("Render timings", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||
}
|
||||
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
|
||||
// ui-trace and the emulator smoke run find it by that label, the same way every
|
||||
// other control here is found -- see AGENTS.md's "Driving the UI".
|
||||
onRunBenchmark?.let { run ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("P0 benchmark", modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = run) { Text("Run benchmark") }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Overridden by the `bench` build type's resValue (build.gradle.kts) to "AI Sessions bench",
|
||||
so the two are never mistaken for each other in the launcher or in Settings. -->
|
||||
<string name="app_name">AI Sessions</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,28 @@
|
||||
# The P0 benchmark fixture
|
||||
|
||||
`transcript.jsonl` is a synthetic transcript in the app's own event model (the JSON lines
|
||||
`GET /sessions/{id}/transcript` returns; see `Events.kt`'s `parseSeqEvent` and
|
||||
`server/src/session/driver.rs`) -- never a real one. It is what both the Compose `bench` build
|
||||
and iris's bench build open with no server, so the two apps draw exactly the same content and a
|
||||
frame-time comparison is measuring the renderer rather than the data.
|
||||
|
||||
Generated by `./generate.py` (Python stdlib only, seeded -- `SEED = 20260905` -- so re-running it
|
||||
reproduces the same file byte for byte). It writes into `assets/` -- a separate directory from this
|
||||
script and README, because the Compose `bench` build type points its own asset source set straight
|
||||
at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
|
||||
script and a markdown file have no business inside an APK:
|
||||
|
||||
- `transcript.jsonl` -- 3,601 events. The first 3,200 (`BACKLOG_COUNT`) are the scrolled-back
|
||||
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
|
||||
assistant replies built from headings, bold/italic/inline code, a link, fenced code blocks that
|
||||
rotate through rust/kotlin/python/sh/json/toml, a markdown table, two embedded images, and
|
||||
periodic `usageDelta`/`compacted` events. The remaining 400 (`STREAM_COUNT`) are not part of the
|
||||
opening window -- both bench harnesses replay them at a fixed rate (20/s) through the same live
|
||||
fold path a real SSE reply arrives on, which is P0's "streaming phase."
|
||||
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
|
||||
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
|
||||
`"type":"image"` events in the transcript.
|
||||
|
||||
Regenerate after changing the shape (a new event type, a different backlog/stream split) with
|
||||
`./generate.py`, and commit the result -- it is checked in rather than generated at build time so
|
||||
both apps' bench builds embed the identical bytes without needing this script at build time.
|
||||
|
After Width: | Height: | Size: 74 B |
|
After Width: | Height: | Size: 74 B |
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generates transcript.jsonl -- the synthetic fixture P0's benchmark opens in both apps.
|
||||
|
||||
Deterministic (fixed seed), so a Compose bench APK and an iris bench APK draw byte-identical
|
||||
content: the point of the fixture is a like-for-like comparison, not a realistic one.
|
||||
|
||||
Never a real transcript -- see AGENTS.md's ui-sandbox.sh, which this borrows its vocabulary
|
||||
style from (headings, code fences, a table, a link) rather than reusing its Claude-Code JSONL
|
||||
shape. This file's shape is the *app's own event model* instead: one JSON object per line,
|
||||
matching what GET /sessions/{id}/transcript returns and what Events.kt's parseSeqEvent reads
|
||||
(server/src/session/driver.rs is the source of truth for the field names).
|
||||
|
||||
./generate.py writes transcript.jsonl and bench1.png/bench2.png here
|
||||
|
||||
BACKLOG_COUNT events (seq 1..BACKLOG_COUNT) are the scrolled-back history the benchmark opens
|
||||
with. A further STREAM_COUNT events (seq BACKLOG_COUNT+1..) are not part of the opening window;
|
||||
both bench harnesses replay them at a fixed rate as the "streaming reply" phase, appended through
|
||||
the same live path a real SSE reply arrives on. Keeping both halves in one file means one
|
||||
generator and one seed to keep in sync, rather than two fixtures that can drift apart.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
SEED = 20260905
|
||||
BACKLOG_COUNT = 3200
|
||||
STREAM_COUNT = 400
|
||||
HERE = Path(__file__).resolve().parent / "assets"
|
||||
|
||||
random.seed(SEED)
|
||||
|
||||
LANGUAGES = ["rust", "kotlin", "python", "sh", "json", "toml"]
|
||||
|
||||
CODE_SNIPPETS = {
|
||||
"rust": '''fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||
// a comment worth keeping: this is the fold the app's own screen runs
|
||||
let mut out = items;
|
||||
out.push(Item::new(seq));
|
||||
out
|
||||
}''',
|
||||
"kotlin": '''fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> {
|
||||
// mirrors the server's own event model, one item per line
|
||||
return items + TranscriptItem.from(entry)
|
||||
}''',
|
||||
"python": '''def render_report(frames, cpu_ms, rss_kb):
|
||||
# printed for a human to paste back, so every number carries its unit
|
||||
return f"{frames} frames, {cpu_ms}ms cpu, {rss_kb}kb peak rss"''',
|
||||
"sh": '''#!/bin/sh
|
||||
# scripted scroll loop, the shape transcript-bench.sh drives on a phone
|
||||
for i in $(seq 1 24); do
|
||||
ui-trace record --do "swipe 540 700 540 1600 200"
|
||||
done''',
|
||||
"json": '{"seq": 1, "type": "status", "state": "running"}',
|
||||
"toml": '''[package]
|
||||
name = "bench-fixture"
|
||||
version = "0.1.0"''',
|
||||
}
|
||||
|
||||
HEADINGS = [
|
||||
"## Plan",
|
||||
"## What changed",
|
||||
"## Why this approach",
|
||||
"### Open questions",
|
||||
"## Results",
|
||||
]
|
||||
|
||||
WORDS = (
|
||||
"session render report frame budget scroll transcript fold event cache "
|
||||
"cursor probe stream backlog swipe fixture bench compose iris widget layout "
|
||||
"measure place draw tool call token context window anchor"
|
||||
).split()
|
||||
|
||||
|
||||
def paragraph(n=24):
|
||||
words = [random.choice(WORDS) for _ in range(n)]
|
||||
words[0] = words[0].capitalize()
|
||||
text = " ".join(words) + "."
|
||||
# Sprinkle markdown inline spans so the syntax highlighter/markdown parser sees a real mix.
|
||||
text = text.replace(" fold ", " **fold** ", 1)
|
||||
text = text.replace(" cursor ", " *cursor* ", 1)
|
||||
text = text.replace(" cache ", " `cache` ", 1)
|
||||
if "bench" in text:
|
||||
text = text.replace(
|
||||
" bench ", " [bench](https://example.com/bench) ", 1
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def make_png(rgb, size=8):
|
||||
"""A tiny, valid PNG -- flat colour, no external dependency."""
|
||||
|
||||
def chunk(tag, data):
|
||||
c = tag + data
|
||||
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c))
|
||||
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
|
||||
raw = b""
|
||||
for _ in range(size):
|
||||
raw += b"\x00" + bytes(rgb) * size
|
||||
idat = zlib.compress(raw)
|
||||
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def main():
|
||||
HERE.mkdir(exist_ok=True)
|
||||
lines = []
|
||||
seq = 1
|
||||
ts = 1_788_000_000.0
|
||||
|
||||
def emit(type_, **fields):
|
||||
nonlocal seq, ts
|
||||
obj = {"seq": seq, "ts": round(ts, 3), "type": type_}
|
||||
obj.update(fields)
|
||||
lines.append(json.dumps(obj, separators=(",", ":")))
|
||||
seq += 1
|
||||
ts += random.uniform(0.05, 2.0)
|
||||
|
||||
emit("status", state="running")
|
||||
emit("settings", model="bench-model", permissionMode="auto")
|
||||
|
||||
image_refs = []
|
||||
turn = 0
|
||||
while seq <= BACKLOG_COUNT:
|
||||
turn += 1
|
||||
emit("userMessage", text=f"Turn {turn}: {paragraph(12)}", id=None, attachments=[])
|
||||
|
||||
# A tool call with kilobyte-scale input/output every few turns.
|
||||
if turn % 3 == 0:
|
||||
tool_id = f"tool-{turn}"
|
||||
big_input = json.dumps({"path": f"/repo/file_{turn}.rs", "content": paragraph(400)})
|
||||
emit("toolStart", id=tool_id, tool="Edit", input=big_input)
|
||||
big_output = "\n".join(paragraph(60) for _ in range(20))
|
||||
emit("toolUpdate", id=tool_id, output=big_output[: len(big_output) // 2])
|
||||
emit("toolEnd", id=tool_id, output=big_output)
|
||||
|
||||
# A reply: a heading, prose, a fenced block in a rotating language, a table, then deltas.
|
||||
emit("assistantText", delta=f"{random.choice(HEADINGS)}\n\n")
|
||||
emit("assistantText", delta=paragraph(30) + "\n\n")
|
||||
lang = LANGUAGES[turn % len(LANGUAGES)]
|
||||
emit("assistantText", delta=f"```{lang}\n{CODE_SNIPPETS[lang]}\n```\n\n")
|
||||
if turn % 5 == 0:
|
||||
emit(
|
||||
"assistantText",
|
||||
delta="| column | value |\n|---|---|\n| a | " + paragraph(3) + " |\n\n",
|
||||
)
|
||||
# A run of small deltas -- the shape a live reply actually streams in.
|
||||
for _ in range(random.randint(3, 8)):
|
||||
emit("assistantText", delta=paragraph(6) + " ")
|
||||
|
||||
# A couple of images, base64 PNGs, the way a real transcript embeds a screenshot.
|
||||
if turn in (10, 40):
|
||||
ref = f"bench{len(image_refs) + 1}.png"
|
||||
image_refs.append(ref)
|
||||
emit("image", ref=ref, about=None)
|
||||
|
||||
emit("usageDelta", tokens=random.randint(200, 4000), context=random.randint(2000, 180000))
|
||||
|
||||
if turn % 15 == 0:
|
||||
emit(
|
||||
"compacted",
|
||||
preTokens=180000,
|
||||
postTokens=20000,
|
||||
trigger="auto",
|
||||
)
|
||||
|
||||
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a
|
||||
# bench harness replays at a fixed events/sec through the live fold path.
|
||||
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
||||
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
||||
emit("assistantText", delta=paragraph(5) + " ")
|
||||
emit("status", state="idle")
|
||||
|
||||
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
|
||||
|
||||
(HERE / "bench1.png").write_bytes(make_png((220, 90, 90)))
|
||||
(HERE / "bench2.png").write_bytes(make_png((90, 150, 220)))
|
||||
|
||||
print(f"wrote {len(lines)} events ({BACKLOG_COUNT} backlog + {STREAM_COUNT} stream) to transcript.jsonl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,6 +4,11 @@
|
||||
# ./build-apk.sh the release build, signed (what the phone runs)
|
||||
# ./build-apk.sh debug the debug build, for reproducing something the
|
||||
# emulator scripts would build anyway
|
||||
# ./build-apk.sh bench P0's benchmark build (own app id, "AI Sessions
|
||||
# bench" label, opens straight onto the fixture
|
||||
# session -- see docs/RUST.md's P0 box and
|
||||
# app/bench-fixture/README.md). Signed the same
|
||||
# as release; never touches the CA it pins.
|
||||
#
|
||||
# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as
|
||||
# build modes, one command line each; it passes nothing else, so the word
|
||||
@@ -25,8 +30,9 @@ VARIANT=${1:-release}
|
||||
case "$VARIANT" in
|
||||
release) TASK=assembleRelease ;;
|
||||
debug) TASK=assembleDebug ;;
|
||||
bench) TASK=assembleBench ;;
|
||||
*)
|
||||
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug)" >&2
|
||||
echo "build-apk.sh: unknown variant '$VARIANT' (release, debug, bench)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -81,7 +87,7 @@ fi
|
||||
# uninstalling it first: the signatures differ, and Android refuses to
|
||||
# update across them.
|
||||
KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}"
|
||||
if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
||||
if { [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; } && [ ! -f "$KEYSTORE" ]; then
|
||||
KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}"
|
||||
KEYTOOL="${KEYTOOL:-keytool}"
|
||||
if ! command -v "$KEYTOOL" >/dev/null 2>&1; then
|
||||
@@ -97,7 +103,7 @@ if [ "$VARIANT" = release ] && [ ! -f "$KEYSTORE" ]; then
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1)
|
||||
fi
|
||||
if [ "$VARIANT" = release ]; then
|
||||
if [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; then
|
||||
AI_APP_KEYSTORE="$KEYSTORE"
|
||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password")
|
||||
export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# RUST.md's I5 "Where iris's frame time goes" pass (2026-09-05). The same
|
||||
# 24-swipe/6-cycle loop as transcript-bench.sh's, extracted for iris's own
|
||||
# demo app -- transcript-bench.sh itself is Compose-specific (opens by
|
||||
# session title through the Compose app's own UI) and cannot be called
|
||||
# directly against dev.iris.android.demo.
|
||||
#
|
||||
# MUST be run from inside this checkout (not /tmp): ui-trace/adb pick which
|
||||
# emulator to target from the current directory's basename (the
|
||||
# per-checkout-AVD rule), and a previous pass lost two attempts to a `cd`
|
||||
# into /tmp that made this resolve to a nonexistent "tmp" checkout.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
. ./android-env.sh >/dev/null 2>&1
|
||||
|
||||
cycles=${1:-6}
|
||||
|
||||
ui-trace record -d 3000 --do "tap 'Reset frame report'" -o /tmp/iris-bench-reset.txt >/dev/null
|
||||
adb logcat -c
|
||||
|
||||
DO=""
|
||||
i=0
|
||||
while [ "$i" -lt "$cycles" ]; do
|
||||
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
|
||||
DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'"
|
||||
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
|
||||
DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'"
|
||||
i=$((i + 1))
|
||||
done
|
||||
eval ui-trace record -d $((cycles * 16000 + 20000)) $DO -o /tmp/iris-bench-scroll.txt >/dev/null
|
||||
|
||||
ui-trace record -d 3000 --do "tap 'Frame report'" -o /tmp/iris-bench-report.txt >/dev/null
|
||||
sleep 1
|
||||
adb logcat -d -s iris-android-app:I | grep "iris frame report:"
|
||||
@@ -17,6 +17,11 @@ dependencyResolutionManagement {
|
||||
|
||||
include(":androidApp")
|
||||
|
||||
// E3 (RUST.md): the Kotlin/Java shell over android-shell's JNI bridge, a
|
||||
// separate module from :androidApp so the ~13,000 lines of working Compose
|
||||
// UI there are untouched. See shellApp/build.gradle.kts's module comment.
|
||||
include(":shellApp")
|
||||
|
||||
// The app half of wg-app-link, resolved by path through the submodule so
|
||||
// this checkout and the crate it consumes move together -- the same
|
||||
// arrangement `server/` uses for the Rust half. See that repo's README.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
plugins { alias(libs.plugins.androidApplication) }
|
||||
|
||||
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
|
||||
// into Rust (`../../android-shell`). Deliberately its own module rather
|
||||
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
|
||||
// of working Compose UI this experiment does not touch, and the two can be
|
||||
// installed side by side on the same development device (see
|
||||
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
|
||||
// and Keystore alias are not the production app's). No Compose plugin, no
|
||||
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
|
||||
// Java, and the CA constant below is generated as Java too.
|
||||
//
|
||||
// The CA this build pins is baked in the same way `androidApp`'s does --
|
||||
// see that module's `build.gradle.kts` comment for the reasoning (the
|
||||
// trust boundary follows the machine that builds, never a pasted copy).
|
||||
// `PinnedCa.java`'s package must match `android-shell`'s
|
||||
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
|
||||
val pinnedCaPath: String =
|
||||
System.getenv("AI_APP_CA")
|
||||
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
||||
"/ai-app/certs/ca.pem"
|
||||
|
||||
abstract class GeneratePinnedCa : DefaultTask() {
|
||||
@get:Input abstract val caPath: Property<String>
|
||||
|
||||
@get:InputFile
|
||||
@get:Optional
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
abstract val caCertificate: RegularFileProperty
|
||||
|
||||
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val path = caPath.get()
|
||||
val ca = File(path)
|
||||
if (!ca.isFile) {
|
||||
throw GradleException(
|
||||
"No CA certificate at $path.\n" +
|
||||
"Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it " +
|
||||
"generates the CA this build pins.\n" +
|
||||
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
|
||||
)
|
||||
}
|
||||
val pem = ca.readText().trim()
|
||||
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
||||
throw GradleException("$path is not a PEM certificate.")
|
||||
}
|
||||
val dir = outputDir.get().dir("com/example/aiapp/shell").asFile
|
||||
dir.mkdirs()
|
||||
// Same reasoning as androidApp's generatePinnedCert: the text block
|
||||
// must start immediately after the opening `"""`, or
|
||||
// CertificateFactory stops recognising the "-----BEGIN" preamble.
|
||||
File(dir, "PinnedCa.java")
|
||||
.writeText(
|
||||
"""
|
||||
|// Generated from $path by the generatePinnedCa task. Do not edit.
|
||||
|package com.example.aiapp.shell;
|
||||
|
|
||||
|public final class PinnedCa {
|
||||
| private PinnedCa() {}
|
||||
| public static final String PINNED_CA_PEM = ""${'"'}
|
||||
|$pem""${'"'};
|
||||
|}
|
||||
|"""
|
||||
.trimMargin()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val generatePinnedCa =
|
||||
tasks.register<GeneratePinnedCa>("generatePinnedCa") {
|
||||
val ca = file(pinnedCaPath)
|
||||
caPath.set(pinnedCaPath)
|
||||
if (ca.isFile) {
|
||||
caCertificate.set(ca)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.aiapp.shell"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.aiapp.shell"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
// Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md)
|
||||
// signs its own, Gradle-free build with this same keystore, and the two can only
|
||||
// `adb install -r` over each other if they carry the same certificate.
|
||||
val keystore = System.getenv("AI_APP_KEYSTORE")
|
||||
signingConfigs {
|
||||
if (keystore != null) {
|
||||
create("release") {
|
||||
storeFile = file(keystore)
|
||||
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||
keyAlias = "ai-app"
|
||||
keyPassword = storePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
if (keystore != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
// E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does
|
||||
// not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a
|
||||
// packaging step, so this one task is the single place Gradle still runs in that pipeline. It
|
||||
// asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes
|
||||
// jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration,
|
||||
// which would hand back .aar files d8 cannot read directly.
|
||||
val artifactType = Attribute.of("artifactType", String::class.java)
|
||||
|
||||
tasks.register("printRuntimeClasspathJars") {
|
||||
description = "Writes the resolved release runtime classpath jars, one per line, for xtask."
|
||||
val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt")
|
||||
outputs.file(outputFile)
|
||||
val jars =
|
||||
configurations
|
||||
.getByName("releaseRuntimeClasspath")
|
||||
.incoming
|
||||
.artifactView { attributes.attribute(artifactType, "android-classes-jar") }
|
||||
.files
|
||||
// Captured as a plain FileCollection (not the ArtifactView itself, which the
|
||||
// configuration cache cannot serialize) so this task is still cacheable.
|
||||
inputs.files(jars)
|
||||
doLast {
|
||||
val file = outputFile.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
file.writeText(jars.joinToString("\n") { it.absolutePath })
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
|
||||
// android-shell's settings.rs calls into this Kotlin class directly
|
||||
// over JNI rather than re-sealing the token in Rust; see that file's
|
||||
// module doc.
|
||||
implementation(project(":link"))
|
||||
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
|
||||
// ServiceCompat -- android-shell's notify.rs calls these classes over
|
||||
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
|
||||
// the library that already has it, rather than being re-derived as a
|
||||
// set of Build.VERSION.SDK_INT branches in Rust.
|
||||
implementation(libs.androidx.core.ktx)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Mirrors androidApp's manifest (AGENTS.md: reuse it rather than
|
||||
re-deriving it) for the permissions and declarations E3 actually
|
||||
exercises. Not carried over: the QR scanner activity (this
|
||||
experiment enrolls via the aiappshell://enroll deep link directly,
|
||||
per AGENTS.md's ui-sandbox.sh banner) and the app icon warning
|
||||
suppression below, for the same reason androidApp's is there. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:label="AI Sessions (shell)"
|
||||
android:allowBackup="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
tools:ignore="MissingApplicationIcon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Enrollment: aiappshell://enroll?host=...&port=...&token=...,
|
||||
per AGENTS.md's ui-sandbox.sh banner (fed to this app with
|
||||
`adb shell am start -a android.intent.action.VIEW -d
|
||||
'aiappshell://enroll?...'`, or -n'd at this component
|
||||
directly if a second app also claims the aiapp scheme). -->
|
||||
<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="aiappshell" android:host="enroll" />
|
||||
</intent-filter>
|
||||
<!-- The share sheet - see android-shell's share.rs. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- specialUse, not dataSync, for the reason androidApp's manifest
|
||||
gives: a connection that has to keep listening overnight
|
||||
cannot accept dataSync's six-hour cap. -->
|
||||
<service
|
||||
android:name=".NotificationService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="E3 experiment: holds one connection to the sandbox server so a
|
||||
session that needs an answer can be reported while the app is closed." />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.example.aiapp.shell;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.widget.Toast;
|
||||
|
||||
/**
|
||||
* E3's floor, per RUST.md's "How much Java is unavoidable": a class the framework
|
||||
* constructs by name from the manifest, with its lifecycle methods handing straight to Rust
|
||||
* (android-shell's {@code share::handle_intent}). No Compose, no layout -- there is no screen to
|
||||
* draw yet (that is E4's job, on iris); {@link #toast} is this experiment's stand-in for showing
|
||||
* something happened.
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
static {
|
||||
System.loadLibrary("android_shell");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
NotificationService.sync(this);
|
||||
nativeHandleIntent(this, getIntent());
|
||||
}
|
||||
|
||||
// launchMode="singleTop": a notification tap or a share while this activity is already on
|
||||
// top lands here rather than in a second instance -- same reasoning as MainActivity.kt's.
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
nativeHandleIntent(this, intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from android-shell, sometimes from a background thread (a share's network call is
|
||||
* never made on the calling thread -- see share.rs). {@code Toast} itself is main-thread-only,
|
||||
* so this hops there with a {@link Handler} rather than assuming the caller already has.
|
||||
*/
|
||||
static void toast(Context context, String message) {
|
||||
new Handler(Looper.getMainLooper())
|
||||
.post(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show());
|
||||
}
|
||||
|
||||
private static native void nativeHandleIntent(Activity activity, Intent intent);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.aiapp.shell;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
|
||||
/**
|
||||
* E3's second unavoidable Java class (RUST.md): a foreground service constructed by the framework
|
||||
* from the manifest, existing only to hand its lifecycle to android-shell's {@code notify} module
|
||||
* -- the SSE follow loop, deciding what a notification says, and posting it are all Rust reached
|
||||
* through these three native calls. See {@code Notifications.kt}'s {@code NotificationService} for
|
||||
* the Kotlin original this mirrors.
|
||||
*/
|
||||
public class NotificationService extends Service {
|
||||
static {
|
||||
System.loadLibrary("android_shell");
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
return nativeOnStartCommand(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
nativeOnDestroy();
|
||||
}
|
||||
|
||||
/** Starts this service if there is a server to connect to, and stops it otherwise. */
|
||||
static void sync(Context context) {
|
||||
nativeSync(context);
|
||||
}
|
||||
|
||||
private static native void nativeSync(Context context);
|
||||
|
||||
private static native int nativeOnStartCommand(Service service);
|
||||
|
||||
private static native void nativeOnDestroy();
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-model"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cookie_store",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# The app's pure logic, held once instead of twice: the event model (shared
|
||||
# with `server/` via `event-model`), the REST + SSE clients for its HTTP
|
||||
# surface (see `server/src/routes.rs`'s module doc for the table), the
|
||||
# transcript fold and cache, the markdown block model, the syntax
|
||||
# highlighter and the ANSI parser. See `docs/CLIENT_CORE.md` for
|
||||
# what this holds today, what it does not yet, and how it corresponds to
|
||||
# the Kotlin it replaces.
|
||||
#
|
||||
# No UI framework dependency of any kind -- this crate is meant to outlive
|
||||
# whichever one the app ends up drawing with (see RUST.md).
|
||||
|
||||
[dependencies]
|
||||
event-model = { path = "../event-model" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# "raw_value" is `fetch_transcript_lines`'s reason -- it needs the exact
|
||||
# bytes the server sent, not this crate's own re-serialization of a parsed
|
||||
# `Value`, so a cached line and a live SSE frame for the same event agree
|
||||
# byte-for-byte (see that method's doc). "float_roundtrip" is why they
|
||||
# agree on a `ts` at all -- see server/Cargo.toml's identical comment.
|
||||
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
||||
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
||||
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
||||
# TLS, so this reuses that choice rather than pulling in reqwest's async
|
||||
# stack -- a client that runs one blocking request at a time, the way
|
||||
# Api.kt's `HttpURLConnection` calls and Sse.kt's blocking read loop do, has
|
||||
# no need of an async runtime, and RUST.md's brief for this port is
|
||||
# "lightweight" throughout.
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
# The markdown block split (`markdown_blocks`), which has to agree with the
|
||||
# renderer in `iris/transcript-ui` about where a block begins -- so it is
|
||||
# the same parser at the same version, rather than a hand-written splitter
|
||||
# that would drift from it.
|
||||
pulldown-cmark = "0.13.4"
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,534 @@
|
||||
//! What a tool printed, with its terminal styling applied and everything
|
||||
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
|
||||
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
|
||||
//! here, so a [`StyledText`] of plain text plus non-overlapping
|
||||
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
|
||||
//! [`Style`] onto whatever it draws with.
|
||||
//!
|
||||
//! Bash output arrives exactly as the program wrote it, escape sequences
|
||||
//! included, and drawn verbatim those are line noise in the middle of the
|
||||
//! thing being read. Stripping them all would be the other half-answer --
|
||||
//! colour is often the whole of what a diff or a test run is saying.
|
||||
//!
|
||||
//! So the sequences that decide how text *looks* become spans, and every
|
||||
//! other one is dropped rather than shown: the rest move a cursor around a
|
||||
//! grid this is not, and "go to column 40" has no meaning in a scrolling
|
||||
//! document.
|
||||
//!
|
||||
//! A carriage return is honoured the way a terminal honours it: what was
|
||||
//! written since the last line break is thrown away and the line starts
|
||||
//! again. That is what makes a progress bar show its final state rather
|
||||
//! than every state it passed through.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||
/// because the one place that needs partial transparency (dimming) says so
|
||||
/// with a separate flag rather than baking it into the colour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rgb {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
}
|
||||
|
||||
/// The sixteen colours a terminal program names, and the two it assumes.
|
||||
///
|
||||
/// Its own palette rather than the syntax one: a program that prints in red
|
||||
/// has chosen red, where a highlighter's colours are this app's reading of
|
||||
/// somebody else's code.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnsiPalette {
|
||||
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
||||
pub colours: [Rgb; 16],
|
||||
/// What uncoloured text is, needed only where a style has to state a colour.
|
||||
pub foreground: Rgb,
|
||||
/// What the text sits on, needed for reverse video.
|
||||
pub background: Rgb,
|
||||
}
|
||||
|
||||
/// One span's worth of styling. `None` fields mean "unspecified", the same
|
||||
/// meaning `Color.Unspecified` and a null `FontWeight` carried in the Kotlin.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Style {
|
||||
pub color: Option<Rgb>,
|
||||
/// How much of `color`'s alpha survives, 0.0-1.0; `None` is opaque.
|
||||
pub alpha: Option<f32>,
|
||||
pub background: Option<Rgb>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub strikethrough: bool,
|
||||
}
|
||||
|
||||
/// Plain text plus the non-overlapping, ordered spans that style parts of it
|
||||
/// -- this crate's stand-in for Compose's `AnnotatedString`.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct StyledText {
|
||||
pub text: String,
|
||||
pub spans: Vec<(Range<usize>, Style)>,
|
||||
}
|
||||
|
||||
impl StyledText {
|
||||
fn plain(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
spans: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ESC: char = '\u{1B}';
|
||||
const BELL: char = '\u{7}';
|
||||
|
||||
/// [text] with its terminal styling applied and everything else taken out;
|
||||
/// see the module doc.
|
||||
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
// The common case by a long way -- nothing to do, and nothing allocated
|
||||
// to find that out.
|
||||
if !text.contains(ESC) && !text.contains('\r') {
|
||||
return StyledText::plain(text.to_string());
|
||||
}
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut runs: Vec<(String, Option<Style>)> = Vec::new();
|
||||
let mut sgr = Sgr::PLAIN;
|
||||
let mut at = 0usize;
|
||||
let mut plain = String::new();
|
||||
|
||||
let flush = |plain: &mut String, sgr: Sgr, runs: &mut Vec<(String, Option<Style>)>| {
|
||||
if !plain.is_empty() {
|
||||
runs.push((std::mem::take(plain), sgr.span(palette)));
|
||||
}
|
||||
};
|
||||
|
||||
while at < chars.len() {
|
||||
let c = chars[at];
|
||||
if c == ESC {
|
||||
flush(&mut plain, sgr, &mut runs);
|
||||
at = skip_escape(&chars, at, |params, final_byte| {
|
||||
if final_byte == 'm' {
|
||||
sgr = sgr.apply(params, palette);
|
||||
}
|
||||
});
|
||||
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
||||
// A bare carriage return rewrites the line. One before a newline
|
||||
// is the other half of a Windows line ending: it rewrites
|
||||
// nothing, and it is dropped rather than kept, since that pair
|
||||
// is one line break.
|
||||
flush(&mut plain, sgr, &mut runs);
|
||||
drop_line(&mut runs);
|
||||
at += 1;
|
||||
} else if c == '\r' {
|
||||
at += 1;
|
||||
} else if c >= ' ' || c == '\n' || c == '\t' {
|
||||
// Everything printable, plus the two control characters that are
|
||||
// layout rather than terminal commands. A stray bell or
|
||||
// backspace goes for the same reason a cursor move does.
|
||||
plain.push(c);
|
||||
at += 1;
|
||||
} else {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
flush(&mut plain, sgr, &mut runs);
|
||||
|
||||
let mut out = String::new();
|
||||
let mut spans = Vec::new();
|
||||
for (run_text, style) in runs {
|
||||
let start = out.len();
|
||||
out.push_str(&run_text);
|
||||
if let Some(style) = style {
|
||||
spans.push((start..out.len(), style));
|
||||
}
|
||||
}
|
||||
StyledText { text: out, spans }
|
||||
}
|
||||
|
||||
/// Throws away everything written since the last line break, as a carriage
|
||||
/// return does.
|
||||
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||
while let Some((text, style)) = runs.pop() {
|
||||
if let Some(break_at) = text.rfind('\n') {
|
||||
runs.push((text[..=break_at].to_string(), style));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes that end a CSI sequence.
|
||||
fn is_csi_final(c: char) -> bool {
|
||||
('@'..='~').contains(&c)
|
||||
}
|
||||
|
||||
/// Steps over the escape sequence starting at `at`, reporting a CSI's
|
||||
/// parameters and final byte. One reader for every kind, because the point
|
||||
/// is to *leave* them all behind: a sequence this did not recognise would
|
||||
/// otherwise have its body printed as ordinary text. Three shapes -- the CSI
|
||||
/// (`ESC [ ... letter`), the string escapes which run to a terminator, and
|
||||
/// the two-character ones.
|
||||
fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) -> usize {
|
||||
let Some(&next) = chars.get(at + 1) else {
|
||||
return at + 1;
|
||||
};
|
||||
match next {
|
||||
'[' => {
|
||||
let mut end = at + 2;
|
||||
while end < chars.len() && !is_csi_final(chars[end]) {
|
||||
end += 1;
|
||||
}
|
||||
if end >= chars.len() {
|
||||
// Cut off mid-sequence, which is what a stream that has not
|
||||
// finished arriving looks like: drop the fragment rather
|
||||
// than printing it, and the whole sequence arrives with the
|
||||
// next delta.
|
||||
chars.len()
|
||||
} else {
|
||||
let params: String = chars[at + 2..end].iter().collect();
|
||||
on_csi(¶ms, chars[end]);
|
||||
end + 1
|
||||
}
|
||||
}
|
||||
']' | 'P' | 'X' | '^' | '_' => {
|
||||
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
||||
// allows after an OSC.
|
||||
let mut end = at + 2;
|
||||
while end < chars.len() {
|
||||
if chars[end] == BELL {
|
||||
return end + 1;
|
||||
}
|
||||
if chars[end] == ESC && chars.get(end + 1) == Some(&'\\') {
|
||||
return end + 2;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
chars.len()
|
||||
}
|
||||
_ => at + 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Sgr {
|
||||
fg: Option<Rgb>,
|
||||
bg: Option<Rgb>,
|
||||
bold: bool,
|
||||
dim: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strike: bool,
|
||||
reverse: bool,
|
||||
}
|
||||
|
||||
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
||||
const DIM_ALPHA: f32 = 0.65;
|
||||
|
||||
impl Sgr {
|
||||
const PLAIN: Sgr = Sgr {
|
||||
fg: None,
|
||||
bg: None,
|
||||
bold: false,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strike: false,
|
||||
reverse: false,
|
||||
};
|
||||
|
||||
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
||||
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||
if *self == Sgr::PLAIN {
|
||||
return None;
|
||||
}
|
||||
let front = if self.reverse {
|
||||
Some(self.bg.unwrap_or(palette.background))
|
||||
} else {
|
||||
self.fg
|
||||
};
|
||||
let back = if self.reverse {
|
||||
Some(self.fg.unwrap_or(palette.foreground))
|
||||
} else {
|
||||
self.bg
|
||||
};
|
||||
// Dim has to have a colour to dim, so where none was named it dims
|
||||
// the ordinary one.
|
||||
let stated = front.or(if self.dim {
|
||||
Some(palette.foreground)
|
||||
} else {
|
||||
None
|
||||
});
|
||||
Some(Style {
|
||||
color: stated,
|
||||
alpha: if self.dim { Some(DIM_ALPHA) } else { None },
|
||||
background: back,
|
||||
bold: self.bold,
|
||||
italic: self.italic,
|
||||
underline: self.underline,
|
||||
strikethrough: self.strike,
|
||||
})
|
||||
}
|
||||
|
||||
/// This state with `params` applied -- one `ESC[...m`, which carries any
|
||||
/// number of them.
|
||||
///
|
||||
/// A code this does not model is ignored rather than reset from: the
|
||||
/// program meant something by it, and starting again would also drop
|
||||
/// the codes beside it that are understood.
|
||||
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
|
||||
// zero too.
|
||||
let codes: Vec<i64> = params
|
||||
.split(';')
|
||||
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
||||
.collect();
|
||||
let mut state = *self;
|
||||
let mut at = 0usize;
|
||||
while at < codes.len() {
|
||||
let code = codes[at];
|
||||
state = match code {
|
||||
0 => Sgr::PLAIN,
|
||||
1 => Sgr {
|
||||
bold: true,
|
||||
..state
|
||||
},
|
||||
2 => Sgr { dim: true, ..state },
|
||||
3 => Sgr {
|
||||
italic: true,
|
||||
..state
|
||||
},
|
||||
4 => Sgr {
|
||||
underline: true,
|
||||
..state
|
||||
},
|
||||
7 => Sgr {
|
||||
reverse: true,
|
||||
..state
|
||||
},
|
||||
9 => Sgr {
|
||||
strike: true,
|
||||
..state
|
||||
},
|
||||
21 | 22 => Sgr {
|
||||
bold: false,
|
||||
dim: false,
|
||||
..state
|
||||
},
|
||||
23 => Sgr {
|
||||
italic: false,
|
||||
..state
|
||||
},
|
||||
24 => Sgr {
|
||||
underline: false,
|
||||
..state
|
||||
},
|
||||
27 => Sgr {
|
||||
reverse: false,
|
||||
..state
|
||||
},
|
||||
29 => Sgr {
|
||||
strike: false,
|
||||
..state
|
||||
},
|
||||
30..=37 => Sgr {
|
||||
fg: Some(palette.colours[(code - 30) as usize]),
|
||||
..state
|
||||
},
|
||||
90..=97 => Sgr {
|
||||
fg: Some(palette.colours[(code - 90 + 8) as usize]),
|
||||
..state
|
||||
},
|
||||
40..=47 => Sgr {
|
||||
bg: Some(palette.colours[(code - 40) as usize]),
|
||||
..state
|
||||
},
|
||||
100..=107 => Sgr {
|
||||
bg: Some(palette.colours[(code - 100 + 8) as usize]),
|
||||
..state
|
||||
},
|
||||
39 => Sgr { fg: None, ..state },
|
||||
49 => Sgr { bg: None, ..state },
|
||||
38 | 48 => {
|
||||
let (colour, last) = extended_colour(&codes, at, palette);
|
||||
at = last;
|
||||
if code == 38 {
|
||||
Sgr {
|
||||
fg: colour,
|
||||
..state
|
||||
}
|
||||
} else {
|
||||
Sgr {
|
||||
bg: colour,
|
||||
..state
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => state,
|
||||
};
|
||||
at += 1;
|
||||
}
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
|
||||
/// last parameter.
|
||||
///
|
||||
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
|
||||
/// one. The first sixteen of that table are the palette's own, so a program
|
||||
/// asking for "colour 1" through either spelling gets the same red.
|
||||
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
||||
match codes.get(at + 1) {
|
||||
Some(&5) => match codes.get(at + 2) {
|
||||
None => (None, at + 1),
|
||||
Some(&n) => (Some(indexed_colour(n, palette)), at + 2),
|
||||
},
|
||||
Some(&2) => {
|
||||
let r = codes.get(at + 2);
|
||||
let g = codes.get(at + 3);
|
||||
let b = codes.get(at + 4);
|
||||
match (r, g, b) {
|
||||
(Some(&r), Some(&g), Some(&b)) => (
|
||||
Some(Rgb::new(
|
||||
r.clamp(0, 255) as u8,
|
||||
g.clamp(0, 255) as u8,
|
||||
b.clamp(0, 255) as u8,
|
||||
)),
|
||||
at + 4,
|
||||
),
|
||||
_ => (None, at + 1),
|
||||
}
|
||||
}
|
||||
_ => (None, at + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
|
||||
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||
|
||||
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
|
||||
/// grey ramp.
|
||||
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||
if n < 0 {
|
||||
palette.foreground
|
||||
} else if n < 16 {
|
||||
palette.colours[n as usize]
|
||||
} else if n < 232 {
|
||||
let i = (n - 16) as usize;
|
||||
Rgb::new(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
||||
} else if n < 256 {
|
||||
let grey = (8 + (n - 232) * 10) as u8;
|
||||
Rgb::new(grey, grey, grey)
|
||||
} else {
|
||||
palette.foreground
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
||||
/// white foreground, black background.
|
||||
fn palette() -> AnsiPalette {
|
||||
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||
for (i, c) in colours.iter_mut().enumerate() {
|
||||
*c = Rgb::new(i as u8, 0, 0);
|
||||
}
|
||||
AnsiPalette {
|
||||
colours,
|
||||
foreground: Rgb::new(255, 255, 255),
|
||||
background: Rgb::new(0, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn styled(text: &str) -> StyledText {
|
||||
ansi_styled(text, &palette())
|
||||
}
|
||||
|
||||
/// The style covering the first character of `word`, or `None` where
|
||||
/// nothing styles it.
|
||||
fn style_over(text: &str, word: &str) -> Option<Style> {
|
||||
let out = styled(text);
|
||||
let at = out
|
||||
.text
|
||||
.find(word)
|
||||
.unwrap_or_else(|| panic!("no {word:?} in {}", out.text));
|
||||
out.spans
|
||||
.iter()
|
||||
.find(|(range, _)| range.contains(&at))
|
||||
.map(|(_, style)| *style)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_becomes_a_span_and_the_sequence_itself_disappears() {
|
||||
let text = format!("plain {ESC}[31mred{ESC}[0m plain");
|
||||
assert_eq!(styled(&text).text, "plain red plain");
|
||||
assert_eq!(
|
||||
style_over(&text, "red").unwrap().color,
|
||||
Some(Rgb::new(1, 0, 0))
|
||||
);
|
||||
assert!(style_over(&text, "plain").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bright_background_and_256_colour_forms_all_reach_the_same_table() {
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[91mx"), "x").unwrap().color,
|
||||
Some(Rgb::new(9, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[44mx"), "x").unwrap().background,
|
||||
Some(Rgb::new(4, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[38;5;1mx"), "x").unwrap().color,
|
||||
Some(Rgb::new(1, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[38;5;16mx"), "x").unwrap().color,
|
||||
Some(Rgb::new(0, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[38;5;231mx"), "x").unwrap().color,
|
||||
Some(Rgb::new(255, 255, 255))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[38;2;10;20;30mx"), "x")
|
||||
.unwrap()
|
||||
.color,
|
||||
Some(Rgb::new(10, 20, 30))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
||||
// A cursor move, an erase, an OSC window title with its bell, and a
|
||||
// bare two-character escape.
|
||||
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
||||
assert_eq!(styled(&text).text, "abcde");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_carriage_return_rewrites_its_line_as_it_does_on_a_terminal() {
|
||||
assert_eq!(styled("10%\r50%\rdone\n").text, "done\n");
|
||||
assert_eq!(styled("kept\r\nfirst\rlast").text, "kept\nlast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_cut_off_mid_stream_takes_no_text_with_it() {
|
||||
assert_eq!(styled(&format!("text {ESC}[3")).text, "text ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstyled_text_costs_no_spans_at_all() {
|
||||
assert_eq!(styled("nothing to do here").spans.len(), 0);
|
||||
assert_eq!(styled(&format!("a{ESC}[2Jb")).spans.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
|
||||
//! module doc for the table); the SSE half is [`crate::event_stream`].
|
||||
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
|
||||
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
|
||||
//! which do not.
|
||||
//!
|
||||
//! Network I/O sits behind the [`Transport`] trait so the rest of this
|
||||
//! crate, and anything built on it, can be tested against a fake one with
|
||||
//! no server involved. [`UreqTransport`] is the only real implementation.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use event_model::SeqEvent;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A request that did not produce what it asked for, carrying the server's
|
||||
/// own wording where it sent some.
|
||||
///
|
||||
/// `status` is the HTTP status where there was a response at all, and
|
||||
/// `None` where the server was never reached -- mirroring `ApiException` in
|
||||
/// `Api.kt`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiError {
|
||||
pub message: String,
|
||||
pub status: Option<u16>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
/// A request body to send, in whichever of the two shapes the surface
|
||||
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
|
||||
pub enum Body {
|
||||
Json(Value),
|
||||
Bytes {
|
||||
content_type: String,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a transport hands back for a REST call: the status and the body
|
||||
/// read whole. A streamed body ([`Transport::stream`]) is a different
|
||||
/// method because its whole point is not reading it whole.
|
||||
pub struct RawResponse {
|
||||
pub status: u16,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The network boundary this crate's pure logic is kept out from behind.
|
||||
/// `server/src/routes.rs`'s module doc is the surface this drives.
|
||||
pub trait Transport: Send + Sync {
|
||||
/// One request/response call -- everything but the long-lived SSE GETs.
|
||||
fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError>;
|
||||
|
||||
/// Opens `path` and answers a reader over the response body, for a
|
||||
/// caller that reads it as a stream rather than all at once (the SSE
|
||||
/// connections in [`crate::event_stream`]). Fails the same way
|
||||
/// [`Transport::request`] does for a non-2xx response.
|
||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
||||
}
|
||||
|
||||
/// One session as `GET /sessions` and `GET /sessions/{id}` report it.
|
||||
/// Mirrors `Api.kt`'s `SessionSummary`; see that type's doc for what each
|
||||
/// field means and why `setup` is never shown.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionSummary {
|
||||
pub id: String,
|
||||
pub setup: String,
|
||||
#[serde(default)]
|
||||
pub keeps_own_transcript: bool,
|
||||
pub setup_name: String,
|
||||
pub provider: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub permission_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub imported: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub notify: bool,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
pub context_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_image_edge: Option<u32>,
|
||||
pub status: String,
|
||||
pub last_activity: f64,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// A client-core equivalent of `requestFromServer` plus the typed calls
|
||||
/// built on it. Holds no state of its own beyond the transport -- the
|
||||
/// session id or setup id a call is about is a parameter, per this
|
||||
/// project's "ask for the least you need".
|
||||
pub struct ApiClient<T: Transport> {
|
||||
transport: T,
|
||||
}
|
||||
|
||||
impl<T: Transport> ApiClient<T> {
|
||||
pub fn new(transport: T) -> Self {
|
||||
Self { transport }
|
||||
}
|
||||
|
||||
/// The transport underneath, for a caller that needs the raw SSE
|
||||
/// stream (`event_stream::follow_session_events`) rather than one of
|
||||
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
|
||||
/// is the one that does.
|
||||
pub fn transport(&self) -> &T {
|
||||
&self.transport
|
||||
}
|
||||
|
||||
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> Result<R, ApiError> {
|
||||
let raw = self.transport.request(method, path, body.map(Body::Json))?;
|
||||
serde_json::from_slice(&raw.body).map_err(|e| ApiError {
|
||||
message: format!("Reached the server but couldn't read its response ({e})"),
|
||||
status: Some(raw.status),
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_request(&self, method: &str, path: &str, body: Option<Value>) -> Result<(), ApiError> {
|
||||
self.transport.request(method, path, body.map(Body::Json))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn fetch_sessions(&self) -> Result<Vec<SessionSummary>, ApiError> {
|
||||
self.json_request("GET", "/sessions", None)
|
||||
}
|
||||
|
||||
pub fn fetch_session(&self, session_id: &str) -> Result<SessionSummary, ApiError> {
|
||||
self.json_request("GET", &format!("/sessions/{session_id}"), None)
|
||||
}
|
||||
|
||||
pub fn send_message(
|
||||
&self,
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
attachment_ids: &[String],
|
||||
) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/message"),
|
||||
Some(serde_json::json!({ "text": text, "attachmentIds": attachment_ids })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn unqueue_message(&self, session_id: &str, message_id: &str) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/unqueue"),
|
||||
Some(serde_json::json!({ "messageId": message_id })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn answer_question(
|
||||
&self,
|
||||
session_id: &str,
|
||||
question_id: &str,
|
||||
answers: &[String],
|
||||
) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/answer"),
|
||||
Some(serde_json::json!({ "questionId": question_id, "answers": answers })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn interrupt_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||
self.empty_request("POST", &format!("/sessions/{session_id}/interrupt"), None)
|
||||
}
|
||||
|
||||
pub fn stop_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||
self.empty_request("POST", &format!("/sessions/{session_id}/stop"), None)
|
||||
}
|
||||
|
||||
pub fn start_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||
self.empty_request("POST", &format!("/sessions/{session_id}/start"), None)
|
||||
}
|
||||
|
||||
pub fn rename_session(&self, session_id: &str, title: &str) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/title"),
|
||||
Some(serde_json::json!({ "title": title })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_session_cwd(&self, session_id: &str, cwd: &str) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/cwd"),
|
||||
Some(serde_json::json!({ "cwd": cwd })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_session_model(&self, session_id: &str, model: &str) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/model"),
|
||||
Some(serde_json::json!({ "model": model })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_session_permission_mode(
|
||||
&self,
|
||||
session_id: &str,
|
||||
mode: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/permission-mode"),
|
||||
Some(serde_json::json!({ "permissionMode": mode })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_session_notify(&self, session_id: &str, notify: bool) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/notify"),
|
||||
Some(serde_json::json!({ "notify": notify })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_command(&self, session_id: &str, text: &str) -> Result<(), ApiError> {
|
||||
self.empty_request(
|
||||
"POST",
|
||||
&format!("/sessions/{session_id}/command"),
|
||||
Some(serde_json::json!({ "text": text })),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compact_session(&self, session_id: &str) -> Result<(), ApiError> {
|
||||
self.empty_request("POST", &format!("/sessions/{session_id}/compact"), None)
|
||||
}
|
||||
|
||||
pub fn delete_session(&self, session_id: &str, delete_foreign: bool) -> Result<(), ApiError> {
|
||||
let path = if delete_foreign {
|
||||
format!("/sessions/{session_id}?deleteForeign=true")
|
||||
} else {
|
||||
format!("/sessions/{session_id}")
|
||||
};
|
||||
self.empty_request("DELETE", &path, None)
|
||||
}
|
||||
|
||||
/// A page of transcript history. `before` is the newest-first cursor
|
||||
/// (server default is "the newest page" when absent, which a caller
|
||||
/// gets by passing `None`); the events themselves are handed back as
|
||||
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
|
||||
/// out of this method's signature so a caller that only wants the raw
|
||||
/// lines (for the transcript cache) is not forced to parse them.
|
||||
pub fn fetch_transcript_page(
|
||||
&self,
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
) -> Result<Vec<Value>, ApiError> {
|
||||
self.json_request(
|
||||
"GET",
|
||||
&transcript_path(session_id, before, limit, coalesce, None),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// A page of transcript history, each line handed back paired with the
|
||||
/// exact text it came from, and bounded below by `after` -- the shape
|
||||
/// `crate::transcript_source::TranscriptSource` needs to store what it
|
||||
/// fetched in the transcript cache without a second round trip to fetch
|
||||
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
|
||||
///
|
||||
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
|
||||
/// parsed [`Value`], so the stored line is the exact bytes the server
|
||||
/// sent (key order and float literal included) rather than this
|
||||
/// crate's own idea of how to write them back out -- the cache and a
|
||||
/// live SSE frame must agree byte-for-byte on the same event, which is
|
||||
/// exactly what caught the `serde_json` float-rounding bug this
|
||||
/// project's `AGENTS.md` records.
|
||||
pub fn fetch_transcript_lines(
|
||||
&self,
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
|
||||
let path = transcript_path(session_id, before, limit, coalesce, after);
|
||||
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
|
||||
raw.into_iter()
|
||||
.map(|value| {
|
||||
let line = value.get().to_string();
|
||||
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
|
||||
message: format!(
|
||||
"the server sent a transcript line this build couldn't parse: {e}"
|
||||
),
|
||||
status: None,
|
||||
})?;
|
||||
Ok((line, event))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
|
||||
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
|
||||
/// parameter is written rather than keeping two copies to drift.
|
||||
fn transcript_path(
|
||||
session_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: u32,
|
||||
coalesce: bool,
|
||||
after: Option<u64>,
|
||||
) -> String {
|
||||
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
||||
if let Some(before) = before {
|
||||
path.push_str(&format!("&before={before}"));
|
||||
}
|
||||
if coalesce {
|
||||
path.push_str("&coalesce=true");
|
||||
}
|
||||
if let Some(after) = after {
|
||||
path.push_str(&format!("&after={after}"));
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
||||
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
||||
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
||||
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
||||
/// store -- the server's certificate is self-signed on purpose (see
|
||||
/// `wg-app-link`).
|
||||
pub struct UreqTransport {
|
||||
agent: ureq::Agent,
|
||||
base_url: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl UreqTransport {
|
||||
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
|
||||
/// exactly as read from `certs/ca.pem`.
|
||||
pub fn new(
|
||||
base_url: impl Into<String>,
|
||||
token: impl Into<String>,
|
||||
ca_pem: &[u8],
|
||||
) -> Result<Self, ApiError> {
|
||||
let cert = ureq::tls::Certificate::from_pem(ca_pem).map_err(|e| ApiError {
|
||||
message: format!("The pinned CA certificate could not be read: {e}"),
|
||||
status: None,
|
||||
})?;
|
||||
let tls_config = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::new_with_certs(&[cert]))
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.tls_config(tls_config)
|
||||
// Read the body ourselves on every status, the way
|
||||
// `requestFromServer` does: the server's own error wording is
|
||||
// in the body of a 4xx/5xx, and the default behaviour throws
|
||||
// it away before this code can read it.
|
||||
.http_status_as_error(false)
|
||||
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||
.build()
|
||||
.into();
|
||||
Ok(Self {
|
||||
agent,
|
||||
base_url: base_url.into(),
|
||||
token: token.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.base_url, path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for UreqTransport {
|
||||
fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError> {
|
||||
let url = self.url(path);
|
||||
let auth = format!("Bearer {}", self.token);
|
||||
let mut builder = ureq::http::Request::builder()
|
||||
.method(method)
|
||||
.uri(&url)
|
||||
.header("Authorization", &auth);
|
||||
let response = match body {
|
||||
None => builder
|
||||
.body(())
|
||||
.map_err(ureq::Error::from)
|
||||
.and_then(|req| self.agent.run(req)),
|
||||
Some(Body::Json(value)) => {
|
||||
builder = builder.header("Content-Type", "application/json");
|
||||
builder
|
||||
.body(serde_json::to_vec(&value).unwrap_or_default())
|
||||
.map_err(ureq::Error::from)
|
||||
.and_then(|req| self.agent.run(req))
|
||||
}
|
||||
Some(Body::Bytes {
|
||||
content_type,
|
||||
bytes,
|
||||
}) => {
|
||||
builder = builder.header("Content-Type", content_type);
|
||||
builder
|
||||
.body(bytes)
|
||||
.map_err(ureq::Error::from)
|
||||
.and_then(|req| self.agent.run(req))
|
||||
}
|
||||
};
|
||||
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
|
||||
let status = response.status().as_u16();
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
.body_mut()
|
||||
.as_reader()
|
||||
.read_to_end(&mut body)
|
||||
.map_err(|e| ApiError {
|
||||
message: format!("Reached {url} but couldn't read its response ({e})"),
|
||||
status: Some(status),
|
||||
})?;
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(response_error(status, &body, path));
|
||||
}
|
||||
Ok(RawResponse { status, body })
|
||||
}
|
||||
|
||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||
let url = self.url(path);
|
||||
let auth = format!("Bearer {}", self.token);
|
||||
let response = self
|
||||
.agent
|
||||
.get(&url)
|
||||
.header("Authorization", &auth)
|
||||
.header("Accept", "text/event-stream")
|
||||
// No read timeout: between events there is nothing to read for
|
||||
// as long as the thing being followed is idle, mirroring
|
||||
// `EventStream.kt`'s `readTimeout = 0`.
|
||||
.config()
|
||||
.timeout_recv_response(None)
|
||||
.build()
|
||||
.call();
|
||||
let mut response = response.map_err(|e| transport_error(&self.base_url, path, e))?;
|
||||
let status = response.status().as_u16();
|
||||
if status != 200 {
|
||||
let mut body = Vec::new();
|
||||
let _ = response.body_mut().as_reader().read_to_end(&mut body);
|
||||
return Err(response_error(status, &body, path));
|
||||
}
|
||||
Ok(Box::new(response.into_body().into_reader()))
|
||||
}
|
||||
}
|
||||
|
||||
fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
||||
ApiError {
|
||||
message: format!(
|
||||
"Couldn't reach the server at {base_url} ({e}) -- is ai-server running, and is this \
|
||||
device able to reach that address (WireGuard up)? [{path}]"
|
||||
),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 401 wording matches `Api.kt`'s, since that message is instructions
|
||||
/// for the reader rather than a diagnostic -- see this project's UI rule
|
||||
/// about shortening a failure in one place rather than at each display site.
|
||||
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
||||
let detail = String::from_utf8_lossy(body).trim().to_string();
|
||||
let message = if status == 401 {
|
||||
"The server rejected this device's token. Re-enroll by scanning the server's QR (or \
|
||||
rotate with --rotate-token and scan the new one)."
|
||||
.to_string()
|
||||
} else if detail.is_empty() {
|
||||
format!("Server returned HTTP {status} for {path}")
|
||||
} else {
|
||||
detail
|
||||
};
|
||||
ApiError {
|
||||
message,
|
||||
status: Some(status),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A transport with no network at all, for the pure-logic tests this
|
||||
/// module can run without a server.
|
||||
#[derive(Default)]
|
||||
struct FakeTransport {
|
||||
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
||||
}
|
||||
|
||||
impl FakeTransport {
|
||||
fn respond(&self, method: &str, path: &str, status: u16, body: &str) {
|
||||
self.responses.lock().unwrap().push((
|
||||
method.to_string(),
|
||||
path.to_string(),
|
||||
RawResponse {
|
||||
status,
|
||||
body: body.as_bytes().to_vec(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for FakeTransport {
|
||||
fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
_body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError> {
|
||||
let mut responses = self.responses.lock().unwrap();
|
||||
let index = responses
|
||||
.iter()
|
||||
.position(|(m, p, _)| m == method && p == path)
|
||||
.ok_or_else(|| ApiError {
|
||||
message: format!("no fake response for {method} {path}"),
|
||||
status: None,
|
||||
})?;
|
||||
let (_, _, response) = responses.remove(index);
|
||||
if !(200..300).contains(&response.status) {
|
||||
return Err(response_error(response.status, &response.body, path));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn stream(&self, _path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||
Ok(Box::new(Cursor::new(Vec::new())))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_sessions_parses_the_list() {
|
||||
let transport = FakeTransport::default();
|
||||
transport.respond(
|
||||
"GET",
|
||||
"/sessions",
|
||||
200,
|
||||
r#"[{"id":"s1","setup":"m1","setupName":"desktop","provider":"claude_cli",
|
||||
"title":"hi","status":"idle","lastActivity":1.0}]"#,
|
||||
);
|
||||
let client = ApiClient::new(transport);
|
||||
let sessions = client.fetch_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, "s1");
|
||||
assert_eq!(sessions[0].setup_name, "desktop");
|
||||
// Defaults for fields the server omits.
|
||||
assert!(sessions[0].notify);
|
||||
assert_eq!(sessions[0].model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_401_gets_the_enrollment_message_regardless_of_the_bare_body() {
|
||||
let transport = FakeTransport::default();
|
||||
transport.respond("POST", "/sessions/s1/interrupt", 401, "unauthorized");
|
||||
let client = ApiClient::new(transport);
|
||||
let err = client.interrupt_session("s1").unwrap_err();
|
||||
assert!(err.message.contains("Re-enroll"));
|
||||
assert_eq!(err.status, Some(401));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_error_status_with_no_body_falls_back_to_a_generic_message() {
|
||||
let transport = FakeTransport::default();
|
||||
transport.respond("POST", "/sessions/s1/stop", 500, "");
|
||||
let client = ApiClient::new(transport);
|
||||
let err = client.stop_session("s1").unwrap_err();
|
||||
assert!(err.message.contains("500"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_explanation_in_the_body_is_surfaced_verbatim() {
|
||||
let transport = FakeTransport::default();
|
||||
transport.respond(
|
||||
"POST",
|
||||
"/sessions/s1/cwd",
|
||||
409,
|
||||
"that path does not exist on this machine",
|
||||
);
|
||||
let client = ApiClient::new(transport);
|
||||
let err = client.set_session_cwd("s1", "/nope").unwrap_err();
|
||||
assert_eq!(err.message, "that path does not exist on this machine");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! What a Rust client needs to reach one enrolled server: host, port and
|
||||
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
|
||||
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
|
||||
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
|
||||
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
|
||||
//! the same text a phone would scan as a QR, with no second format
|
||||
//! invented for it (RUST.md's E4).
|
||||
//!
|
||||
//! What this type deliberately does not decide: where it is persisted, and
|
||||
//! under what file permissions. A phone seals its token in the Android
|
||||
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
||||
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
||||
//! never in the repo). Both are caller-specific, so they stay out of this
|
||||
//! crate per the code rules' "ask for the least you need" -- see
|
||||
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
||||
/// with `token` as a bearer header. Does not carry the pinned CA -- that is
|
||||
/// a public certificate rather than a secret, and where to find it differs
|
||||
/// by caller (a phone pins the one its APK was built against; a desktop
|
||||
/// client is told a path).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EnrolledServer {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl EnrolledServer {
|
||||
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not
|
||||
/// matter; unrecognised keys are ignored). `token` is percent-decoded,
|
||||
/// since `ui-sandbox.sh` encodes it precisely because a raw token can
|
||||
/// contain `+`, which turns into a space if left to a naive splitter.
|
||||
pub fn parse_link(link: &str) -> Result<Self, String> {
|
||||
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
||||
format!(
|
||||
"'{link}' has no query string (expected \
|
||||
aiapp://enroll?host=...&port=...&token=...)"
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut host = None;
|
||||
let mut port = None;
|
||||
let mut token = None;
|
||||
for pair in query.split('&') {
|
||||
let Some((key, value)) = pair.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let value = percent_decode(value);
|
||||
match key {
|
||||
"host" => host = Some(value),
|
||||
"port" => port = Some(value),
|
||||
"token" => token = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
|
||||
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
|
||||
let port: u16 = port_str
|
||||
.parse()
|
||||
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
||||
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
||||
|
||||
Ok(Self { host, port, token })
|
||||
}
|
||||
|
||||
/// Where a `client_core::api::UreqTransport` reaches this server.
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("https://{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& i + 2 < bytes.len()
|
||||
&& let Ok(byte) =
|
||||
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
||||
{
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_host_port_and_token() {
|
||||
let server =
|
||||
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
server,
|
||||
EnrolledServer {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8547,
|
||||
token: "abcDEF123".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_order_does_not_matter() {
|
||||
let server =
|
||||
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
|
||||
.unwrap();
|
||||
assert_eq!(server.host, "example.com");
|
||||
assert_eq!(server.port, 443);
|
||||
assert_eq!(server.token, "tok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_percent_encoded_token_is_decoded() {
|
||||
// ui-sandbox.sh's own reason for encoding: a raw '+' would
|
||||
// otherwise arrive as a space.
|
||||
let server =
|
||||
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
||||
assert_eq!(server.token, "a+b/c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_field_is_named_in_the_error() {
|
||||
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
|
||||
assert!(
|
||||
err.contains("token"),
|
||||
"error should name the missing field: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_numeric_port_is_named_in_the_error() {
|
||||
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
||||
assert!(
|
||||
err.contains("port"),
|
||||
"error should name the offending field: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! A span of milliseconds, written the way somebody reads it -- the port
|
||||
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
|
||||
//!
|
||||
//! Only the tool-timeout half is here. `formatSpan` (the usage
|
||||
//! countdown's rounding-up rule) belongs with whatever draws the usage
|
||||
//! bar, and nothing in this crate needs it yet.
|
||||
|
||||
/// A span of milliseconds, written the way somebody reads it.
|
||||
///
|
||||
/// A tool's timeout arrives as `480000`, which nobody reads as eight
|
||||
/// minutes. The rule has two halves, because a short span and a long one
|
||||
/// are read for different things. Under a minute the question is "roughly
|
||||
/// how long", so only the largest unit is shown and a fraction carries the
|
||||
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
|
||||
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
|
||||
/// units are left out rather than written as zero.
|
||||
///
|
||||
/// Sub-second precision is dropped past a minute: nothing that takes days
|
||||
/// is measured in milliseconds.
|
||||
pub fn format_millis(ms: i64) -> String {
|
||||
if ms < 0 {
|
||||
return format!("-{}", format_millis(-ms));
|
||||
}
|
||||
if ms < 1000 {
|
||||
return format!("{ms}ms");
|
||||
}
|
||||
if ms < 60_000 {
|
||||
let tenths = (ms + 50) / 100;
|
||||
let (whole, rest) = (tenths / 10, tenths % 10);
|
||||
return if rest == 0 {
|
||||
format!("{whole}s")
|
||||
} else {
|
||||
format!("{whole}.{rest}s")
|
||||
};
|
||||
}
|
||||
let seconds = ms / 1000;
|
||||
[
|
||||
("d", seconds / 86_400),
|
||||
("h", seconds / 3600 % 24),
|
||||
("m", seconds / 60 % 60),
|
||||
("s", seconds % 60),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, n)| *n > 0)
|
||||
.map(|(unit, n)| format!("{n}{unit}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// `text` as a span when it is a whole number of milliseconds, and
|
||||
/// unchanged when it is not.
|
||||
pub fn format_millis_text(text: &str) -> String {
|
||||
match text.trim().parse::<i64>() {
|
||||
Ok(ms) => format_millis(ms),
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two ways a span of time is written here, and the rule each of
|
||||
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
|
||||
/// both are read off a screen to make a decision, so what matters is
|
||||
/// that the shortest form that answers the question is what appears.
|
||||
#[test]
|
||||
fn under_a_minute_is_the_largest_unit_alone() {
|
||||
assert_eq!(format_millis(30), "30ms");
|
||||
assert_eq!(format_millis(999), "999ms");
|
||||
assert_eq!(format_millis(1000), "1s");
|
||||
assert_eq!(format_millis(2500), "2.5s");
|
||||
// One decimal, rounded rather than cut: 2.46s is nearer two and a
|
||||
// half than two and four.
|
||||
assert_eq!(format_millis(2460), "2.5s");
|
||||
assert_eq!(format_millis(59_900), "59.9s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
|
||||
// The figure this rule was written for: a tool timeout, which
|
||||
// arrives as milliseconds and is unreadable as 480000.
|
||||
assert_eq!(format_millis(480_000), "8m");
|
||||
assert_eq!(format_millis(60_000), "1m");
|
||||
assert_eq!(format_millis(90_000), "1m 30s");
|
||||
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
|
||||
// Empty units are left out rather than written as zero: the labels
|
||||
// say which is which, and "5d 0h 4m" is only longer.
|
||||
assert_eq!(format_millis(432_240_000), "5d 4m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_whole_number_of_milliseconds_is_rewritten() {
|
||||
assert_eq!(format_millis_text(" 480000 "), "8m");
|
||||
// A timeout a tool expressed some other way is its own words,
|
||||
// passed through rather than guessed at.
|
||||
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||
assert_eq!(format_millis_text(""), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! The SSE half of the API: one long-lived GET per open session screen,
|
||||
//! replaying the transcript after a cursor and then following it live.
|
||||
//! Ported from `app/.../EventStream.kt`; the framing itself is
|
||||
//! [`crate::sse`].
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
use event_model::SeqEvent;
|
||||
|
||||
use crate::api::{ApiError, Transport};
|
||||
use crate::sse::SseReader;
|
||||
|
||||
/// The frame name the server uses to say a cursor was too far behind to
|
||||
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
|
||||
const RESET_EVENT: &str = "reset";
|
||||
|
||||
/// One frame of a session's event stream, folded from the wire shape the
|
||||
/// caller needs to act on -- mirroring what `EventStream.kt`'s three
|
||||
/// callbacks were for, as a single enum instead, since Rust has no
|
||||
/// equivalent of handing three closures to one blocking call.
|
||||
pub enum StreamItem {
|
||||
/// The connection was accepted; the measured moment the stream is live
|
||||
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
|
||||
/// event, is what clears a previous failure on screen).
|
||||
Open,
|
||||
/// The cursor was too far behind to continue from: everything already
|
||||
/// displayed is stale, and the events that follow are a fresh window.
|
||||
/// Arrives before those events, so a caller that clears on it stays in
|
||||
/// order.
|
||||
Reset,
|
||||
/// One event, as both the raw line the transcript cache stores and the
|
||||
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
|
||||
/// line, so both travel together rather than being parsed twice from
|
||||
/// two call sites.
|
||||
Event { raw: String, event: SeqEvent },
|
||||
}
|
||||
|
||||
/// Follows `/sessions/{id}/events?after={after}`, calling `on_item` for
|
||||
/// each [`StreamItem`] until the connection drops or `on_item` asks to
|
||||
/// stop (by returning `false`). Reconnecting -- with the last seq seen as
|
||||
/// the new cursor -- is the caller's job, same as in the Kotlin version.
|
||||
pub fn follow_session_events(
|
||||
transport: &dyn Transport,
|
||||
session_id: &str,
|
||||
after: u64,
|
||||
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let path = format!("/sessions/{session_id}/events?after={after}");
|
||||
let body = transport.stream(&path)?;
|
||||
if !on_item(StreamItem::Open) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut lines = BufReader::new(body).lines();
|
||||
let mut reader = SseReader::new();
|
||||
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
|
||||
message: format!("Can't reach the server -- retrying. ({e})"),
|
||||
status: None,
|
||||
})? {
|
||||
let Some(frame) = reader.feed_line(&line) else {
|
||||
continue;
|
||||
};
|
||||
// A named frame carries no payload and a data frame has no name.
|
||||
if frame.name.as_deref() == Some(RESET_EVENT) {
|
||||
if !on_item(StreamItem::Reset) {
|
||||
return Ok(());
|
||||
}
|
||||
} else if !frame.data.is_empty() {
|
||||
let event: SeqEvent = serde_json::from_str(&frame.data).map_err(|e| ApiError {
|
||||
message: format!("The server sent an event this build couldn't parse: {e}"),
|
||||
status: None,
|
||||
})?;
|
||||
if !on_item(StreamItem::Event {
|
||||
raw: frame.data,
|
||||
event,
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::{Body, RawResponse};
|
||||
use std::io::Cursor;
|
||||
|
||||
struct FixtureTransport {
|
||||
body: &'static str,
|
||||
}
|
||||
|
||||
impl Transport for FixtureTransport {
|
||||
fn request(
|
||||
&self,
|
||||
_method: &str,
|
||||
_path: &str,
|
||||
_body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError> {
|
||||
unimplemented!("this fixture only serves a stream")
|
||||
}
|
||||
|
||||
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
|
||||
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_and_a_reset_frame_are_told_apart() {
|
||||
let transport = FixtureTransport {
|
||||
body: "event:reset\n\ndata:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
follow_session_events(&transport, "s1", 0, |item| {
|
||||
items.push(match item {
|
||||
StreamItem::Open => "open".to_string(),
|
||||
StreamItem::Reset => "reset".to_string(),
|
||||
StreamItem::Event { event, .. } => format!("event:{}", event.seq),
|
||||
});
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(items, vec!["open", "reset", "event:1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caller_can_stop_early() {
|
||||
let transport = FixtureTransport {
|
||||
body: "data:{\"seq\":1,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n\
|
||||
data:{\"seq\":2,\"ts\":1.0,\"type\":\"status\",\"state\":\"idle\"}\n\n",
|
||||
};
|
||||
let mut count = 0;
|
||||
follow_session_events(&transport, "s1", 0, |item| {
|
||||
if matches!(item, StreamItem::Event { .. }) {
|
||||
count += 1;
|
||||
}
|
||||
count < 1
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
//! A language the highlighter can colour, and the data-driven [`Rules`] each
|
||||
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
|
||||
//! why nearly every language is a row of data read by one shared scanner,
|
||||
//! with Markdown the one exception (`super::markdown`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Language {
|
||||
C,
|
||||
Coffeescript,
|
||||
Cpp,
|
||||
Csharp,
|
||||
Dart,
|
||||
Fish,
|
||||
Go,
|
||||
Java,
|
||||
Javascript,
|
||||
Json,
|
||||
Kotlin,
|
||||
Markdown,
|
||||
Perl,
|
||||
Php,
|
||||
Python,
|
||||
Ron,
|
||||
Ruby,
|
||||
Rust,
|
||||
Shell,
|
||||
Swift,
|
||||
Toml,
|
||||
Typescript,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
||||
/// (`Language.entries`).
|
||||
pub const ALL: [Language; 22] = [
|
||||
Language::C,
|
||||
Language::Coffeescript,
|
||||
Language::Cpp,
|
||||
Language::Csharp,
|
||||
Language::Dart,
|
||||
Language::Fish,
|
||||
Language::Go,
|
||||
Language::Java,
|
||||
Language::Javascript,
|
||||
Language::Json,
|
||||
Language::Kotlin,
|
||||
Language::Markdown,
|
||||
Language::Perl,
|
||||
Language::Php,
|
||||
Language::Python,
|
||||
Language::Ron,
|
||||
Language::Ruby,
|
||||
Language::Rust,
|
||||
Language::Shell,
|
||||
Language::Swift,
|
||||
Language::Toml,
|
||||
Language::Typescript,
|
||||
];
|
||||
}
|
||||
|
||||
/// What [`super::scan`] needs to know about one language -- data, not code,
|
||||
/// so that adding a language is a row here rather than a branch anywhere.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Rules {
|
||||
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
||||
/// anything else.
|
||||
pub keywords: HashSet<&'static str>,
|
||||
/// Tokens that open a comment running to the end of the line.
|
||||
pub line_comments: Vec<&'static str>,
|
||||
/// Whether `line_comments` count only at the start of a word. The shells
|
||||
/// need it: `$#`, `${#x}` and `a#b` are not comments.
|
||||
pub line_comments_at_word_start: bool,
|
||||
pub block_comment: Option<BlockComment>,
|
||||
/// The string forms. The longest opener that matches wins, so `"""` is
|
||||
/// tried before `"`.
|
||||
pub quotes: Vec<Quote>,
|
||||
pub attributes: Attributes,
|
||||
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
||||
pub raw_strings: bool,
|
||||
/// Rust: `'` opens a character literal only when a backslash or one
|
||||
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||
pub lifetimes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BlockComment {
|
||||
pub open: &'static str,
|
||||
pub close: &'static str,
|
||||
pub nests: bool,
|
||||
}
|
||||
|
||||
/// One string form. `escapes` is whether a backslash escapes the closer
|
||||
/// (and itself).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Quote {
|
||||
pub open: &'static str,
|
||||
pub close: &'static str,
|
||||
pub escapes: bool,
|
||||
}
|
||||
|
||||
/// What opens a metadata span, of the shapes that exist across these languages.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum Attributes {
|
||||
#[default]
|
||||
None,
|
||||
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
||||
AtWord,
|
||||
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
||||
HashBracket,
|
||||
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
||||
HashLine,
|
||||
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
||||
LineBracket,
|
||||
}
|
||||
|
||||
const C_STYLE: BlockComment = BlockComment {
|
||||
open: "/*",
|
||||
close: "*/",
|
||||
nests: false,
|
||||
};
|
||||
const NESTING: BlockComment = BlockComment {
|
||||
open: "/*",
|
||||
close: "*/",
|
||||
nests: true,
|
||||
};
|
||||
|
||||
const DOUBLE: Quote = Quote {
|
||||
open: "\"",
|
||||
close: "\"",
|
||||
escapes: true,
|
||||
};
|
||||
const SINGLE: Quote = Quote {
|
||||
open: "'",
|
||||
close: "'",
|
||||
escapes: true,
|
||||
};
|
||||
const TRIPLE_DOUBLE: Quote = Quote {
|
||||
open: "\"\"\"",
|
||||
close: "\"\"\"",
|
||||
escapes: true,
|
||||
};
|
||||
const TRIPLE_SINGLE: Quote = Quote {
|
||||
open: "'''",
|
||||
close: "'''",
|
||||
escapes: true,
|
||||
};
|
||||
|
||||
fn words(list: &'static str) -> HashSet<&'static str> {
|
||||
list.split_whitespace().collect()
|
||||
}
|
||||
|
||||
/// The rules for one language. A `match` rather than a lazily-built map --
|
||||
/// there is no once-per-process cost worth paying for in a language table
|
||||
/// this small, and it sidesteps the Kotlin version's own workaround for
|
||||
/// property initialization order.
|
||||
pub fn rules_for(language: Language) -> Rules {
|
||||
match language {
|
||||
Language::C => Rules {
|
||||
keywords: words(KEYWORDS_C),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::HashLine,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Cpp => Rules {
|
||||
keywords: words(KEYWORDS_CPP),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::HashLine,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Csharp => Rules {
|
||||
keywords: words(KEYWORDS_CSHARP),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
// `###` opens and closes a block comment and `#` opens a line one,
|
||||
// which is why the scanner tries the block opener first.
|
||||
Language::Coffeescript => Rules {
|
||||
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||
line_comments: vec!["#"],
|
||||
block_comment: Some(BlockComment {
|
||||
open: "###",
|
||||
close: "###",
|
||||
nests: false,
|
||||
}),
|
||||
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Dart => Rules {
|
||||
keywords: words(KEYWORDS_DART),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Fish => Rules {
|
||||
keywords: words(KEYWORDS_FISH),
|
||||
line_comments: vec!["#"],
|
||||
line_comments_at_word_start: true,
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Go => Rules {
|
||||
keywords: words(KEYWORDS_GO),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
SINGLE,
|
||||
Quote {
|
||||
open: "`",
|
||||
close: "`",
|
||||
escapes: false,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Java => Rules {
|
||||
keywords: words(KEYWORDS_JAVA),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Javascript => Rules {
|
||||
keywords: words(KEYWORDS_JAVASCRIPT),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
SINGLE,
|
||||
Quote {
|
||||
open: "`",
|
||||
close: "`",
|
||||
escapes: true,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Json => Rules {
|
||||
keywords: words(KEYWORDS_JSON),
|
||||
quotes: vec![DOUBLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Kotlin => Rules {
|
||||
keywords: words(KEYWORDS_KOTLIN),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
quotes: vec![
|
||||
Quote {
|
||||
open: "\"\"\"",
|
||||
close: "\"\"\"",
|
||||
escapes: false,
|
||||
},
|
||||
DOUBLE,
|
||||
SINGLE,
|
||||
],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Perl => Rules {
|
||||
keywords: words(KEYWORDS_PERL),
|
||||
line_comments: vec!["#"],
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Php => Rules {
|
||||
keywords: words(KEYWORDS_PHP),
|
||||
line_comments: vec!["//", "#"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Python => Rules {
|
||||
keywords: words(KEYWORDS_PYTHON),
|
||||
line_comments: vec!["#"],
|
||||
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Ron => Rules {
|
||||
keywords: words(KEYWORDS_RON),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::HashBracket,
|
||||
raw_strings: true,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Ruby => Rules {
|
||||
keywords: words(KEYWORDS_RUBY),
|
||||
line_comments: vec!["#"],
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Rust => Rules {
|
||||
keywords: words(KEYWORDS_RUST),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
// No `'` here: `lifetimes` decides when one opens a character literal.
|
||||
quotes: vec![DOUBLE],
|
||||
attributes: Attributes::HashBracket,
|
||||
raw_strings: true,
|
||||
lifetimes: true,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Shell => Rules {
|
||||
keywords: words(KEYWORDS_SHELL),
|
||||
line_comments: vec!["#"],
|
||||
line_comments_at_word_start: true,
|
||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
Quote {
|
||||
open: "'",
|
||||
close: "'",
|
||||
escapes: false,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Swift => Rules {
|
||||
keywords: words(KEYWORDS_SWIFT),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
quotes: vec![TRIPLE_DOUBLE, DOUBLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Toml => Rules {
|
||||
keywords: words(KEYWORDS_TOML),
|
||||
line_comments: vec!["#"],
|
||||
quotes: vec![
|
||||
TRIPLE_DOUBLE,
|
||||
Quote {
|
||||
open: "'''",
|
||||
close: "'''",
|
||||
escapes: false,
|
||||
},
|
||||
DOUBLE,
|
||||
Quote {
|
||||
open: "'",
|
||||
close: "'",
|
||||
escapes: false,
|
||||
},
|
||||
],
|
||||
attributes: Attributes::LineBracket,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Typescript => Rules {
|
||||
keywords: words(KEYWORDS_TYPESCRIPT),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
SINGLE,
|
||||
Quote {
|
||||
open: "`",
|
||||
close: "`",
|
||||
escapes: true,
|
||||
},
|
||||
],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
||||
Language::Markdown => Rules::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// The keyword sets. Every list below other than RON, TOML, fish and JSON
|
||||
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
|
||||
// Kotlin scanner replaced, so that no fence which was coloured there turns
|
||||
// plain here either.
|
||||
|
||||
const KEYWORDS_C: &str =
|
||||
"auto break case char const continue default do double else enum extern float for goto if
|
||||
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||
void volatile while";
|
||||
|
||||
const KEYWORDS_CPP: &str =
|
||||
"asm auto bool break case catch char class const const_cast continue default delete do
|
||||
double dynamic_cast else enum explicit export extern false float for friend goto if inline
|
||||
int long mutable namespace new operator private protected public register reinterpret_cast
|
||||
return short signed sizeof static static_cast struct switch template this throw true try
|
||||
typedef typeid typename union unsigned using virtual void volatile wchar_t while";
|
||||
|
||||
const KEYWORDS_CSHARP: &str =
|
||||
"abstract as base bool break byte case catch char checked class const continue decimal
|
||||
default delegate do double else enum event explicit extern false finally fixed float for
|
||||
foreach goto if implicit in int interface internal is lock long namespace new null object
|
||||
operator out override params private protected public readonly ref return sbyte sealed short
|
||||
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
|
||||
unsafe ushort using virtual void volatile while";
|
||||
|
||||
const KEYWORDS_COFFEESCRIPT: &str =
|
||||
"Infinity NaN and arguments await break by case catch class continue debugger delete defer
|
||||
default do else export extends false finally for function if import in instanceof is isnt
|
||||
let loop new no not null of on or package return super switch this throw true try typeof
|
||||
unless undefined var wait when with yield";
|
||||
|
||||
const KEYWORDS_DART: &str =
|
||||
"abstract as assert async await base break case catch class const continue covariant
|
||||
default deferred do dynamic else enum export extends external factory false final finally
|
||||
for get if implements import in interface is late library mixin new null on operator part
|
||||
required rethrow return sealed set show static super switch this throw true try var void
|
||||
when with while yield";
|
||||
|
||||
/// fish is not in the library at all, so its fences are drawn plain today.
|
||||
/// The list is the shell's own words, which is what a fish fence is mostly
|
||||
/// made of.
|
||||
const KEYWORDS_FISH: &str =
|
||||
"and begin break builtin case command continue else end exec for function if in not or
|
||||
return switch while set echo test string math read source";
|
||||
|
||||
const KEYWORDS_GO: &str =
|
||||
"break case chan const continue default defer else fallthrough false for func go goto if
|
||||
import interface map package range return select struct switch true type var";
|
||||
|
||||
const KEYWORDS_JAVA: &str =
|
||||
"abstract assert boolean break byte case catch char class const continue default do double
|
||||
else enum extends final finally float for goto if implements import instanceof int interface
|
||||
long native new null package private protected public return short static strictfp super
|
||||
switch synchronized this throw throws transient try void volatile while";
|
||||
|
||||
const KEYWORDS_JAVASCRIPT: &str =
|
||||
"async await boolean break case catch class const continue debugger default delete do else
|
||||
enum export extends false finally for function if implements import in instanceof interface
|
||||
let new null package private protected public return super switch this throw true try typeof
|
||||
var void while with yield";
|
||||
|
||||
const KEYWORDS_JSON: &str = "true false null";
|
||||
|
||||
const KEYWORDS_KOTLIN: &str =
|
||||
"actual abstract annotation as break by catch class companion const constructor continue
|
||||
coroutine crossinline data delegate dynamic do else enum expect external false final finally
|
||||
for fun get if import in infix inline interface internal is lazy lateinit native null object
|
||||
open operator out override package private protected public reified return sealed set super
|
||||
suspend tailrec this throw true try typealias typeof val var vararg when while yield";
|
||||
|
||||
const KEYWORDS_PERL: &str =
|
||||
"__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
|
||||
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
|
||||
use while xor";
|
||||
|
||||
const KEYWORDS_PHP: &str =
|
||||
"__halt_compiler abstract and array as break callable case catch class clone const continue
|
||||
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
|
||||
endwhile eval exit extends final finally fn for foreach function global goto if implements
|
||||
include include_once instanceof insteadof interface isset list match new or print private
|
||||
protected public require require_once return static switch throw trait try unset use var
|
||||
while xor yield";
|
||||
|
||||
const KEYWORDS_PYTHON: &str =
|
||||
"False True and as assert async await break class continue def del elif else except finally
|
||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||
yield";
|
||||
|
||||
/// RON is not in the library either; these are the words a RON file can hold.
|
||||
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
||||
|
||||
const KEYWORDS_RUBY: &str =
|
||||
"__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
|
||||
else elsif end ensure false for if in module next nil not or redo rescue retry return self
|
||||
super then true undef unless until when while yield";
|
||||
|
||||
const KEYWORDS_RUST: &str =
|
||||
"as async await break const continue crate dyn else enum extern false fn for if impl in
|
||||
let loop match mod move mut pub ref return Self self static struct super trait true type
|
||||
union unsafe use where while abstract become box do final macro override priv try typeof
|
||||
unsized virtual yield";
|
||||
|
||||
const KEYWORDS_SHELL: &str =
|
||||
"alias bg bind break builtin caller cd command compgen complete compopt continue declare
|
||||
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
|
||||
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
|
||||
test";
|
||||
|
||||
const KEYWORDS_SWIFT: &str =
|
||||
"_ associatedtype class deinit enum extension fileprivate func import init inout internal
|
||||
let open operator private precedencegroup protocol public rethrows static struct subscript
|
||||
typealias var break case catch continue default defer do else fallthrough for guard if in
|
||||
repeat return throw switch where while Any as await false is nil self Self super throws true
|
||||
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
|
||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||
unowned weak willSet";
|
||||
|
||||
/// TOML is not in the library; `inf` and `nan` are values rather than
|
||||
/// names, like the booleans.
|
||||
const KEYWORDS_TOML: &str = "true false inf nan";
|
||||
|
||||
const KEYWORDS_TYPESCRIPT: &str =
|
||||
"abstract as asserts await break case catch class const constructor continue debugger
|
||||
default delete do else enum export extends false finally for from function get if implements
|
||||
import in infer instanceof interface is keyof let module namespace new null number object
|
||||
package private protected public readonly require global return set static string super
|
||||
switch this throw true try type typeof undefined unique unknown var void while with yield";
|
||||
|
||||
/// The highlighter's language for a fence's info word, or `None` for one it
|
||||
/// has no rules for. Also what `super::file_language` reads for a file's
|
||||
/// extension -- one table, so a language added for fences is a language
|
||||
/// added for files.
|
||||
pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
||||
let name = name?.trim().to_lowercase();
|
||||
FENCE_LANGUAGES
|
||||
.iter()
|
||||
.find(|(alias, _)| *alias == name)
|
||||
.map(|(_, language)| *language)
|
||||
}
|
||||
|
||||
/// The highlighter's language for a *file*, from its name.
|
||||
///
|
||||
/// The extension is the part after the *last* dot, which is what makes
|
||||
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
|
||||
/// extension, it has a name that starts with a dot. A name with no dot at
|
||||
/// all -- `Makefile` -- is likewise `None`.
|
||||
pub fn file_language(name: &str) -> Option<Language> {
|
||||
let dot = name.rfind('.')?;
|
||||
if dot < 1 {
|
||||
return None;
|
||||
}
|
||||
fence_language(Some(&name[dot + 1..]))
|
||||
}
|
||||
|
||||
const FENCE_LANGUAGES: &[(&str, Language)] = &[
|
||||
("kotlin", Language::Kotlin),
|
||||
("kt", Language::Kotlin),
|
||||
("kts", Language::Kotlin),
|
||||
("rust", Language::Rust),
|
||||
("rs", Language::Rust),
|
||||
("sh", Language::Shell),
|
||||
("bash", Language::Shell),
|
||||
("shell", Language::Shell),
|
||||
("zsh", Language::Shell),
|
||||
("console", Language::Shell),
|
||||
("python", Language::Python),
|
||||
("py", Language::Python),
|
||||
("javascript", Language::Javascript),
|
||||
("js", Language::Javascript),
|
||||
("jsx", Language::Javascript),
|
||||
("typescript", Language::Typescript),
|
||||
("ts", Language::Typescript),
|
||||
("tsx", Language::Typescript),
|
||||
("java", Language::Java),
|
||||
("c", Language::C),
|
||||
("h", Language::C),
|
||||
("cpp", Language::Cpp),
|
||||
("c++", Language::Cpp),
|
||||
("cc", Language::Cpp),
|
||||
("hpp", Language::Cpp),
|
||||
("csharp", Language::Csharp),
|
||||
("cs", Language::Csharp),
|
||||
("c#", Language::Csharp),
|
||||
("go", Language::Go),
|
||||
("golang", Language::Go),
|
||||
("swift", Language::Swift),
|
||||
("dart", Language::Dart),
|
||||
("ruby", Language::Ruby),
|
||||
("rb", Language::Ruby),
|
||||
("php", Language::Php),
|
||||
("perl", Language::Perl),
|
||||
("pl", Language::Perl),
|
||||
("coffeescript", Language::Coffeescript),
|
||||
("coffee", Language::Coffeescript),
|
||||
("ron", Language::Ron),
|
||||
("toml", Language::Toml),
|
||||
("fish", Language::Fish),
|
||||
("json", Language::Json),
|
||||
("markdown", Language::Markdown),
|
||||
("md", Language::Markdown),
|
||||
];
|
||||
@@ -0,0 +1,681 @@
|
||||
//! Markdown read into the spans that carry a colour -- a ```markdown fence
|
||||
//! in a reply, and a `.md` file in the viewer. Ported from
|
||||
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
|
||||
//! scanner rather than a row of [`super::Rules`] (what a character means
|
||||
//! depends on where it sits, not on what it is) and why an indented code
|
||||
//! block is deliberately not recognised.
|
||||
//!
|
||||
//! Structure is read a line at a time and each line's prose left to right,
|
||||
//! except the two decisions that are not: a fenced block is state carried
|
||||
//! forward, and a table is found by its delimiter row, which comes after
|
||||
//! the header it belongs to (the one place here that looks ahead).
|
||||
|
||||
use super::{Kind, Span};
|
||||
|
||||
/// The characters an unordered list may be bulleted with.
|
||||
const BULLETS: &str = "-*+";
|
||||
/// The characters a thematic break, or a setext heading's underline, can be
|
||||
/// drawn with.
|
||||
const RULE_MARKERS: &str = "-*_=";
|
||||
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
||||
const EMPHASIS: &str = "*_~";
|
||||
/// Characters that end a bare URL wherever they appear, and ones only
|
||||
/// trimmed off the end.
|
||||
const URL_STOPS: &str = "<>\"'`|";
|
||||
const URL_TRAILING: &str = ".,:;!?";
|
||||
|
||||
pub fn scan_markdown(code: &str) -> Vec<Span> {
|
||||
MarkdownScanner::new(code).run()
|
||||
}
|
||||
|
||||
struct MarkdownScanner {
|
||||
code: Vec<char>,
|
||||
spans: Vec<Span>,
|
||||
}
|
||||
|
||||
impl MarkdownScanner {
|
||||
fn new(code: &str) -> Self {
|
||||
Self {
|
||||
code: code.chars().collect(),
|
||||
spans: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut self) -> Vec<Span> {
|
||||
let mut at = 0usize;
|
||||
// The delimiter run that opened the fenced block we are inside, or
|
||||
// None between them.
|
||||
let mut fence: Option<Vec<char>> = None;
|
||||
// Whether the row above was part of a table, which is what makes
|
||||
// this one a body row.
|
||||
let mut table = false;
|
||||
loop {
|
||||
let end = self.line_end(at);
|
||||
if let Some(open) = fence.clone() {
|
||||
// The content and the closing line alike: a fence is one
|
||||
// block of code, and its own delimiters belong to it the
|
||||
// way a string's quotes belong to the string.
|
||||
self.emit(at, end, Kind::String);
|
||||
if self.closes_fence(at, end, &open) {
|
||||
fence = None;
|
||||
}
|
||||
} else {
|
||||
let opened = self.opens_fence(at, end);
|
||||
if opened.is_some() {
|
||||
table = false;
|
||||
fence = opened;
|
||||
} else {
|
||||
table = self.row(at, end, table);
|
||||
}
|
||||
}
|
||||
if end == self.code.len() {
|
||||
break;
|
||||
}
|
||||
at = end + 1;
|
||||
}
|
||||
self.spans
|
||||
}
|
||||
|
||||
/// The end of the line beginning at `at`: the newline, or the end of the text.
|
||||
fn line_end(&self, at: usize) -> usize {
|
||||
self.code[at..]
|
||||
.iter()
|
||||
.position(|&c| c == '\n')
|
||||
.map(|p| at + p)
|
||||
.unwrap_or(self.code.len())
|
||||
}
|
||||
|
||||
/// One line that is not inside a fence, and whether the table it may be
|
||||
/// part of is still open.
|
||||
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
||||
if self.table_delimiter(start, end) {
|
||||
let indented = self.indented(start, end);
|
||||
self.emit(indented, end, Kind::Mark);
|
||||
return true;
|
||||
}
|
||||
let header = end < self.code.len() && self.table_delimiter(end + 1, self.line_end(end + 1));
|
||||
if (table || header) && self.has_pipe(start, end) {
|
||||
self.table_row(start, end);
|
||||
return true;
|
||||
}
|
||||
self.structure(start, end);
|
||||
false
|
||||
}
|
||||
|
||||
/// A line of nothing but pipes, dashes, alignment colons and space, with
|
||||
/// one of each needed.
|
||||
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||
let mut dashes = false;
|
||||
let mut pipes = false;
|
||||
for at in self.indented(start, end)..end {
|
||||
match self.code[at] {
|
||||
'-' => dashes = true,
|
||||
'|' => pipes = true,
|
||||
':' | ' ' | '\t' => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
dashes && pipes
|
||||
}
|
||||
|
||||
fn has_pipe(&self, start: usize, end: usize) -> bool {
|
||||
let mut at = start;
|
||||
while at < end {
|
||||
if self.code[at] == '\\' {
|
||||
at += 2;
|
||||
} else if self.code[at] == '|' {
|
||||
return true;
|
||||
} else {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A table row: the pipes are the structure, and what is between them is prose.
|
||||
fn table_row(&mut self, start: usize, end: usize) {
|
||||
let mut at = self.indented(start, end);
|
||||
let mut cell = at;
|
||||
while at < end {
|
||||
match self.code[at] {
|
||||
'\\' => at += 2,
|
||||
'|' => {
|
||||
self.inline(cell, at);
|
||||
self.emit(at, at + 1, Kind::Mark);
|
||||
at += 1;
|
||||
cell = at;
|
||||
}
|
||||
_ => at += 1,
|
||||
}
|
||||
}
|
||||
self.inline(cell, end);
|
||||
}
|
||||
|
||||
/// Spans, coalesced with the one before when they touch and agree.
|
||||
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
||||
if end <= start {
|
||||
return;
|
||||
}
|
||||
if let Some(last) = self.spans.last_mut()
|
||||
&& last.kind == kind
|
||||
&& last.end == start
|
||||
{
|
||||
last.end = end;
|
||||
return;
|
||||
}
|
||||
self.spans.push(Span { start, end, kind });
|
||||
}
|
||||
|
||||
/// The first character of the line at or after `start` that is not indentation.
|
||||
fn indented(&self, start: usize, end: usize) -> usize {
|
||||
let mut at = start;
|
||||
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// The run of backticks or tildes that could open or close a fence on
|
||||
/// this line, or `None`.
|
||||
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
||||
let at = self.indented(start, end);
|
||||
if at == end {
|
||||
return None;
|
||||
}
|
||||
let marker = self.code[at];
|
||||
if marker != '`' && marker != '~' {
|
||||
return None;
|
||||
}
|
||||
let mut run = at;
|
||||
while run < end && self.code[run] == marker {
|
||||
run += 1;
|
||||
}
|
||||
if run - at >= 3 { Some((at, run)) } else { None }
|
||||
}
|
||||
|
||||
/// Draws an opening fence line and answers its delimiter, or `None` if
|
||||
/// this is not one.
|
||||
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
||||
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||
self.emit(run_start, run_end, Kind::String);
|
||||
// The info word is what the fence is a fence *of*, which is
|
||||
// metadata about the block rather than part of it.
|
||||
let indented = self.indented(run_end, end);
|
||||
self.emit(indented, end, Kind::Metadata);
|
||||
Some(self.code[run_start..run_end].to_vec())
|
||||
}
|
||||
|
||||
/// Whether this line closes a fence opened by `open`: the same
|
||||
/// character, at least as many of them, and nothing else on the line.
|
||||
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
||||
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||
return false;
|
||||
};
|
||||
if self.code[run_start] != open[0] || run_end - run_start < open.len() {
|
||||
return false;
|
||||
}
|
||||
self.indented(run_end, end) == end
|
||||
}
|
||||
|
||||
/// One ordinary line: what its opening characters make it, and then its prose.
|
||||
fn structure(&mut self, start: usize, end: usize) {
|
||||
let mut at = start;
|
||||
// Quote markers come before everything else and can be several
|
||||
// deep, and what follows one is an ordinary line again -- a heading
|
||||
// inside a quote is still a heading.
|
||||
while at < end && self.code[at] == '>' {
|
||||
at += 1;
|
||||
self.emit(at - 1, at, Kind::Mark);
|
||||
at = self.indented(at, end);
|
||||
}
|
||||
if at == end {
|
||||
return;
|
||||
}
|
||||
if self.heading(at, end) || self.thematic_break(at, end) {
|
||||
return;
|
||||
}
|
||||
let text_start = self.bullet(at, end);
|
||||
self.inline(text_start, end);
|
||||
}
|
||||
|
||||
/// `#` to `######` and a space. Without the space it is a word
|
||||
/// beginning with a hash.
|
||||
fn heading(&mut self, start: usize, end: usize) -> bool {
|
||||
let mut at = start;
|
||||
while at < end && self.code[at] == '#' {
|
||||
at += 1;
|
||||
}
|
||||
let depth = at - start;
|
||||
if !(1..=6).contains(&depth) {
|
||||
return false;
|
||||
}
|
||||
if at < end && self.code[at] != ' ' && self.code[at] != '\t' {
|
||||
return false;
|
||||
}
|
||||
self.emit(start, end, Kind::Keyword);
|
||||
true
|
||||
}
|
||||
|
||||
/// A line made of one repeated rule character and nothing else.
|
||||
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||
let marker = self.code[start];
|
||||
if !RULE_MARKERS.contains(marker) {
|
||||
return false;
|
||||
}
|
||||
let mut seen = 0usize;
|
||||
for at in start..end {
|
||||
let c = self.code[at];
|
||||
if c == marker {
|
||||
seen += 1;
|
||||
} else if !c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if seen < if marker == '=' { 1 } else { 3 } {
|
||||
return false;
|
||||
}
|
||||
self.emit(start, end, Kind::Mark);
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws a list marker if the line opens with one, and answers where
|
||||
/// the item's text starts.
|
||||
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
||||
let marker = self.code[start];
|
||||
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
||||
self.emit(start, start + 1, Kind::Mark);
|
||||
return self.indented(start + 1, end);
|
||||
}
|
||||
let mut digits = start;
|
||||
while digits < end && self.code[digits].is_ascii_digit() {
|
||||
digits += 1;
|
||||
}
|
||||
let delimiter = self.code.get(digits).copied();
|
||||
if digits > start
|
||||
&& (delimiter == Some('.') || delimiter == Some(')'))
|
||||
&& self.space_or_end(digits + 1, end)
|
||||
{
|
||||
self.emit(start, digits + 1, Kind::Mark);
|
||||
return self.indented(digits + 1, end);
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
fn space_or_end(&self, at: usize, end: usize) -> bool {
|
||||
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
||||
}
|
||||
|
||||
/// The inline forms, left to right. Every branch answers a position
|
||||
/// strictly after `start` of its call, so this terminates.
|
||||
fn inline(&mut self, start: usize, end: usize) {
|
||||
let mut at = start;
|
||||
while at < end {
|
||||
let c = self.code[at];
|
||||
at = if c == '\\' {
|
||||
// A backslash takes the character after it out of the
|
||||
// running entirely, which is how `\*` stays an asterisk
|
||||
// rather than opening emphasis.
|
||||
at + 2
|
||||
} else if c == '`' {
|
||||
self.code_span(at, end)
|
||||
} else if c == '[' {
|
||||
self.link(at, at, end)
|
||||
} else if c == '!' && self.code.get(at + 1) == Some(&'[') {
|
||||
self.link(at, at + 1, end)
|
||||
} else if c == '<' {
|
||||
self.autolink(at, end)
|
||||
} else if EMPHASIS.contains(c) {
|
||||
self.emphasis(at, end)
|
||||
} else {
|
||||
self.url(at, end).unwrap_or(at + 1)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
||||
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
||||
let mut open = start;
|
||||
while open < end && self.code[open] == '`' {
|
||||
open += 1;
|
||||
}
|
||||
let ticks = open - start;
|
||||
let mut at = open;
|
||||
while at < end {
|
||||
if self.code[at] != '`' {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let mut close = at;
|
||||
while close < end && self.code[close] == '`' {
|
||||
close += 1;
|
||||
}
|
||||
if close - at == ticks {
|
||||
self.emit(start, close, Kind::String);
|
||||
return close;
|
||||
}
|
||||
at = close;
|
||||
}
|
||||
// Nothing closes it on this line, so those were ordinary backticks.
|
||||
open
|
||||
}
|
||||
|
||||
/// `[text](destination)`, and the same with a leading `!` for an image.
|
||||
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||
let mut depth = 0i32;
|
||||
let mut close = bracket;
|
||||
while close < end {
|
||||
match self.code[close] {
|
||||
'\\' => close += 1,
|
||||
'[' => depth += 1,
|
||||
']' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
close += 1;
|
||||
}
|
||||
if close >= end {
|
||||
return start + 1;
|
||||
}
|
||||
let destination = close + 1;
|
||||
if self.code.get(destination) != Some(&'(') {
|
||||
return start + 1;
|
||||
}
|
||||
let Some(paren_rel) = self.code[destination..].iter().position(|&c| c == ')') else {
|
||||
return start + 1;
|
||||
};
|
||||
let paren = destination + paren_rel;
|
||||
if paren >= end {
|
||||
return start + 1;
|
||||
}
|
||||
self.emit(start, bracket + 1, Kind::Mark);
|
||||
self.inline(bracket + 1, close);
|
||||
self.emit(close, destination, Kind::Mark);
|
||||
self.emit(destination, paren + 1, Kind::Metadata);
|
||||
paren + 1
|
||||
}
|
||||
|
||||
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
||||
/// destination they are.
|
||||
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||
let mut at = start + 1;
|
||||
let mut addressed = false;
|
||||
while at < end {
|
||||
let c = self.code[at];
|
||||
if c.is_whitespace() || c == '<' {
|
||||
return start + 1;
|
||||
}
|
||||
if c == '>' {
|
||||
if !addressed {
|
||||
return start + 1;
|
||||
}
|
||||
self.emit(start, at + 1, Kind::Metadata);
|
||||
return at + 1;
|
||||
}
|
||||
if c == ':' || c == '@' {
|
||||
addressed = true;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
start + 1
|
||||
}
|
||||
|
||||
/// A bare `scheme://...` written in prose, or `None` if one does not
|
||||
/// start here.
|
||||
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
||||
if start > 0 && is_word(self.code[start - 1]) {
|
||||
return None;
|
||||
}
|
||||
let mut scheme = start;
|
||||
while scheme < end && self.code[scheme].is_alphabetic() {
|
||||
scheme += 1;
|
||||
}
|
||||
if scheme == start || !starts_with(&self.code, scheme, "://") {
|
||||
return None;
|
||||
}
|
||||
let body = scheme + 3;
|
||||
let mut at = body;
|
||||
let mut openers = 0i32;
|
||||
let mut closers = 0i32;
|
||||
while at < end && !self.code[at].is_whitespace() && !URL_STOPS.contains(self.code[at]) {
|
||||
if self.code[at] == '(' {
|
||||
openers += 1;
|
||||
} else if self.code[at] == ')' {
|
||||
closers += 1;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
while at > body {
|
||||
let last = self.code[at - 1];
|
||||
if URL_TRAILING.contains(last) {
|
||||
at -= 1;
|
||||
} else if last == ')' && closers > openers {
|
||||
closers -= 1;
|
||||
at -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if at == body {
|
||||
return None;
|
||||
}
|
||||
self.emit(start, at, Kind::Metadata);
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
||||
/// all.
|
||||
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||
let marker = self.code[start];
|
||||
let mut open = start;
|
||||
while open < end && self.code[open] == marker {
|
||||
open += 1;
|
||||
}
|
||||
let length = open - start;
|
||||
if marker == '~' && length != 2 {
|
||||
return open;
|
||||
}
|
||||
if length > 3 {
|
||||
return open;
|
||||
}
|
||||
if open == end || self.code[open].is_whitespace() {
|
||||
return open;
|
||||
}
|
||||
if marker == '_' && start > 0 && is_word(self.code[start - 1]) {
|
||||
return open;
|
||||
}
|
||||
let mut at = open;
|
||||
while at < end {
|
||||
if self.code[at] == '\\' {
|
||||
at += 2;
|
||||
continue;
|
||||
}
|
||||
if self.code[at] != marker {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let mut close = at;
|
||||
while close < end && self.code[close] == marker {
|
||||
close += 1;
|
||||
}
|
||||
let finish = at + length;
|
||||
if close - at >= length
|
||||
&& !self.code[at - 1].is_whitespace()
|
||||
&& !(marker == '_' && finish < end && is_word(self.code[finish]))
|
||||
{
|
||||
self.emit(start, finish, Kind::Literal);
|
||||
return finish;
|
||||
}
|
||||
at = close;
|
||||
}
|
||||
open
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn starts_with(code: &[char], at: usize, token: &str) -> bool {
|
||||
let token: Vec<char> = token.chars().collect();
|
||||
if at + token.len() > code.len() {
|
||||
return false;
|
||||
}
|
||||
code[at..at + token.len()] == token[..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Kind, Language, span_text, spans_of};
|
||||
|
||||
fn spans(code: &str, kind: Kind) -> Vec<String> {
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
spans_of(code, Language::Markdown)
|
||||
.into_iter()
|
||||
.filter(|s| s.kind == kind)
|
||||
.map(|s| span_text(&chars, &s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_spans(code: &str, kind: Kind, expected: &[&str]) {
|
||||
assert_eq!(spans(code, kind), expected.to_vec(), "{kind:?} in: {code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_is_coloured_whole_and_a_hash_inside_a_word_is_not_one() {
|
||||
let code = "## Layout\nissue #12 is fixed\n#hashtag";
|
||||
assert_spans(code, Kind::Keyword, &["## Layout"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_hashes_are_not_a_heading() {
|
||||
assert_spans("####### deep", Kind::Keyword, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fence_carries_its_language_as_metadata_and_its_body_as_one_string() {
|
||||
let code = "text\n```kotlin\nval x = 1\n```\nmore";
|
||||
assert_spans(code, Kind::Metadata, &["kotlin"]);
|
||||
assert_spans(code, Kind::String, &["```", "val x = 1", "```"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_longer_fence_is_not_closed_by_a_shorter_one_and_a_heading_inside_it_is_not_a_heading() {
|
||||
let code = "````\n```\n# not a heading\n````\nafter";
|
||||
assert_spans(code, Kind::Keyword, &[]);
|
||||
assert_spans(
|
||||
code,
|
||||
Kind::String,
|
||||
&["````", "```", "# not a heading", "````"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_fence_runs_to_the_end_rather_than_panicking() {
|
||||
assert_spans("```\nstill going", Kind::String, &["```", "still going"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_markers_and_quote_markers_colour_without_their_text() {
|
||||
let code = "- one\n2. two\n> quoted";
|
||||
assert_spans(code, Kind::Mark, &["-", "2.", ">"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_and_a_setext_underline_are_the_same_mark() {
|
||||
assert_spans("Title\n=====\n\n---", Kind::Mark, &["=====", "---"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasis_needs_something_on_both_sides_of_it() {
|
||||
assert_spans(
|
||||
"**bold** and *thin*",
|
||||
Kind::Literal,
|
||||
&["**bold**", "*thin*"],
|
||||
);
|
||||
assert_spans("a * b * c and *p = *q", Kind::Literal, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_underscore_inside_a_word_emphasises_nothing() {
|
||||
assert_spans("snake_case_name and _real_", Kind::Literal, &["_real_"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_span_holds_a_backtick_when_opened_with_two() {
|
||||
assert_spans("``a ` b`` and `c`", Kind::String, &["``a ` b``", "`c`"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_code_span_is_ordinary_text() {
|
||||
assert_spans("a ` b", Kind::String, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_marks_its_brackets_and_colours_its_destination() {
|
||||
let code = "see [the plan](PLAN.md) now";
|
||||
assert_spans(code, Kind::Mark, &["[", "]"]);
|
||||
assert_spans(code, Kind::Metadata, &["(PLAN.md)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_is_found_by_its_delimiter_row_and_pipes_elsewhere_are_plain() {
|
||||
let code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph";
|
||||
assert_spans(
|
||||
code,
|
||||
Kind::Mark,
|
||||
&["|", "|", "|", "|---|---|", "|", "|", "|"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_without_outer_pipes_still_colours_and_the_table_ends_with_the_rows() {
|
||||
let code = "a | b\n--- | ---\nnot a row";
|
||||
assert_spans(code, Kind::Mark, &["|", "--- | ---"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_autolink_colours_and_an_html_tag_does_not() {
|
||||
let code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">";
|
||||
assert_spans(
|
||||
code,
|
||||
Kind::Metadata,
|
||||
&["<https://example.com>", "<a@b.com>", "http://x"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_url_gives_back_the_sentences_punctuation() {
|
||||
assert_spans(
|
||||
"see https://example.com/a., and ssh://host/x)",
|
||||
Kind::Metadata,
|
||||
&["https://example.com/a", "ssh://host/x"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bracket_a_url_opened_itself_stays_in_it() {
|
||||
assert_spans(
|
||||
"https://en.wikipedia.org/wiki/A_(b) here",
|
||||
Kind::Metadata,
|
||||
&["https://en.wikipedia.org/wiki/A_(b)"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_inside_a_link_destination_is_not_coloured_twice() {
|
||||
assert_spans(
|
||||
"[x](https://example.com)",
|
||||
Kind::Metadata,
|
||||
&["(https://example.com)"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bracket_with_no_destination_after_it_is_left_plain() {
|
||||
assert_spans("an [aside] here", Kind::Mark, &[]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
//! `code` read once, left to right, into the spans that carry a colour.
|
||||
//! Ported from `app/.../Highlighter.kt`.
|
||||
//!
|
||||
//! One pass with a small state -- in a comment, in a string, or in ordinary
|
||||
//! code -- rather than a locator per token kind over the whole text, which
|
||||
//! is what the library this replaced did and is why it found comments
|
||||
//! before it knew the language: a `#` inside a shell string, a `//` inside
|
||||
//! a URL and a block-comment opener inside a shell glob each commented out
|
||||
//! the rest of a line that was nothing of the sort.
|
||||
//!
|
||||
//! Every span is produced by advancing an index forward, so the result is
|
||||
//! ordered, non-overlapping and inside the code by construction. Nothing
|
||||
//! here panics: an unterminated string or comment runs to the end of the
|
||||
//! code, which is also what it looks like while a fence is still being
|
||||
//! written.
|
||||
//!
|
||||
//! **Indices are char offsets, not byte offsets** -- the scanner works over
|
||||
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
|
||||
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
|
||||
//! back into the text it covers.
|
||||
|
||||
pub mod languages;
|
||||
pub mod markdown;
|
||||
|
||||
pub use languages::{
|
||||
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
||||
};
|
||||
|
||||
/// What a span of code is, in the terms a palette has a colour for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Kind {
|
||||
Keyword,
|
||||
String,
|
||||
Literal,
|
||||
Comment,
|
||||
Metadata,
|
||||
Punctuation,
|
||||
Mark,
|
||||
}
|
||||
|
||||
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
/// The text a [`Span`] covers, for a caller working in char indices (every
|
||||
/// test in this module, and any UI that also holds `code` as `Vec<char>`).
|
||||
pub fn span_text(code: &[char], span: &Span) -> String {
|
||||
code[span.start..span.end].iter().collect()
|
||||
}
|
||||
|
||||
/// The spans `language` colours in `code` -- the one way to ask, whatever
|
||||
/// the language turns out to be made of. `None` draws plain.
|
||||
pub fn spans_of(code: &str, language: Language) -> Vec<Span> {
|
||||
if language == Language::Markdown {
|
||||
markdown::scan_markdown(code)
|
||||
} else {
|
||||
scan(code, &rules_for(language))
|
||||
}
|
||||
}
|
||||
|
||||
/// `code` read into the spans [`Rules`] describes. Also reachable directly
|
||||
/// for a caller that already has a [`Rules`] (there is currently only one:
|
||||
/// [`spans_of`]), kept public because the Kotlin original exposed it the
|
||||
/// same way.
|
||||
pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
||||
Scanner::new(code, rules).run()
|
||||
}
|
||||
|
||||
/// Characters coloured as punctuation, and as marks. Both sets are the ones
|
||||
/// the library this replaced used.
|
||||
const PUNCTUATION: &str = ",.:;";
|
||||
const MARKS: &str = "()={}<>-+[]|&";
|
||||
|
||||
struct Scanner<'a> {
|
||||
code: Vec<char>,
|
||||
rules: &'a Rules,
|
||||
spans: Vec<Span>,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Scanner<'a> {
|
||||
fn new(code: &str, rules: &'a Rules) -> Self {
|
||||
Self {
|
||||
code: code.chars().collect(),
|
||||
rules,
|
||||
spans: Vec::new(),
|
||||
at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut self) -> Vec<Span> {
|
||||
while self.at < self.code.len() {
|
||||
// Every branch that answers true has advanced `self.at`, so
|
||||
// this terminates.
|
||||
let consumed = self.block_comment()
|
||||
|| self.line_comment()
|
||||
|| self.raw_string()
|
||||
|| self.character_or_lifetime()
|
||||
|| self.string()
|
||||
|| self.attribute()
|
||||
|| self.number()
|
||||
|| self.word()
|
||||
|| self.single_character();
|
||||
if !consumed {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
self.spans
|
||||
}
|
||||
|
||||
fn emit(&mut self, start: usize, kind: Kind) {
|
||||
if self.at > start {
|
||||
self.spans.push(Span {
|
||||
start,
|
||||
end: self.at,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn starts(&self, token: &str) -> bool {
|
||||
starts_with_at(&self.code, self.at, token)
|
||||
}
|
||||
|
||||
/// Whether a line comment token here opens one; see
|
||||
/// [`Rules::line_comments_at_word_start`].
|
||||
fn at_word_start(&self) -> bool {
|
||||
self.at == 0
|
||||
|| self.code[self.at - 1].is_whitespace()
|
||||
|| ";|&(".contains(self.code[self.at - 1])
|
||||
}
|
||||
|
||||
/// Whether only whitespace stands between the start of this line and here.
|
||||
fn at_line_start(&self) -> bool {
|
||||
let mut back = self.at as isize - 1;
|
||||
while back >= 0 && self.code[back as usize] != '\n' {
|
||||
if !self.code[back as usize].is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
back -= 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn advance_to_end_of_line(&mut self) {
|
||||
while self.at < self.code.len() && self.code[self.at] != '\n' {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// From an open bracket through the one that matches it, or to the end
|
||||
/// if none does.
|
||||
fn advance_to_matching_bracket(&mut self) {
|
||||
let mut depth = 0i32;
|
||||
while self.at < self.code.len() {
|
||||
match self.code[self.at] {
|
||||
'[' => depth += 1,
|
||||
']' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
self.at += 1;
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn block_comment(&mut self) -> bool {
|
||||
let Some(comment) = self.rules.block_comment else {
|
||||
return false;
|
||||
};
|
||||
if !self.starts(comment.open) {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
self.at += comment.open.chars().count();
|
||||
let mut depth = 1i32;
|
||||
while self.at < self.code.len() && depth > 0 {
|
||||
// The closer is tried first so that a language whose two
|
||||
// delimiters are the same string -- CoffeeScript's `###` --
|
||||
// closes rather than nesting forever.
|
||||
if self.starts(comment.close) {
|
||||
depth -= 1;
|
||||
self.at += comment.close.chars().count();
|
||||
} else if comment.nests && self.starts(comment.open) {
|
||||
depth += 1;
|
||||
self.at += comment.open.chars().count();
|
||||
} else {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
self.emit(start, Kind::Comment);
|
||||
true
|
||||
}
|
||||
|
||||
fn line_comment(&mut self) -> bool {
|
||||
if !self.rules.line_comments.iter().any(|c| self.starts(c)) {
|
||||
return false;
|
||||
}
|
||||
if self.rules.line_comments_at_word_start && !self.at_word_start() {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
self.advance_to_end_of_line();
|
||||
self.emit(start, Kind::Comment);
|
||||
true
|
||||
}
|
||||
|
||||
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
||||
fn raw_string(&mut self) -> bool {
|
||||
if !self.rules.raw_strings {
|
||||
return false;
|
||||
}
|
||||
let mut ahead = self.at;
|
||||
if self.code.get(ahead) == Some(&'b') {
|
||||
ahead += 1;
|
||||
}
|
||||
if self.code.get(ahead) != Some(&'r') {
|
||||
return false;
|
||||
}
|
||||
ahead += 1;
|
||||
let mut hashes = 0usize;
|
||||
while self.code.get(ahead) == Some(&'#') {
|
||||
ahead += 1;
|
||||
hashes += 1;
|
||||
}
|
||||
if self.code.get(ahead) != Some(&'"') {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
let closer: String = std::iter::once('"')
|
||||
.chain(std::iter::repeat_n('#', hashes))
|
||||
.collect();
|
||||
let closer_chars: Vec<char> = closer.chars().collect();
|
||||
let closed = find_from(&self.code, ahead + 1, &closer_chars);
|
||||
self.at = match closed {
|
||||
Some(index) => index + closer_chars.len(),
|
||||
None => self.code.len(),
|
||||
};
|
||||
self.emit(start, Kind::String);
|
||||
true
|
||||
}
|
||||
|
||||
/// See [`Rules::lifetimes`]: an apostrophe that is not a character
|
||||
/// literal opens nothing.
|
||||
fn character_or_lifetime(&mut self) -> bool {
|
||||
if !self.rules.lifetimes || self.code[self.at] != '\'' {
|
||||
return false;
|
||||
}
|
||||
let Some(&next) = self.code.get(self.at + 1) else {
|
||||
return false;
|
||||
};
|
||||
if next == '\\' || self.code.get(self.at + 2) == Some(&'\'') {
|
||||
self.quoted(Quote {
|
||||
open: "'",
|
||||
close: "'",
|
||||
escapes: true,
|
||||
});
|
||||
} else {
|
||||
self.at += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn string(&mut self) -> bool {
|
||||
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
|
||||
// than an empty string followed by a quote.
|
||||
let mut quote: Option<Quote> = None;
|
||||
for candidate in &self.rules.quotes {
|
||||
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||
if self.starts(candidate.open) && candidate.open.chars().count() > current_len {
|
||||
quote = Some(*candidate);
|
||||
}
|
||||
}
|
||||
let Some(quote) = quote else {
|
||||
return false;
|
||||
};
|
||||
self.quoted(quote);
|
||||
true
|
||||
}
|
||||
|
||||
fn quoted(&mut self, quote: Quote) {
|
||||
let start = self.at;
|
||||
self.at += quote.open.chars().count();
|
||||
while self.at < self.code.len() {
|
||||
if quote.escapes && self.code[self.at] == '\\' && self.at + 1 < self.code.len() {
|
||||
self.at += 2;
|
||||
continue;
|
||||
}
|
||||
if self.starts(quote.close) {
|
||||
self.at += quote.close.chars().count();
|
||||
break;
|
||||
}
|
||||
self.at += 1;
|
||||
}
|
||||
self.at = self.at.min(self.code.len());
|
||||
self.emit(start, Kind::String);
|
||||
}
|
||||
|
||||
fn attribute(&mut self) -> bool {
|
||||
let start = self.at;
|
||||
match self.rules.attributes {
|
||||
Attributes::None => return false,
|
||||
Attributes::AtWord => {
|
||||
if self.code[self.at] != '@' || !is_word_start(self.code.get(self.at + 1).copied())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.at += 1;
|
||||
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
Attributes::HashBracket => {
|
||||
if self.code[self.at] != '#' {
|
||||
return false;
|
||||
}
|
||||
let mut ahead = self.at + 1;
|
||||
if self.code.get(ahead) == Some(&'!') {
|
||||
ahead += 1;
|
||||
}
|
||||
if self.code.get(ahead) != Some(&'[') {
|
||||
return false;
|
||||
}
|
||||
self.at = ahead;
|
||||
self.advance_to_matching_bracket();
|
||||
}
|
||||
Attributes::HashLine => {
|
||||
if self.code[self.at] != '#' || !self.at_line_start() {
|
||||
return false;
|
||||
}
|
||||
self.advance_to_end_of_line();
|
||||
}
|
||||
Attributes::LineBracket => {
|
||||
if self.code[self.at] != '[' || !self.at_line_start() {
|
||||
return false;
|
||||
}
|
||||
self.advance_to_matching_bracket();
|
||||
}
|
||||
}
|
||||
self.emit(start, Kind::Metadata);
|
||||
true
|
||||
}
|
||||
|
||||
/// A number is a run starting with a digit and carrying on through
|
||||
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
|
||||
/// and `3.14` without a grammar for any of them.
|
||||
fn number(&mut self) -> bool {
|
||||
if !self.code[self.at].is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
while self.at < self.code.len() {
|
||||
let c = self.code[self.at];
|
||||
if c.is_alphanumeric() || c == '_' || c == '.' {
|
||||
self.at += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.emit(start, Kind::Literal);
|
||||
true
|
||||
}
|
||||
|
||||
fn word(&mut self) -> bool {
|
||||
if !is_word_start(Some(self.code[self.at])) {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
|
||||
self.at += 1;
|
||||
}
|
||||
let word: String = self.code[start..self.at].iter().collect();
|
||||
if self.rules.keywords.contains(word.as_str()) {
|
||||
self.emit(start, Kind::Keyword);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn single_character(&mut self) -> bool {
|
||||
let kind = if PUNCTUATION.contains(self.code[self.at]) {
|
||||
Kind::Punctuation
|
||||
} else if MARKS.contains(self.code[self.at]) {
|
||||
Kind::Mark
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
self.at += 1;
|
||||
self.emit(self.at - 1, kind);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_start(c: Option<char>) -> bool {
|
||||
matches!(c, Some(c) if c.is_alphabetic() || c == '_')
|
||||
}
|
||||
|
||||
fn is_word_part(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Whether `code[at..]` starts with `token`, both read as chars.
|
||||
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||
let token: Vec<char> = token.chars().collect();
|
||||
if at + token.len() > code.len() {
|
||||
return false;
|
||||
}
|
||||
code[at..at + token.len()] == token[..]
|
||||
}
|
||||
|
||||
/// The first index at or after `from` where `code` contains `needle`, or
|
||||
/// `None`.
|
||||
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||
if needle.is_empty() || from > code.len() {
|
||||
return None;
|
||||
}
|
||||
(from..=code.len().saturating_sub(needle.len())).find(|&i| code[i..i + needle.len()] == *needle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spans(code: &str, language: Language, kind: Kind) -> Vec<String> {
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
spans_of(code, language)
|
||||
.into_iter()
|
||||
.filter(|s| s.kind == kind)
|
||||
.map(|s| span_text(&chars, &s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_spans(code: &str, language: Language, kind: Kind, expected: &[&str]) {
|
||||
assert_eq!(
|
||||
spans(code, language, kind),
|
||||
expected.to_vec(),
|
||||
"{kind:?} in: {code}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quoted_glob_is_one_string_not_a_comment() {
|
||||
assert_spans("x '*/a/*'", Language::Shell, Kind::String, &["'*/a/*'"]);
|
||||
assert_spans("x '*/a/*'", Language::Shell, Kind::Comment, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_find_with_globs_has_no_comment_in_it() {
|
||||
let code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Shell,
|
||||
Kind::String,
|
||||
&["'*/.git/*'", "'*.kt'"],
|
||||
);
|
||||
assert_spans(code, Language::Shell, Kind::Comment, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_does_not_comment_out_the_rest_of_a_shell_line() {
|
||||
let code = "curl https://example.com/x && echo done";
|
||||
assert_spans(code, Language::Shell, Kind::Comment, &[]);
|
||||
assert_spans(code, Language::Shell, Kind::Keyword, &["echo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_inside_a_kotlin_string_stays_a_string() {
|
||||
let code = "val url = \"https://example.com\"\nfun f() = 1";
|
||||
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Kotlin,
|
||||
Kind::String,
|
||||
&["\"https://example.com\""],
|
||||
);
|
||||
assert_spans(code, Language::Kotlin, Kind::Keyword, &["val", "fun"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_attribute_is_metadata_and_the_struct_after_it_still_colours() {
|
||||
let code = "#[derive(Debug)]\nstruct A { b: u8 }";
|
||||
assert_spans(code, Language::Rust, Kind::Metadata, &["#[derive(Debug)]"]);
|
||||
assert_spans(code, Language::Rust, Kind::Comment, &[]);
|
||||
assert_spans(code, Language::Rust, Kind::Keyword, &["struct"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inner_rust_attribute_closes_at_its_own_bracket() {
|
||||
let code = "#![allow(dead_code)]\nfn f() {}";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Rust,
|
||||
Kind::Metadata,
|
||||
&["#![allow(dead_code)]"],
|
||||
);
|
||||
assert_spans(code, Language::Rust, Kind::Keyword, &["fn"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_c_preprocessor_line_is_metadata_rather_than_a_comment() {
|
||||
let code = "#include <stdio.h>\nint main() { return 0; }";
|
||||
assert_spans(code, Language::C, Kind::Metadata, &["#include <stdio.h>"]);
|
||||
assert_spans(code, Language::C, Kind::Comment, &[]);
|
||||
assert_spans(code, Language::C, Kind::Keyword, &["int", "return"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kotlin_annotation_is_metadata() {
|
||||
assert_spans(
|
||||
"@Composable fun f() {}",
|
||||
Language::Kotlin,
|
||||
Kind::Metadata,
|
||||
&["@Composable"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_inside_a_kotlin_string_is_not_a_comment() {
|
||||
let code = "val c = \"#FF0000\"\nval d = 1";
|
||||
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
|
||||
assert_spans(code, Language::Kotlin, Kind::String, &["\"#FF0000\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_apostrophe_inside_a_kotlin_string_does_not_open_one() {
|
||||
let code = "val a = \"don't\"\nval b = \"x\"";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Kotlin,
|
||||
Kind::String,
|
||||
&["\"don't\"", "\"x\""],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_lifetime_does_not_open_a_string_but_a_character_literal_does() {
|
||||
let code = "fn f<'a>(x: &'a str) { let c = 'x'; }";
|
||||
assert_spans(code, Language::Rust, Kind::String, &["'x'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escaped_quote_is_inside_the_rust_character_literal() {
|
||||
assert_spans("let c = '\\'';", Language::Rust, Kind::String, &["'\\''"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_raw_string_keeps_its_inner_quotes() {
|
||||
let code = "let s = r#\"a \"quoted\" b\"#;";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Rust,
|
||||
Kind::String,
|
||||
&["r#\"a \"quoted\" b\"#"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kotlin_triple_quoted_string_is_one_string() {
|
||||
assert_spans(
|
||||
"val s = \"\"\"a \"b\" c\"\"\"",
|
||||
Language::Kotlin,
|
||||
Kind::String,
|
||||
&["\"\"\"a \"b\" c\"\"\""],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_single_quoted_string_takes_no_escapes() {
|
||||
assert_spans("echo 'a\\' b", Language::Shell, Kind::String, &["'a\\'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_and_kotlin_nest_block_comments() {
|
||||
let code = "/* a /* b */ c */ x";
|
||||
assert_spans(code, Language::Rust, Kind::Comment, &["/* a /* b */ c */"]);
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Kotlin,
|
||||
Kind::Comment,
|
||||
&["/* a /* b */ c */"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_ends_a_block_comment_at_the_first_close() {
|
||||
assert_spans(
|
||||
"/* a /* b */ c */ x",
|
||||
Language::C,
|
||||
Kind::Comment,
|
||||
&["/* a /* b */"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_comment_starts_only_at_a_word_boundary() {
|
||||
let code = "${#x} $# a#b # real";
|
||||
assert_spans(code, Language::Shell, Kind::Comment, &["# real"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_anywhere_is_a_python_comment() {
|
||||
assert_spans("x = 1 # note", Language::Python, Kind::Comment, &["# note"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_toml_table_header_is_metadata_and_a_hash_in_a_value_is_not_a_comment() {
|
||||
let code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one";
|
||||
assert_spans(code, Language::Toml, Kind::Metadata, &["[server]"]);
|
||||
assert_spans(code, Language::Toml, Kind::String, &["\"#FF0000\""]);
|
||||
assert_spans(code, Language::Toml, Kind::Comment, &["# the real one"]);
|
||||
assert_spans(code, Language::Toml, Kind::Literal, &["8080"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ron_attribute_and_its_values_colour() {
|
||||
let code = "#![enable(implicit_some)]\n(count: 3, on: true)";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Ron,
|
||||
Kind::Metadata,
|
||||
&["#![enable(implicit_some)]"],
|
||||
);
|
||||
assert_spans(code, Language::Ron, Kind::Keyword, &["true"]);
|
||||
assert_spans(code, Language::Ron, Kind::Literal, &["3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_fence_language_is_none() {
|
||||
assert_eq!(fence_language(Some("brainfuck")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_language_the_fence_table_knows_has_a_scanner() {
|
||||
for language in Language::ALL {
|
||||
spans_of("x", language);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scanner must never panic and must never answer a span the code
|
||||
/// does not contain: the library this replaced answered a reversed
|
||||
/// range here, which crashed a card, and a fence still being written is
|
||||
/// an unterminated string or comment on every keystroke.
|
||||
#[test]
|
||||
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||
let nasty = [
|
||||
"",
|
||||
"'",
|
||||
"\"",
|
||||
"\"unterminated",
|
||||
"/* unterminated",
|
||||
"###",
|
||||
"#",
|
||||
"#.collect();
|
||||
let spans = spans_of(code, language);
|
||||
for s in &spans {
|
||||
assert!(
|
||||
s.start <= s.end && s.end <= chars.len(),
|
||||
"{language:?} answered {s:?} for {code:?}"
|
||||
);
|
||||
}
|
||||
let mut sorted = spans.clone();
|
||||
sorted.sort_by_key(|s| s.start);
|
||||
assert_eq!(
|
||||
spans, sorted,
|
||||
"{language:?} answered spans out of order for {code:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! The app's pure logic, shared between the server and any Rust client --
|
||||
//! see `docs/CLIENT_CORE.md` for what lives here and what does
|
||||
//! not yet.
|
||||
|
||||
pub mod ansi;
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod durations;
|
||||
pub mod event_stream;
|
||||
pub mod highlight;
|
||||
pub mod markdown_blocks;
|
||||
pub mod notifications;
|
||||
pub mod sse;
|
||||
pub mod tool_summary;
|
||||
pub mod transcript_cache;
|
||||
pub mod transcript_fold;
|
||||
pub mod transcript_source;
|
||||
|
||||
pub use event_model::*;
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Split a markdown message into its top-level **blocks** -- one
|
||||
//! paragraph, heading, fenced code block, list, table or quote each, as a
|
||||
//! byte slice of the original source.
|
||||
//!
|
||||
//! This exists for streaming. A transcript row used to be one text widget
|
||||
//! holding the whole message, so a single streamed delta re-shaped every
|
||||
//! paragraph of it through the text engine again; the phone's bench v2 put
|
||||
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
|
||||
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
|
||||
//! block now, and a delta that lands in the last block leaves every
|
||||
//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has
|
||||
//! what that rejected and why the split lives here rather than in the UI
|
||||
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
|
||||
//! keeping it here means iris stays a text renderer that knows nothing
|
||||
//! about markdown.
|
||||
//!
|
||||
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
|
||||
//! renderer's own job, per block -- this deliberately does not build a
|
||||
//! full AST, because nothing needs one yet.
|
||||
//!
|
||||
//! ## Appending is not guaranteed to leave earlier blocks alone
|
||||
//!
|
||||
//! It nearly always does, which is what makes the fast path worth having,
|
||||
//! but markdown has no such rule: appending a "```" line can turn text
|
||||
//! that was three paragraphs into one fenced block, and appending "---"
|
||||
//! under a paragraph turns that paragraph into a heading. So a caller
|
||||
//! taking the O(last block) path **must compare the prefix it is about to
|
||||
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
|
||||
//! it is cheap next to laying the text out again.
|
||||
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag};
|
||||
|
||||
/// What a block is, for a renderer that wants to style or space blocks
|
||||
/// differently. `Other` is deliberately present rather than a panic or a
|
||||
/// silent fallback to `Paragraph`: markdown has more block kinds than this
|
||||
/// list and more get added, and a renderer treating an unknown one as
|
||||
/// prose is right, but it should be able to *tell* that is what it is
|
||||
/// doing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockKind {
|
||||
Paragraph,
|
||||
Heading,
|
||||
/// A fenced or indented code block.
|
||||
Code,
|
||||
List,
|
||||
Table,
|
||||
Quote,
|
||||
/// A thematic break, raw HTML, a footnote -- anything with no
|
||||
/// distinguished treatment here.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// One top-level block: its kind and the exact source that produced it.
|
||||
/// `source` is a slice of the input with trailing whitespace removed, so
|
||||
/// two splits of the same prefix compare equal even when one of them had a
|
||||
/// delta arriving after it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Block {
|
||||
pub kind: BlockKind,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
fn kind_of(tag: &Tag) -> BlockKind {
|
||||
match tag {
|
||||
Tag::Paragraph => BlockKind::Paragraph,
|
||||
Tag::Heading { .. } => BlockKind::Heading,
|
||||
Tag::CodeBlock(_) => BlockKind::Code,
|
||||
Tag::List(_) => BlockKind::List,
|
||||
Tag::Table(_) => BlockKind::Table,
|
||||
Tag::BlockQuote(_) => BlockKind::Quote,
|
||||
_ => BlockKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn options() -> Options {
|
||||
// The same set `transcript-ui`'s renderer parses with, so a block
|
||||
// boundary here and the styling there cannot disagree about what the
|
||||
// source means.
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||
}
|
||||
|
||||
/// Split `src` into its top-level blocks, in source order. An empty or
|
||||
/// whitespace-only input gives no blocks; text the parser does not put
|
||||
/// inside any block (a stray fence marker mid-stream) still comes back,
|
||||
/// as `Other`, rather than being dropped.
|
||||
pub fn split_blocks(src: &str) -> Vec<Block> {
|
||||
let mut out: Vec<Block> = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut kind = BlockKind::Other;
|
||||
for (event, range) in Parser::new_ext(src, options()).into_offset_iter() {
|
||||
match event {
|
||||
Event::Start(tag) => {
|
||||
if depth == 0 {
|
||||
kind = kind_of(&tag);
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
Event::End(_) => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
push(&mut out, kind, &src[range]);
|
||||
}
|
||||
}
|
||||
// A top-level event that is not part of any block -- a
|
||||
// thematic break, a block of raw HTML. Inside one, it is the
|
||||
// enclosing block's business and this does nothing.
|
||||
_ => {
|
||||
if depth == 0 {
|
||||
push(&mut out, BlockKind::Other, &src[range]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push(out: &mut Vec<Block>, kind: BlockKind, source: &str) {
|
||||
let source = source.trim_end();
|
||||
if source.is_empty() {
|
||||
return;
|
||||
}
|
||||
out.push(Block {
|
||||
kind,
|
||||
source: source.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// How many leading blocks of `old` and `new` are identical -- what a
|
||||
/// caller may keep the laid-out widgets for. See the module doc for why
|
||||
/// this is a comparison rather than an assumption.
|
||||
pub fn common_prefix(old: &[Block], new: &[Block]) -> usize {
|
||||
old.iter().zip(new).take_while(|(a, b)| a == b).count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn kinds(src: &str) -> Vec<BlockKind> {
|
||||
split_blocks(src).into_iter().map(|b| b.kind).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_splits_into_its_top_level_blocks() {
|
||||
let src = "# Title\n\nFirst para.\n\n```rust\nfn main() {}\n```\n\n- a\n- b\n";
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![
|
||||
BlockKind::Heading,
|
||||
BlockKind::Paragraph,
|
||||
BlockKind::Code,
|
||||
BlockKind::List
|
||||
]
|
||||
);
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(blocks[1].source, "First para.");
|
||||
assert_eq!(blocks[2].source, "```rust\nfn main() {}\n```");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_input_has_no_blocks() {
|
||||
assert!(split_blocks("").is_empty());
|
||||
assert!(split_blocks(" \n\n ").is_empty());
|
||||
}
|
||||
|
||||
/// The property the streaming fast path rests on, in its ordinary
|
||||
/// shape: a delta landing in the last paragraph must leave every
|
||||
/// earlier block byte-identical.
|
||||
#[test]
|
||||
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
|
||||
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
|
||||
let after = split_blocks("# Title\n\nFirst para.\n\nSecond paragraph now.");
|
||||
assert_eq!(common_prefix(&before, &after), 2);
|
||||
assert_eq!(before.len(), 3);
|
||||
assert_eq!(after.len(), 3);
|
||||
assert_ne!(before[2], after[2]);
|
||||
}
|
||||
|
||||
/// A delta that starts a *new* block keeps every old block, including
|
||||
/// the one that was last -- so the fast path appends rather than
|
||||
/// replacing.
|
||||
#[test]
|
||||
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
|
||||
let before = split_blocks("First para.\n\nSecond para.");
|
||||
let after = split_blocks("First para.\n\nSecond para.\n\nThird");
|
||||
assert_eq!(common_prefix(&before, &after), 2);
|
||||
assert_eq!(after.len(), 3);
|
||||
}
|
||||
|
||||
/// A code fence arrives one delta at a time and is unterminated for
|
||||
/// most of its life. It must still be *one* block the whole way, or
|
||||
/// every delta would re-split the message into a different number of
|
||||
/// pieces.
|
||||
#[test]
|
||||
fn an_unterminated_fence_is_one_block_while_it_streams() {
|
||||
for src in [
|
||||
"Here:\n\n```rust\n",
|
||||
"Here:\n\n```rust\nfn main() {\n",
|
||||
"Here:\n\n```rust\nfn main() {\n println!(\"hi\");\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
kinds(src),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code],
|
||||
"{src:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The half the fast path had no reason to touch, and the reason
|
||||
/// `common_prefix` is a comparison rather than an assumption:
|
||||
/// appending can rewrite what came before. `---` under a paragraph
|
||||
/// turns that paragraph into a setext heading, so the block that was
|
||||
/// already laid out is not the block it is now.
|
||||
#[test]
|
||||
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
|
||||
let before = split_blocks("Not a heading\n\nsecond");
|
||||
let after = split_blocks("Not a heading\n\nsecond\n---");
|
||||
assert_eq!(before[1].kind, BlockKind::Paragraph);
|
||||
assert_eq!(after[1].kind, BlockKind::Heading);
|
||||
assert_eq!(
|
||||
common_prefix(&before, &after),
|
||||
1,
|
||||
"the rewritten block must not be reported as keepable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thematic_break_is_its_own_block() {
|
||||
assert_eq!(
|
||||
kinds("one\n\n---\n\ntwo"),
|
||||
vec![BlockKind::Paragraph, BlockKind::Other, BlockKind::Paragraph]
|
||||
);
|
||||
}
|
||||
|
||||
/// The shapes a real transcript actually contains, each checked for
|
||||
/// the one property the streaming fast path needs: the *number* of
|
||||
/// blocks and every earlier block's source stay put while the message
|
||||
/// grows. A fence's own blank lines, a `---` inside one, a nested
|
||||
/// list and a table are all places where a naive line-based split
|
||||
/// would break the message into more pieces than there are blocks.
|
||||
#[test]
|
||||
fn the_transcripts_own_block_shapes_survive_a_split() {
|
||||
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
|
||||
assert_eq!(
|
||||
kinds(fence_with_blanks),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code, BlockKind::Paragraph],
|
||||
"a blank line inside a fence is not a block boundary"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("```\n---\n```"),
|
||||
vec![BlockKind::Code],
|
||||
"a thematic break inside a fence is code, not a break"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("- a\n - a1\n - a2\n- b"),
|
||||
vec![BlockKind::List],
|
||||
"a nested list is one top-level block"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("## Heading\n```sh\nls\n```"),
|
||||
vec![BlockKind::Heading, BlockKind::Code],
|
||||
"a fence directly under a heading, with no blank line"
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("| a | b |\n|---|---|\n| 1 | 2 |"),
|
||||
vec![BlockKind::Table]
|
||||
);
|
||||
assert_eq!(
|
||||
kinds("> quoted\n> more\n\nplain"),
|
||||
vec![BlockKind::Quote, BlockKind::Paragraph]
|
||||
);
|
||||
}
|
||||
|
||||
/// `apply_delta`'s precondition, stated as the property rather than
|
||||
/// the arithmetic: for every prefix of a realistic streamed message,
|
||||
/// the blocks before the last one must be exactly the blocks the
|
||||
/// previous prefix had. Where markdown breaks that (the `---` case
|
||||
/// above), `common_prefix` has to *say* so -- which is what the
|
||||
/// `>= len - 1` assertion below checks: the split may rewrite the
|
||||
/// last block, never an earlier one, or `RowBlocks::apply_delta`
|
||||
/// would keep a widget whose text is no longer what it holds.
|
||||
#[test]
|
||||
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
|
||||
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
|
||||
// Every character boundary, so a delta landing mid-word and one
|
||||
// landing exactly on a fence's closing backtick are both covered.
|
||||
let mut prev = Vec::new();
|
||||
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
|
||||
let now = split_blocks(&full[..end]);
|
||||
let common = common_prefix(&prev, &now);
|
||||
assert!(
|
||||
prev.is_empty() || common + 1 >= prev.len(),
|
||||
"at {end} bytes the split rewrote block {common} of {}, not just the last one:\n before={prev:#?}\nafter={now:#?}",
|
||||
prev.len()
|
||||
);
|
||||
prev = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// The half a growing message cannot show: a fence that never closes.
|
||||
/// The stream ends there and the block must still be the code block
|
||||
/// it has been all along, not re-split into paragraphs.
|
||||
#[test]
|
||||
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
|
||||
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
|
||||
let blocks = split_blocks(src);
|
||||
assert_eq!(
|
||||
blocks.iter().map(|b| b.kind).collect::<Vec<_>>(),
|
||||
vec![BlockKind::Paragraph, BlockKind::Code]
|
||||
);
|
||||
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
|
||||
}
|
||||
|
||||
/// A delta that closes a fence changes the *last* block only, so the
|
||||
/// fast path takes it -- the case the module doc says is the reason
|
||||
/// `common_prefix` is a comparison.
|
||||
#[test]
|
||||
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
|
||||
let before = split_blocks("Text.\n\n```\ncode\n");
|
||||
let after = split_blocks("Text.\n\n```\ncode\n```");
|
||||
assert_eq!(before.len(), after.len());
|
||||
assert_eq!(common_prefix(&before, &after), 1);
|
||||
assert_ne!(before[1], after[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
||||
//! places, never both" describes. Ported from the parsing half of
|
||||
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
||||
//! ([`crate::sse`]) and the wire shape ([`SessionNotification`],
|
||||
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||
//! `Notification`/`NotificationKind`).
|
||||
//!
|
||||
//! What is deliberately **not** here, because it is a decision rather than
|
||||
//! logic: whether a given notification is shown at all (the session on
|
||||
//! screen gets nothing), handed to the app as a banner, or posted to the
|
||||
//! platform's own notification drawer. That three-way choice reads
|
||||
//! process-wide state (what screen is open, whether the app is in front)
|
||||
//! that has no meaning to a pure crate with no UI and no Android in it --
|
||||
//! see `android-shell` for where it lives for this port.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{ApiError, Transport};
|
||||
use crate::sse::SseReader;
|
||||
|
||||
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
|
||||
/// `Notification` field for field.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionNotification {
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
pub kind: NotificationKind,
|
||||
/// Epoch seconds, so a phone that was asleep can say how long ago.
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
|
||||
/// the same way, so this deserializes the wire's `"awaitingInput"` /
|
||||
/// `"finished"` directly rather than through a string match.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NotificationKind {
|
||||
AwaitingInput,
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl NotificationKind {
|
||||
/// What a notification asks of the reader, in the words they see --
|
||||
/// ported verbatim from `Notifications.kt`'s `attentionLine`. One
|
||||
/// function because the same fact is shown in two places (the
|
||||
/// platform's drawer and the app's own banner) and two mappings of one
|
||||
/// word drift.
|
||||
pub fn attention_line(self) -> &'static str {
|
||||
match self {
|
||||
NotificationKind::AwaitingInput => "Waiting for you",
|
||||
NotificationKind::Finished => "Finished",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows `/notifications`, calling `on_notification` for each frame until
|
||||
/// the connection drops or the callback asks to stop (by returning
|
||||
/// `false`). Reconnecting is the caller's job -- mirroring
|
||||
/// `NotificationService.follow`'s retry loop, which is a platform policy
|
||||
/// (how long to wait, whether to give up) rather than parsing logic.
|
||||
pub fn follow_notifications(
|
||||
transport: &dyn Transport,
|
||||
mut on_notification: impl FnMut(SessionNotification) -> bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let body = transport.stream("/notifications")?;
|
||||
let mut lines = BufReader::new(body).lines();
|
||||
let mut reader = SseReader::new();
|
||||
while let Some(line) = lines.next().transpose().map_err(|e| ApiError {
|
||||
message: format!("Can't reach the server -- retrying. ({e})"),
|
||||
status: None,
|
||||
})? {
|
||||
let Some(frame) = reader.feed_line(&line) else {
|
||||
continue;
|
||||
};
|
||||
if frame.data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let notification: SessionNotification =
|
||||
serde_json::from_str(&frame.data).map_err(|e| ApiError {
|
||||
message: format!("The server sent a notification this build couldn't parse: {e}"),
|
||||
status: None,
|
||||
})?;
|
||||
if !on_notification(notification) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::{Body, RawResponse};
|
||||
use std::io::Cursor;
|
||||
|
||||
struct FixtureTransport {
|
||||
body: &'static str,
|
||||
}
|
||||
|
||||
impl Transport for FixtureTransport {
|
||||
fn request(
|
||||
&self,
|
||||
_method: &str,
|
||||
_path: &str,
|
||||
_body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError> {
|
||||
unimplemented!("this fixture only serves a stream")
|
||||
}
|
||||
|
||||
fn stream(&self, _path: &str) -> Result<Box<dyn std::io::Read + Send>, ApiError> {
|
||||
Ok(Box::new(Cursor::new(self.body.as_bytes().to_vec())))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notification_frame_parses_both_kinds() {
|
||||
let transport = FixtureTransport {
|
||||
body: "data:{\"sessionId\":\"s1\",\"title\":\"fix the bug\",\"kind\":\"awaitingInput\",\"at\":1.0}\n\n\
|
||||
data:{\"sessionId\":\"s2\",\"title\":\"add tests\",\"kind\":\"finished\",\"at\":2.0}\n\n",
|
||||
};
|
||||
let mut seen = Vec::new();
|
||||
follow_notifications(&transport, |n| {
|
||||
seen.push((n.session_id, n.kind));
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec![
|
||||
("s1".to_string(), NotificationKind::AwaitingInput),
|
||||
("s2".to_string(), NotificationKind::Finished),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caller_can_stop_early() {
|
||||
let transport = FixtureTransport {
|
||||
body: "data:{\"sessionId\":\"s1\",\"title\":\"a\",\"kind\":\"finished\",\"at\":1.0}\n\n\
|
||||
data:{\"sessionId\":\"s2\",\"title\":\"b\",\"kind\":\"finished\",\"at\":2.0}\n\n",
|
||||
};
|
||||
let mut count = 0;
|
||||
follow_notifications(&transport, |_| {
|
||||
count += 1;
|
||||
count < 1
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attention_line_matches_the_kotlin_original() {
|
||||
assert_eq!(
|
||||
NotificationKind::AwaitingInput.attention_line(),
|
||||
"Waiting for you"
|
||||
);
|
||||
assert_eq!(NotificationKind::Finished.attention_line(), "Finished");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
|
||||
//! `event:` lines accumulate until a blank line ends the frame, comments
|
||||
//! start with `:`, and a frame is either named with no payload or a payload
|
||||
//! with no name.
|
||||
//!
|
||||
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
|
||||
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
|
||||
//! caller here feeds lines from wherever they came from (a real connection,
|
||||
//! a test fixture) and gets frames back with no I/O of its own -- which is
|
||||
//! what lets this be tested with no server, per RUST.md's "pure logic
|
||||
//! first" for this crate.
|
||||
|
||||
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
pub name: Option<String>,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Accumulates lines into [`Frame`]s. One instance per connection --
|
||||
/// `feed_line` is called for every line the transport reads (with line
|
||||
/// endings already stripped), and answers a frame when a blank line closes
|
||||
/// one.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SseReader {
|
||||
data: String,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
impl SseReader {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Feeds one line (no trailing `\n`). Answers the frame this line
|
||||
/// completed, if any.
|
||||
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
||||
if line.is_empty() {
|
||||
if self.name.is_some() || !self.data.is_empty() {
|
||||
let frame = Frame {
|
||||
name: self.name.take(),
|
||||
data: std::mem::take(&mut self.data),
|
||||
};
|
||||
return Some(frame);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
self.data.push_str(rest.trim());
|
||||
} else if let Some(rest) = line.strip_prefix("event:") {
|
||||
self.name = Some(rest.trim().to_string());
|
||||
}
|
||||
// `id:`, comments -- nothing to do.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frames(lines: &[&str]) -> Vec<Frame> {
|
||||
let mut reader = SseReader::new();
|
||||
lines.iter().filter_map(|l| reader.feed_line(l)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_only_frame_has_no_name() {
|
||||
assert_eq!(
|
||||
frames(&["data:hello", ""]),
|
||||
vec![Frame {
|
||||
name: None,
|
||||
data: "hello".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_frame_with_no_payload_still_completes() {
|
||||
assert_eq!(
|
||||
frames(&["event:reset", ""]),
|
||||
vec![Frame {
|
||||
name: Some("reset".to_string()),
|
||||
data: String::new()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_line_with_nothing_pending_yields_no_frame() {
|
||||
assert_eq!(frames(&[""]), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_and_an_id_line_are_ignored() {
|
||||
assert_eq!(
|
||||
frames(&[":keepalive", "id:5", "data:hi", ""]),
|
||||
vec![Frame {
|
||||
name: None,
|
||||
data: "hi".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_frames_in_a_row_are_both_reported() {
|
||||
assert_eq!(
|
||||
frames(&["data:one", "", "data:two", ""]),
|
||||
vec![
|
||||
Frame {
|
||||
name: None,
|
||||
data: "one".to_string()
|
||||
},
|
||||
Frame {
|
||||
name: None,
|
||||
data: "two".to_string()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! A tool call's input, read rather than dumped -- the port of
|
||||
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
|
||||
//! card's one-line summary and the expanded card's key/value list are
|
||||
//! derived from.
|
||||
//!
|
||||
//! Every tool's input arrives as JSON, and showing it raw makes the reader
|
||||
//! parse `{"command":"…","timeout":120000}` themselves to find the one
|
||||
//! line they care about. So the fields that carry the meaning are pulled
|
||||
//! out, and anything left over is still shown, because dropping a field
|
||||
//! would be claiming the tool has no other input when it might.
|
||||
//!
|
||||
//! Pure, and here rather than in the widget crate, for the reason the rest
|
||||
//! of this crate exists: the derivation is the same on a phone and on a
|
||||
//! desktop, and it is testable without a renderer.
|
||||
|
||||
use crate::durations::format_millis_text;
|
||||
use crate::highlight::Language;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A tool call's input, split into the parts a card draws separately.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ToolInput {
|
||||
/// The thing that will actually be run or read, if this tool has one.
|
||||
pub subject: Option<String>,
|
||||
/// The language [`ToolInput::subject`] is written in, for
|
||||
/// highlighting.
|
||||
pub language: Option<Language>,
|
||||
/// The tool's own one-line summary, when it wrote one.
|
||||
pub description: Option<String>,
|
||||
/// How long the call may take, in the largest units it fits. Shown
|
||||
/// apart because it is a limit on the call rather than part of what
|
||||
/// the call does.
|
||||
pub timeout: Option<String>,
|
||||
/// Everything else, as `name: value` lines. Never dropped.
|
||||
pub rest: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolInput {
|
||||
/// The one line to show when there is only room for one: what this
|
||||
/// call is for.
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.description
|
||||
.as_deref()
|
||||
.or(self.subject.as_deref())
|
||||
// A subject that is only whitespace would draw as an empty
|
||||
// summary line, which reads as a tool with nothing to say
|
||||
// rather than as one whose subject was blank.
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Which field of which tool is the subject.
|
||||
///
|
||||
/// A table rather than a chain of `if`s: adding a tool is a row, and the
|
||||
/// shape stops any of them from being the special case that gets its own
|
||||
/// code path. Unknown tools fall through to "no subject, everything is
|
||||
/// rest".
|
||||
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
||||
("Bash", "command", Some(Language::Shell)),
|
||||
("Read", "file_path", None),
|
||||
("Write", "file_path", None),
|
||||
("Edit", "file_path", None),
|
||||
("Glob", "pattern", None),
|
||||
("Grep", "pattern", None),
|
||||
("WebFetch", "url", None),
|
||||
];
|
||||
|
||||
/// Fields that are the tool's own prose about itself rather than input to
|
||||
/// it.
|
||||
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||
|
||||
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
|
||||
/// string is its own characters, anything else is its JSON form.
|
||||
///
|
||||
/// One function rather than two, because the same coercion decides both
|
||||
/// what a subject reads as and what a leftover field's value reads as, and
|
||||
/// two copies would eventually disagree about a number.
|
||||
fn as_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&Value>) -> Option<String> {
|
||||
let text = as_text(value?);
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// Split `input` (a tool call's JSON) into the parts a card draws.
|
||||
///
|
||||
/// Input that is not a JSON object -- older transcripts and some tools
|
||||
/// send a bare string -- is still the input, so it is still shown, as the
|
||||
/// whole of `rest`.
|
||||
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
||||
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
||||
return ToolInput {
|
||||
rest: match input.trim().is_empty() {
|
||||
true => Vec::new(),
|
||||
false => vec![input.to_string()],
|
||||
},
|
||||
..ToolInput::default()
|
||||
};
|
||||
};
|
||||
parse_object(tool, &json)
|
||||
}
|
||||
|
||||
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
||||
let (subject_key, language) = SUBJECTS
|
||||
.iter()
|
||||
.find(|(name, ..)| *name == tool)
|
||||
.map(|(_, key, language)| (Some(*key), *language))
|
||||
.unwrap_or((None, None));
|
||||
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
|
||||
let description = DESCRIPTIONS
|
||||
.iter()
|
||||
.find_map(|key| non_blank(json.get(*key)));
|
||||
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
||||
|
||||
// Sorted, so the leftovers are in the same order every time this call
|
||||
// is drawn rather than in whatever order the JSON happened to arrive
|
||||
// in. A field is left out only when it is already drawn somewhere
|
||||
// else on the card.
|
||||
let mut keys: Vec<&String> = json
|
||||
.keys()
|
||||
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
||||
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
|
||||
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
|
||||
.collect();
|
||||
keys.sort();
|
||||
let rest = keys
|
||||
.into_iter()
|
||||
.map(|key| format!("{key}: {}", as_text(&json[key])))
|
||||
.collect();
|
||||
|
||||
ToolInput {
|
||||
subject,
|
||||
language,
|
||||
description,
|
||||
timeout,
|
||||
rest,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_tool_in_the_table_has_its_own_subject() {
|
||||
// One assertion per row of `SUBJECTS`, because the table is the
|
||||
// whole of the rule and a row lost in an edit would otherwise
|
||||
// only show up as a card with no summary line.
|
||||
let cases = [
|
||||
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
||||
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
||||
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
|
||||
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
|
||||
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
|
||||
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
|
||||
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
|
||||
];
|
||||
for (tool, input, expected) in cases {
|
||||
let parsed = parse_tool_input(tool, input);
|
||||
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
|
||||
assert_eq!(parsed.title(), Some(expected), "{tool}");
|
||||
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
|
||||
}
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
|
||||
Some(Language::Shell),
|
||||
"a Bash command is shell, and is the one row that names a language"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tools_own_description_is_what_the_one_line_says() {
|
||||
// The description wins over the subject: it is the tool's own
|
||||
// prose about what this call is for, which is what a reader
|
||||
// scanning a collapsed run is looking for.
|
||||
let parsed = parse_tool_input(
|
||||
"Bash",
|
||||
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
||||
);
|
||||
assert_eq!(parsed.title(), Some("Run the iris tests"));
|
||||
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
|
||||
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
|
||||
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
|
||||
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
||||
// The half the "never dropped" promise is about: a tool this
|
||||
// build has never heard of has no subject, so *everything* is
|
||||
// rest -- and a known tool's extra fields are too.
|
||||
let parsed = parse_tool_input(
|
||||
"Edit",
|
||||
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec![
|
||||
"new_string: y".to_string(),
|
||||
"old_string: x".to_string(),
|
||||
"replace_all: true".to_string(),
|
||||
],
|
||||
"sorted, and a non-string value written as JSON"
|
||||
);
|
||||
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
|
||||
assert_eq!(unknown.subject, None);
|
||||
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_that_is_not_an_object_is_still_the_input() {
|
||||
// Older transcripts and some tools send a bare string; a card
|
||||
// that dropped it would claim the call had no input at all.
|
||||
assert_eq!(
|
||||
parse_tool_input("Bash", "just a string").rest,
|
||||
vec!["just a string".to_string()]
|
||||
);
|
||||
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
|
||||
assert_eq!(parse_tool_input("Bash", "").title(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
|
||||
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
||||
assert_eq!(parsed.subject, None);
|
||||
assert_eq!(parsed.title(), None);
|
||||
// Not dropped just because it was blank -- it is still a field
|
||||
// the call carried.
|
||||
assert_eq!(
|
||||
parsed.rest,
|
||||
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
//! Where a session screen gets a transcript from: this phone's copy first,
|
||||
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
|
||||
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
|
||||
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
|
||||
//!
|
||||
//! One seam rather than a cache the screen has to remember to consult.
|
||||
//! Everything fetched before is asked of this, and everything the server
|
||||
//! sends is written into the cache on the way past, so a caller never
|
||||
//! learns which side answered. The one rule worth keeping in mind: the
|
||||
//! cache is never load-bearing. Every read here has a network path beside
|
||||
//! it producing the same result.
|
||||
//!
|
||||
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
|
||||
//! ability to close a live stream from another thread. Both are wall-clock
|
||||
//! and thread-lifetime concerns that belong to whatever runtime the caller
|
||||
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
|
||||
//! scope) rather than to this pure logic -- `follow` below is the same
|
||||
//! decorator shape `iris/desktop-app/src/app.rs` and
|
||||
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
|
||||
//! `event_stream::follow_session_events`, just with the cache write built
|
||||
//! in so a future caller does not have to repeat it a third time.
|
||||
|
||||
use event_model::SeqEvent;
|
||||
|
||||
use crate::api::{ApiClient, ApiError, Transport};
|
||||
use crate::event_stream::{self, StreamItem};
|
||||
use crate::transcript_cache::SessionCache;
|
||||
|
||||
/// How many events a session screen opens with, cached or fetched.
|
||||
///
|
||||
/// The server's own default page size, named here because the cached
|
||||
/// opening has to be the same size as the fetched one -- a reader must not
|
||||
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
|
||||
/// in the Kotlin original).
|
||||
pub const OPENING_WINDOW: u32 = 80;
|
||||
|
||||
/// A transcript-line parse failure, told apart from [`ApiError`] so a
|
||||
/// caller can tell "the server is unreachable" from "the server (or this
|
||||
/// phone's own disk) sent something this build cannot read" -- the two
|
||||
/// mean different things to a reader (retry, versus a build that is
|
||||
/// behind).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParseError(pub String);
|
||||
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
/// Either half of what can go wrong asking for a page: the network, or a
|
||||
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
|
||||
/// read.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PageError {
|
||||
Api(ApiError),
|
||||
Parse(ParseError),
|
||||
}
|
||||
|
||||
impl From<ApiError> for PageError {
|
||||
fn from(e: ApiError) -> Self {
|
||||
Self::Api(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseError> for PageError {
|
||||
fn from(e: ParseError) -> Self {
|
||||
Self::Parse(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`TranscriptSource::page`] found, kept as two states rather than
|
||||
/// one possibly-empty list.
|
||||
///
|
||||
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
|
||||
/// empty [`Self::Events`] means "this conversation has no more history",
|
||||
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
|
||||
/// question could not be asked yet, which it must not. Collapsing the two
|
||||
/// into an empty `Vec` puts the bug back, because the caller cannot tell
|
||||
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
|
||||
/// same silently.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OlderPage {
|
||||
/// The events before the cursor, oldest first. Empty means the start
|
||||
/// of the conversation has been reached.
|
||||
Events(Vec<SeqEvent>),
|
||||
/// Nothing is loaded, so there was no cursor to page back from
|
||||
/// (`before == 0`). Not an answer about the conversation at all.
|
||||
NothingLoaded,
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||
}
|
||||
|
||||
/// This phone's copy of one session's transcript, plus the server it
|
||||
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
|
||||
pub struct TranscriptSource<T: Transport> {
|
||||
api: ApiClient<T>,
|
||||
session_id: String,
|
||||
pub cache: SessionCache,
|
||||
}
|
||||
|
||||
impl<T: Transport> TranscriptSource<T> {
|
||||
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
|
||||
Self {
|
||||
api,
|
||||
session_id: session_id.into(),
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached opening window, or `None` when there is nothing usable
|
||||
/// to draw.
|
||||
///
|
||||
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
|
||||
/// whole point of the feature: the rows are on screen while the check
|
||||
/// that they are still the server's rows is in flight, and a failed
|
||||
/// check replaces them exactly as a reset does.
|
||||
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
|
||||
self.cache.tail()?;
|
||||
let lines = self.cache.newest(limit);
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match lines.iter().map(|l| parse_line(l)).collect() {
|
||||
Ok(events) => Some(events),
|
||||
// A line this build cannot read at all, which the cache's own checks cannot
|
||||
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
|
||||
// open.
|
||||
Err(ParseError(_)) => {
|
||||
self.cache.purge();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the server's event at the cached cursor is still the cached
|
||||
/// one.
|
||||
///
|
||||
/// A caller must not resume a live stream from a cached seq unless it
|
||||
/// is the same conversation: a transcript is append-only in ordinary
|
||||
/// use, but the file backing it can be replaced or truncated (a
|
||||
/// sandbox re-seeded with the same ids, a backup restored, a session
|
||||
/// re-imported), and the server's catch-up on such a file would hand
|
||||
/// this phone a continuation of a *different* conversation, spliced
|
||||
/// onto the cached one with no seam. Caught with one request of a few
|
||||
/// hundred bytes.
|
||||
///
|
||||
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
|
||||
/// server not being askable, which is neither: the cached rows stay
|
||||
/// on screen and the caller tries again on its own reconnect schedule.
|
||||
///
|
||||
/// What this cannot see is a line changed in the middle of the file
|
||||
/// with the tail intact -- that is what a full reload is for.
|
||||
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||
let Some(tail) = self.cache.tail() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
|
||||
// event *at* the cursor when the server still has one there.
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(tail.seq + 1),
|
||||
1,
|
||||
false,
|
||||
None,
|
||||
)?;
|
||||
let matches = page.len() == 1
|
||||
&& parse_line(&tail.line)
|
||||
.map(|cached| cached == page[0].1)
|
||||
.unwrap_or(false);
|
||||
if !matches {
|
||||
self.cache.purge();
|
||||
}
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Today's opening fetch, kept as the start of the live run. Only
|
||||
/// called when the cache has nothing to open with, or when
|
||||
/// [`Self::probe`] said what it had was not the server's.
|
||||
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
|
||||
let page =
|
||||
self.api
|
||||
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
|
||||
for (line, event) in &page {
|
||||
self.cache.append(line, event.seq);
|
||||
}
|
||||
self.cache.flush();
|
||||
Ok(page.into_iter().map(|(_, event)| event).collect())
|
||||
}
|
||||
|
||||
/// The page before `before`: from the cache when it holds it,
|
||||
/// otherwise from the server bounded by what the cache already has.
|
||||
///
|
||||
/// The server bound (`after`) is what keeps the cache worth having. A
|
||||
/// coalesced page reaches back as far as its row count takes it -- a
|
||||
/// single reply is hundreds of lines -- so a page fetched after the
|
||||
/// reader has been away could run straight past the cached run and
|
||||
/// overlap it, and an overlapping page cannot be stored. Told where
|
||||
/// this phone's copy starts, the server stops there instead.
|
||||
///
|
||||
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
|
||||
/// the cache or the server anything -- see AGENTS.md's "things that
|
||||
/// have bitten": there is no event before the first one, so the
|
||||
/// request is not a harmless no-op, and its empty answer is
|
||||
/// indistinguishable from having reached the start of history.
|
||||
/// Guarded here rather than left to every caller, because it is a fact
|
||||
/// about the question, not about who is asking it.
|
||||
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||
if before == 0 {
|
||||
return Ok(OlderPage::NothingLoaded);
|
||||
}
|
||||
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
|
||||
let events: Vec<SeqEvent> = lines
|
||||
.iter()
|
||||
.map(|l| parse_line(l).map_err(PageError::from))
|
||||
.collect::<Result<_, _>>()?;
|
||||
return Ok(OlderPage::Events(events));
|
||||
}
|
||||
let after = self.cache.covered_up_to(before).map(|v| v - 1);
|
||||
let page = self.api.fetch_transcript_lines(
|
||||
&self.session_id,
|
||||
Some(before),
|
||||
limit,
|
||||
coalesce,
|
||||
after,
|
||||
)?;
|
||||
if let Some((_, first_event)) = page.first() {
|
||||
// `before` rather than the newest line's seq: a coalesced page covers
|
||||
// everything up to the cursor it was asked with, and nothing in its lines
|
||||
// says so.
|
||||
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
|
||||
self.cache
|
||||
.store_page(&lines, first_event.seq, before, coalesce);
|
||||
}
|
||||
Ok(OlderPage::Events(
|
||||
page.into_iter().map(|(_, event)| event).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// [`event_stream::follow_session_events`], with every frame written to
|
||||
/// the cache before `on_item` sees it.
|
||||
///
|
||||
/// Before, so that an event held back for a reader who is scrolled
|
||||
/// away is already on disk -- what the cache holds is what the server
|
||||
/// sent, not what a screen has got round to drawing. Flushed on each
|
||||
/// status change, which is a turn's boundary and the granularity a
|
||||
/// crash may as well lose, and once more when the stream ends.
|
||||
pub fn follow(
|
||||
&self,
|
||||
after: u64,
|
||||
mut on_item: impl FnMut(StreamItem) -> bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let cache = &self.cache;
|
||||
let result = event_stream::follow_session_events(
|
||||
self.api.transport(),
|
||||
&self.session_id,
|
||||
after,
|
||||
|item| {
|
||||
if let StreamItem::Event { raw, event } = &item {
|
||||
cache.append(raw, event.seq);
|
||||
if matches!(event.event, event_model::Event::Status { .. }) {
|
||||
cache.flush();
|
||||
}
|
||||
}
|
||||
on_item(item)
|
||||
},
|
||||
);
|
||||
cache.flush();
|
||||
result
|
||||
}
|
||||
|
||||
/// Leaves the cache with everything it was given -- called once a
|
||||
/// caller is done with this source, mirroring the Kotlin `close`'s
|
||||
/// final flush (that method's stream cancellation itself is the
|
||||
/// runtime concern the module doc says is not ported here).
|
||||
pub fn close(&self) {
|
||||
self.cache.flush();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::{Body, RawResponse};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A transport that answers fixed bodies in call order, and records
|
||||
/// every path it was asked for -- so a test can assert *how many*
|
||||
/// requests a method made, which is the point for the `before == 0`
|
||||
/// guard (AGENTS.md's regression: the guard must stop the request
|
||||
/// before it happens, not merely tolerate the empty answer).
|
||||
#[derive(Default)]
|
||||
struct ScriptedTransport {
|
||||
responses: Mutex<VecDeque<(u16, String)>>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ScriptedTransport {
|
||||
fn respond(&self, status: u16, body: impl Into<String>) {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back((status, body.into()));
|
||||
}
|
||||
|
||||
fn call_count(&self) -> usize {
|
||||
self.calls.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for ScriptedTransport {
|
||||
fn request(
|
||||
&self,
|
||||
_method: &str,
|
||||
path: &str,
|
||||
_body: Option<Body>,
|
||||
) -> Result<RawResponse, ApiError> {
|
||||
self.calls.lock().unwrap().push(path.to_string());
|
||||
let (status, body) = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
|
||||
Ok(RawResponse {
|
||||
status,
|
||||
body: body.into_bytes(),
|
||||
})
|
||||
}
|
||||
|
||||
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
|
||||
self.calls.lock().unwrap().push(path.to_string());
|
||||
let (_, body) = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| {
|
||||
panic!("ScriptedTransport got an unscripted stream request: {path}")
|
||||
});
|
||||
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
|
||||
}
|
||||
}
|
||||
|
||||
fn source(
|
||||
transport: ScriptedTransport,
|
||||
cache_root: &std::path::Path,
|
||||
) -> TranscriptSource<ScriptedTransport> {
|
||||
let api = ApiClient::new(transport);
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(cache_root).session("s1");
|
||||
TranscriptSource::new(api, "s1", cache)
|
||||
}
|
||||
|
||||
fn status_line(seq: u64) -> String {
|
||||
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
|
||||
assert_eq!(source.cached_opening(80), None);
|
||||
let opening = source.fetch_opening().unwrap();
|
||||
assert_eq!(opening.len(), 1);
|
||||
assert_eq!(opening[0].seq, 1);
|
||||
// The fetch wrote through: reopening the same cache now has something to show.
|
||||
assert!(source.cache.tail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(200, format!("[{}]", status_line(1)));
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(source2.probe().unwrap());
|
||||
assert!(source2.cache.tail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_mismatching_the_cached_tail_purges_the_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
// The server now answers with a different event at the same seq -- the file
|
||||
// behind this session was replaced.
|
||||
let transport2 = ScriptedTransport::default();
|
||||
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
|
||||
transport2.respond(200, format!("[{different}]"));
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(!source2.probe().unwrap());
|
||||
assert!(source2.cache.tail().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_finding_no_server_leaves_the_cache_untouched() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(1)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(500, "server on fire");
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
assert!(source2.probe().is_err());
|
||||
assert!(
|
||||
source2.cache.tail().is_some(),
|
||||
"an unreachable server must not be treated as a mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
/// The regression this module exists to close: `before == 0` must
|
||||
/// never reach the network or the cache, because an empty answer there
|
||||
/// is indistinguishable from "there is genuinely no more history" --
|
||||
/// AGENTS.md's `loadOlderPage` incident.
|
||||
#[test]
|
||||
fn paging_before_the_first_event_makes_no_request_at_all() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = source(transport, dir.path());
|
||||
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
|
||||
assert_eq!(source.api.transport().call_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let calls_before = source.api.transport().call_count();
|
||||
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
|
||||
panic!("a cursor of 2 is a real question about the conversation");
|
||||
};
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].seq, 1);
|
||||
assert_eq!(
|
||||
source.api.transport().call_count(),
|
||||
calls_before,
|
||||
"a cache hit must not touch the network"
|
||||
);
|
||||
}
|
||||
|
||||
/// With nothing older cached there is no floor to give the server, so
|
||||
/// the request carries no `after` at all.
|
||||
#[test]
|
||||
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(5)));
|
||||
let source = source(transport, dir.path());
|
||||
source.fetch_opening().unwrap();
|
||||
|
||||
let transport2 = ScriptedTransport::default();
|
||||
transport2.respond(200, format!("[{}]", status_line(3)));
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
|
||||
source2.page(5, 10, true).unwrap();
|
||||
assert_eq!(
|
||||
source2.api.transport().calls.lock().unwrap()[0],
|
||||
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
|
||||
);
|
||||
}
|
||||
|
||||
/// The half the test above cannot show: when the cache *does* hold an
|
||||
/// older run, the fetch is floored at its end, or the page would run
|
||||
/// straight past it and overlap -- which `store_page` then refuses,
|
||||
/// silently costing the phone the page it just paid for.
|
||||
#[test]
|
||||
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
// A stored page covering [3, 6) and two live events above it, so the run this
|
||||
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
|
||||
// cache reads the directory as damaged and discards it.
|
||||
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||
assert!(cache.store_page(&lines, 3, 6, true));
|
||||
cache.append(&status_line(6), 6);
|
||||
cache.append(&status_line(7), 7);
|
||||
cache.flush();
|
||||
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("[{}]", status_line(9)));
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
source.page(10, 10, true).unwrap();
|
||||
assert_eq!(
|
||||
source.api.transport().calls.lock().unwrap()[0],
|
||||
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
|
||||
"the fetch must stop one seq below where this phone's copy ends"
|
||||
);
|
||||
}
|
||||
|
||||
/// A page the server could not answer is an error, never an empty
|
||||
/// page: the caller would read the second as "this conversation has no
|
||||
/// more history" and stop paging for good.
|
||||
#[test]
|
||||
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(500, "server on fire");
|
||||
let source = source(transport, dir.path());
|
||||
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||
}
|
||||
|
||||
/// A cached line this build cannot read is told apart from the network
|
||||
/// failing, for the same reason: neither is "no more history".
|
||||
#[test]
|
||||
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
cache.store_page(
|
||||
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
||||
3,
|
||||
4,
|
||||
true,
|
||||
);
|
||||
cache.append(&status_line(4), 4);
|
||||
cache.flush();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
|
||||
assert_eq!(
|
||||
source.api.transport().call_count(),
|
||||
0,
|
||||
"a cache hit that cannot be read must not fall through to the server unnoticed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||
cache.append("not json at all", 1);
|
||||
cache.flush();
|
||||
let transport = ScriptedTransport::default();
|
||||
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||
assert_eq!(source.cached_opening(80), None);
|
||||
assert!(
|
||||
source.cache.tail().is_none(),
|
||||
"a damaged line purges the cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let transport = ScriptedTransport::default();
|
||||
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
|
||||
let source = source(transport, dir.path());
|
||||
let mut seen = Vec::new();
|
||||
source
|
||||
.follow(0, |item| {
|
||||
if let StreamItem::Event { event, .. } = item {
|
||||
seen.push(event.seq);
|
||||
}
|
||||
true
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seen, vec![1]);
|
||||
assert_eq!(source.cache.tail().unwrap().seq, 1);
|
||||
}
|
||||
|
||||
fn sse_frame(data: &str) -> String {
|
||||
format!("data:{data}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
# client-core
|
||||
|
||||
`client-core/` is the app's pure logic held once instead of twice, per
|
||||
RUST.md's recommendation item 1. It is a plain Rust library crate with no UI
|
||||
framework dependency of any kind, so it can outlive whichever one the app
|
||||
ends up drawing with (Masonry, iris, or something else -- see RUST.md).
|
||||
`event-model/` is its sibling: the wire shape both this crate and `server/`
|
||||
share, extracted from `server/src/session/driver.rs` and
|
||||
`session/transcript.rs` on 2026-09-04.
|
||||
|
||||
Neither crate is wired into anything yet. `server/` re-exports `event-model`
|
||||
so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core`
|
||||
has no caller -- it exists for whichever experiment in RUST.md picks it up
|
||||
next (a Masonry or iris transcript screen, most likely).
|
||||
|
||||
## What's here, and what Kotlin file it replaces
|
||||
|
||||
| `client-core/src/…` | Kotlin original | Status |
|
||||
|------------------------------------------|-------------------------------------------|--------|
|
||||
| `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done |
|
||||
| `ansi.rs` | `Ansi.kt` | Done, ported test-for-test |
|
||||
| `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test |
|
||||
| `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test |
|
||||
| `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test |
|
||||
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
|
||||
| `api.rs` | `Api.kt` | Partial -- see below |
|
||||
| `event_stream.rs` | `EventStream.kt` | Done |
|
||||
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below |
|
||||
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
|
||||
| `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below |
|
||||
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
|
||||
|
||||
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
|
||||
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
|
||||
cases ported alongside it, plus new tests for the pieces that had none
|
||||
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
|
||||
`transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt`
|
||||
had no JVM unit tests of their own, so these were written fresh against the
|
||||
Kotlin source and AGENTS.md's paging incidents as the spec). Test count by
|
||||
crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its
|
||||
types carry no logic of their own to test -- `server/`'s own tests exercise
|
||||
them via `session::transcript`'s round-trip coverage).
|
||||
|
||||
## Correspondence notes worth knowing before touching either side
|
||||
|
||||
- **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's
|
||||
`AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose.
|
||||
`StyledText` is plain text plus a `Vec<(Range<usize>, Style)>` of
|
||||
non-overlapping spans. Whatever UI framework ends up consuming this
|
||||
crate maps `Style` onto its own text-styling type; nothing here should
|
||||
change to accommodate a particular one.
|
||||
- **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets**
|
||||
(`Vec<char>` internally), mirroring the Kotlin original's `Char`-indexed
|
||||
strings. `highlight::span_text` turns a `Span` back into text for a
|
||||
caller working the same way; a caller that wants byte offsets into a
|
||||
`&str` has to convert.
|
||||
- **`transcript_cache.rs`'s `SessionCache::guard`** found a real
|
||||
translation bug while it was being written: an early draft let a
|
||||
*damaged* chunk (one file unreadable, discard just this session) and a
|
||||
genuine I/O failure (disk gone, disable the whole cache) both surface as
|
||||
the same `Err` from one closure, which would have disabled every
|
||||
session's cache over a single corrupt chunk. Fixed by checking a
|
||||
thread-local "was this damage" flag before deciding which failure mode
|
||||
it was -- see the comment on `guard` and the commit message for
|
||||
`transcript_cache.rs`.
|
||||
|
||||
## What `api.rs` covers, and what it does not yet
|
||||
|
||||
`ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so
|
||||
`ApiClient` and `event_stream::follow_session_events` are tested with a
|
||||
fake transport and no server). `UreqTransport` is the only real
|
||||
implementation, backed by `ureq` -- see its Cargo.toml comment for why
|
||||
(blocking, already a project dependency, no extra TLS crate needed since
|
||||
`ureq::tls::Certificate::from_pem` reads the pinned CA directly).
|
||||
|
||||
Covered: session list/read, message send, unqueue, answer, interrupt,
|
||||
stop, start, rename, cwd, model, permission-mode, notify, command,
|
||||
compact, delete, and one transcript page.
|
||||
|
||||
**Not covered, and each is real work rather than a stub to fill in:**
|
||||
setups (`/setups*`, machine and provider discovery), the file explorer
|
||||
(`/setups/{id}/dir|file`), usage (`/usage`), models
|
||||
(`/models*`, HuggingFace browsing and downloads), attachments
|
||||
(`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`),
|
||||
and the `/notifications` stream. `server/src/routes.rs`'s module doc is
|
||||
the full table to work from when one of these is next.
|
||||
|
||||
## What `transcript_fold.rs` covers, and what it does not yet
|
||||
|
||||
`fold_event` covers every `Event` variant server/ can produce today,
|
||||
including tool-call/question/image attachment and peer-message placement.
|
||||
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
|
||||
|
||||
`join_pages` (with `heal_split_message` and `adopt_run`, both private) is
|
||||
now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
|
||||
call split across two fetched pages, rejoins a message a boundary cut
|
||||
through, and renames a run of tool calls onto whichever name is already on
|
||||
screen. Ported with AGENTS.md's "things that have bitten" incidents as the
|
||||
spec rather than a JVM test file (`TranscriptItems.kt` had none of its
|
||||
own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
|
||||
is the regression test for the bug that shipped -- `adopt_run` must run on
|
||||
*every* join, not only the one where a split call was found, or a boundary
|
||||
landing cleanly between two already-finished calls (most of them) leaves
|
||||
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
|
||||
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
|
||||
and `adopt_run_never_renames_into_a_question_row` cover the other three
|
||||
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
|
||||
that no tool id survives in both halves -- the duplicate row it exists to
|
||||
prevent, checked rather than assumed. What it deliberately does *not*
|
||||
assert is seq ordering across the boundary: a peer note carries the seq
|
||||
its turn began at (`place_peer_note`), which can be older than the page
|
||||
it arrived in, so the two pages' seqs legitimately interleave there. An
|
||||
earlier draft asserted it and would have panicked in debug builds on an
|
||||
ordinary transcript.
|
||||
|
||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
||||
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||
A server newer than this build that adds an event type will fail to parse
|
||||
that line rather than degrading to a placeholder row. Closing this means
|
||||
deciding how `event_model` itself represents "a shape I don't recognise"
|
||||
-- a shared-model decision affecting `server/` too, not a `client-core`-only
|
||||
fix, so it is recorded here rather than silently worked around.
|
||||
|
||||
## `config.rs`: `EnrolledServer`
|
||||
|
||||
`EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads
|
||||
the exact `aiapp://enroll?host=H&port=P&token=T` deep link
|
||||
`wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment`
|
||||
parses on the phone -- so any Rust client enrols from the same text a
|
||||
phone would scan as a QR, with no second format invented for it (RUST.md's
|
||||
E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is
|
||||
persisted or under what file permissions -- a phone seals its token in the
|
||||
Android Keystore, `iris/desktop-app/src/config.rs` writes it to
|
||||
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is
|
||||
caller-specific (the code rules' "ask for the least you need"). Its only
|
||||
caller today is `desktop-app`; a future Android build of this crate would
|
||||
be a second one, not a reason to move the type.
|
||||
|
||||
## What `transcript_source.rs` covers, and what it does not
|
||||
|
||||
`TranscriptSource<T: Transport>` is the seam a session screen asks for a
|
||||
page, ported test-for-test against the Kotlin doc rather than a JVM test
|
||||
file (there wasn't one): `cached_opening`, `probe`, `fetch_opening`,
|
||||
`page` and `follow`, each matching its Kotlin namesake's contract --
|
||||
including `probe`'s three-way outcome (matches / cache purged /
|
||||
unreachable, told apart so a caller never treats "couldn't ask" as "was
|
||||
wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`.
|
||||
|
||||
Two additions beyond a literal port, both load-bearing:
|
||||
|
||||
- **`page(before, ..)` refuses `before == 0` before touching the cache or
|
||||
the network**, answering `OlderPage::NothingLoaded`. This is AGENTS.md's
|
||||
`loadOlderPage` incident (`before = 0` is "no event before the first
|
||||
one," indistinguishable from "reached the start of history" if a caller
|
||||
ever asks it) moved out of the Kotlin screen and into this layer, so
|
||||
every future caller gets the guard rather than having to remember it.
|
||||
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
|
||||
The Kotlin's two falses are different answers -- `oldestSeq == 0`
|
||||
returns without touching `moreHistory`, an empty page latches it false
|
||||
-- so a port that answered both with an empty list would have moved the
|
||||
bug rather than fixed it, one layer down and out of sight of the screen
|
||||
that used to hold the check. `OlderPage::Events(vec![])` means the start
|
||||
of the conversation; `OlderPage::NothingLoaded` is not an answer about
|
||||
the conversation at all. Reviewed 2026-09-06.
|
||||
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
|
||||
transport calls, not just the variant, since a request that happens to
|
||||
answer empty is exactly what caused the original bug, and
|
||||
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
|
||||
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
|
||||
are the same rule for the two ways a page can fail.
|
||||
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
|
||||
paired with the exact server bytes it came from, via
|
||||
`serde_json::value::RawValue` rather than re-serializing a parsed
|
||||
`Value` -- the cache and a live SSE frame for the same event have to
|
||||
agree byte-for-byte, which is exactly what the `serde_json`
|
||||
float-rounding bug (AGENTS.md) was about. The existing
|
||||
`fetch_transcript_page` is untouched (other callers under `iris/`
|
||||
depend on its signature); the two share a `transcript_path` helper so
|
||||
the query string is written in one place.
|
||||
|
||||
**Not ported:** `EventStream.kt`'s reconnect-with-backoff loop, and
|
||||
`TranscriptSource.close`'s ability to cancel a live stream from another
|
||||
thread. Both are wall-clock/thread-lifetime policy that belongs to
|
||||
whichever runtime embeds this crate (iris's own timers, a Tokio task, a
|
||||
Kotlin coroutine scope), not to this pure logic -- `follow` is the same
|
||||
"write to the cache, then hand the frame to the caller" decorator
|
||||
`iris/desktop-app/src/app.rs` and `iris/android-app/src/transcript_client.rs`
|
||||
already hand-wrote around `event_stream::follow_session_events` before this
|
||||
existed; the cache write moved into one shared place so a third caller
|
||||
does not repeat it again by hand.
|
||||
|
||||
## What is not started at all
|
||||
|
||||
- **A full markdown AST.** `markdown_blocks` (2026-09-06) splits a message
|
||||
into its *top-level* blocks -- heading, paragraph, fence, list, table,
|
||||
quote -- with each block's own source, which is what a renderer needs to
|
||||
lay out prose versus code and what lets a streamed delta re-lay out one
|
||||
block instead of the message (docs/RUST.md's Task B). What it
|
||||
deliberately does **not** build is the tree below that: nested list
|
||||
items, table cells, inline spans. Inline styling is still the renderer's
|
||||
own job per block (`iris/transcript-ui/src/markdown.rs`), and nothing
|
||||
has needed the rest yet. `CodeFence.kt`'s use of `org.intellij.markdown`
|
||||
for a full CommonMark AST is Compose rendering plumbing, not something
|
||||
to port as-is.
|
||||
- **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since
|
||||
it flattens a row into bounded units for a *specific* lazy-list
|
||||
framework's composition cost, which is a fact about that framework
|
||||
rather than about the transcript.
|
||||
|
||||
## Verifying
|
||||
|
||||
`./run-tests.sh` from the repo root now runs `event-model`, `client-core`
|
||||
and `server` in that order (each `cargo test`, forwarding arguments the
|
||||
same way it always has). From `client-core/` directly: `cargo test`
|
||||
(119 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
|
||||
this writing (2026-09-06).
|
||||
@@ -0,0 +1,444 @@
|
||||
# Decisions taken for Iris to review
|
||||
|
||||
Short list of design choices made by the design agent without asking, so
|
||||
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
||||
for iris API changes); this file is only the summary. Newest first. Items
|
||||
marked **DEFERRED** are ones the agent chose not to decide alone.
|
||||
|
||||
## 2026-09-06 (how a tool call looks, P1b)
|
||||
|
||||
- **A card that never got a result says "no result", in yellow, and it is
|
||||
a state Compose cannot say.** A call that finished having printed
|
||||
nothing and a call whose turn was interrupted before anything came back
|
||||
both leave an empty output. Compose draws both as an ordinary finished
|
||||
call, which reads as a fact somebody established. There are five states
|
||||
now, each with a word and a colour: nothing at all for a call that
|
||||
worked, "running" (grey), "your turn" (peach, Compose's own wording and
|
||||
colour), "failed" (red), "no result" (yellow).
|
||||
|
||||
- **A failed call is drawn as failed, which needed a field on the wire.**
|
||||
`is_error` is on the CLI's `tool_result` and was being dropped; the
|
||||
server now carries it to the phone. Reversible, but the alternative is a
|
||||
card that says a call succeeded because it cannot tell.
|
||||
|
||||
- **A group's cards do not each carry their own surface.** Compose gives
|
||||
each card a fill and squares the corners where it faces a neighbour, so
|
||||
a run reads as one object broken into parts. iris has no per-corner
|
||||
radius, and -- more to the point -- a group built the way Compose builds
|
||||
it hit a framework layout defect that drew every card's text a card
|
||||
below its own box. So a group is one surface with its cards on it,
|
||||
separated by a small gap, and the 4dp inset Compose holds them off the
|
||||
edge by is gone. Worth revisiting once the layout defect is fixed
|
||||
(docs/IRIS_TODO.md).
|
||||
|
||||
- **A long tool output is capped at 80 lines or 4 kB with a "Show all N
|
||||
lines".** Compose draws the whole thing, and gets away with it because
|
||||
its `Text` inside a `LazyColumn` lays out lazily; here the output is one
|
||||
text widget and shaping a hundred kilobytes of it costs what the file
|
||||
editor's 32 kB limit was measured against. If iris's text gets cheaper,
|
||||
this is the number to move.
|
||||
|
||||
- **A card's command is clipped, not pannable, and its summary line is
|
||||
clipped rather than ellipsised.** Both are framework gaps rather than
|
||||
choices (`scrollable_on` on a non-editable text draws nothing; there is
|
||||
no overflow ellipsis), and both are worse than Compose today. Named here
|
||||
because they are visible.
|
||||
|
||||
## 2026-09-06 (how a markdown block looks, P1a)
|
||||
|
||||
- **A table is drawn as padded monospace columns, not as a grid.** Your
|
||||
call to reverse. Compose draws a real grid: cells on a tint, each
|
||||
column with a 136dp floor, scrolling sideways when there are too many.
|
||||
iris has no grid widget, and building one would be a widget per
|
||||
markdown feature -- which is the thing the block model exists to avoid.
|
||||
In a monospace face a character count *is* a pixel width, so padding
|
||||
each cell to its column's width is alignment, the widths are still
|
||||
measured from the cells, and a table that is too wide pans sideways
|
||||
through the same mechanism a code fence already uses. The header is
|
||||
bold with a rule under it, and a long cell wraps inside its column
|
||||
(capped at 28 characters, which is what fits three columns across a
|
||||
phone). **What it trades:** no cell borders, and a table looks like
|
||||
code rather than like a table. If you want the grid, it is a new widget
|
||||
and it is a day's work.
|
||||
- **Three block frames, and only three.** A heading, paragraph and list
|
||||
are plain text with spans; a fence and a table are a rounded panel that
|
||||
does not wrap; a quote is a bar with the text padded past it.
|
||||
Everything else markdown says is expressed in span styles, which cost
|
||||
no widgets and no layout nodes. So a new markdown feature is a span,
|
||||
not a widget.
|
||||
- **A list's marker is part of the text, so a wrapped item's second line
|
||||
returns to the left margin.** Compose keeps it indented by giving the
|
||||
marker its own column. Doing the same here needs per-line indent in
|
||||
iris's text attributes; it is written down rather than done, because
|
||||
the list items in a real reply are usually one line.
|
||||
- **A link opens on a tap and not on the end of a drag.** A press that
|
||||
panned the transcript past a link, or that held long enough to start a
|
||||
selection, does not follow it -- decided by the same gesture machine
|
||||
that decides pan-versus-select, so there is one rule rather than two
|
||||
that can disagree.
|
||||
|
||||
## 2026-09-06 (composer scroll and the streaming block model)
|
||||
|
||||
- **A streamed message becomes a column of per-block widgets.** Decided by
|
||||
the design agent; recorded here because it is the shape of every message
|
||||
on screen. A transcript row is one `TextEdit` today, so a streamed delta
|
||||
re-shapes the entire message through parley on every event -- the stream
|
||||
phase is the one place iris is behind Compose on your phone (p50 18.2ms
|
||||
vs 13.4ms). A row becomes a column of one widget per markdown block
|
||||
(paragraph, heading, fence, list, table) and a delta replaces only the
|
||||
last block, keeping every earlier block's layout. **Rejected:** splitting
|
||||
parley's layout at block boundaries inside one text widget (couples
|
||||
iris's text widget to markdown structure, and parley has no incremental
|
||||
API), and caching shaped runs per paragraph inside `TextEdit` (a second
|
||||
cache with its own invalidation beside the glyph cache). Chosen because
|
||||
P1's markdown block model is needed anyway, so the split happens once, in
|
||||
`client-core`, and iris stays a text renderer. **Status: designed, not
|
||||
built** -- this pass spent its budget on the composer's three layout
|
||||
defects; docs/RUST.md has the design and the pass conditions.
|
||||
- **The composer's overflowing text now scrolls on a finger**, capped at
|
||||
six lines and clipped to the bar. Reverses the "still does not scroll"
|
||||
item below.
|
||||
- **A widget may not report a `dp` length** (see IRIS.md). A rule for
|
||||
widget authors, enforced by a `debug_assert!`; nothing changes for app
|
||||
code.
|
||||
|
||||
## 2026-09-06 (stale-primitives and touch-scroll pass)
|
||||
|
||||
- **A vertical drag inside a focused composer now scrolls rather than
|
||||
selects.** Android's own `EditText` does this -- a vertical drag scrolls
|
||||
the field, and only a long press starts a selection -- so the platform
|
||||
decided it. What it costs: you can no longer drag straight down inside
|
||||
the composer to select several lines of what you typed; use a long press
|
||||
and then drag, or drag sideways. Say if that trade is wrong for you.
|
||||
- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area
|
||||
does not, because it has no per-frame tick to animate one and the areas
|
||||
it wraps are at most a screenful (Android does not fling a six-line text
|
||||
box either). Easy to add later if a scroll area ever wraps something long.
|
||||
- **The composer still does not scroll its overflowed text**, though the
|
||||
mechanism it needs is now in place. Wrapping the field in `.scrollable()`
|
||||
was tried and reverted the same day: `Scroll` measures its content and
|
||||
container against the *window*, so inside the `MaxSize` that caps the
|
||||
composer at six lines the two are in different spaces and the field pans
|
||||
itself entirely out of the bar (measured on the emulator with 474
|
||||
characters in it -- the bar collapsed to its padding). Fixing that means
|
||||
`Scroll` measuring against its own offered box, which is a change to a
|
||||
widget the transcript and the bench shell both use, so it is its own
|
||||
piece of work rather than a rider on this one.
|
||||
|
||||
## 2026-09-06 (defect pass)
|
||||
|
||||
- **The keyboard-open diagnostics overlay is gone; the capture only
|
||||
logs now.** It was added when `on_insets_changed` was not firing at all
|
||||
and there was no way to get a report off the phone. It fires reliably
|
||||
since the activity went edge-to-edge -- and what that looks like in
|
||||
use is a full-screen report covering the app **every time the keyboard
|
||||
opens**, with its own Copy/Close buttons sitting underneath the
|
||||
keyboard, so it cannot be dismissed (reproduced on the emulator this
|
||||
pass: two `tap 'CLOSE'` runs left it up). An interruption for something
|
||||
nobody asked for, over the app you are trying to type into. The named
|
||||
`Diagnostics` button still shows the same text on demand, and the new
|
||||
`iris surface:`/`iris insets:` log lines carry the lifecycle a `logcat`
|
||||
pull needs. Reversible: `capture_keyboard_diagnostics` is still the one
|
||||
place this is decided, and `PlatformHandle::show_diagnostics_overlay`
|
||||
is still there.
|
||||
|
||||
- **The bench shell's report pane is sized to its report, not to a share
|
||||
of the window.** It held `.height(rest(1))` beside the transcript's
|
||||
`rest(2)`, so an *empty* `TextEdit` reserved a third of every screen --
|
||||
which is what Iris's "the app does not start with keyboard spacing
|
||||
correct" screenshot was showing, with the composer two thirds down and
|
||||
black below it. It is `.max_height(dp(260))` now and sits above the
|
||||
transcript rather than under the composer, where it was eating the
|
||||
navigation-bar clearance. Cost: a filled report is clipped at 260dp
|
||||
rather than scrolling (a `Scroll` there drew itself off the top of the
|
||||
screen, since `Scroll` pins to the end of its content and reports its
|
||||
content's full length to the parent -- worth fixing in `Scroll`, not
|
||||
worked around here). "Copy report" and `logcat` still have the whole
|
||||
thing.
|
||||
|
||||
## 2026-09-05
|
||||
|
||||
- **iris no longer asks every device for compute-shader limits it never
|
||||
uses.** `adapter.request_device` (both `iris/src/android/render.rs` and
|
||||
`iris/src/default/render.rs`) used `Limits::default()` plus an override
|
||||
for `max_buffer_size`, and `Limits::default()` unconditionally requests
|
||||
desktop-tier compute limits (`max_compute_workgroups_per_dimension:
|
||||
65535`, per `wgpu_types`) even though nothing in `iris`/`iris-core`
|
||||
creates a `ComputePipeline` or writes a `@compute` shader stage —
|
||||
confirmed by grepping the whole tree, not assumed. That crashed
|
||||
`request_device` outright on the Android emulator's software GL path
|
||||
(`EMU_GPU=software`, `--features force-gles`): SwiftShader's GL reports
|
||||
itself as OpenGL ES 3.0, which has no compute shaders at all, so the
|
||||
adapter's real limit is 0 against the unconditional request for 65535 —
|
||||
`RUST.md`'s "Software mode ... crashes for a third, different reason,"
|
||||
2026-09-05, earlier today. The same would happen on any real
|
||||
GLES-3.0-only Android device, not just the emulator. Fixed by a new
|
||||
`iris_core::device_limits()` (`iris/core/src/render/mod.rs`), shared by
|
||||
both platform backends so the two requests cannot drift, that zeros the
|
||||
six `max_compute_*` fields explicitly rather than switching to a
|
||||
downlevel `Limits` preset — `Limits::downlevel_webgl2_defaults()` was
|
||||
considered and rejected: it also zeros
|
||||
`max_storage_buffers_per_shader_stage`, and `shader.wgsl`'s vertex stage
|
||||
reads four `var<storage>` buffers (rects, glyphs, masks, move_offsets),
|
||||
so that preset would trade the compute crash for a bind-group-layout
|
||||
one on the same downlevel hardware this is meant to support. No
|
||||
capability check or fallback path was needed since nothing is being
|
||||
disabled — the request is simply narrowed to what the pipeline actually
|
||||
uses. `rigs/gpu-probe`'s own mirrored limits (it is deliberately its own
|
||||
crate, not a workspace member, so it cannot call `device_limits()`
|
||||
directly) were updated to match, and confirm `IRIS DEVICE: ok` against
|
||||
this VM's own Vulkan and GL adapters. **Not verified this pass**: the
|
||||
specific SwiftShader-ES-3.0 crash this fixes, on-device — the
|
||||
`EMU_GPU=software` cold boot this needs would have force-restarted this
|
||||
checkout's emulator while another session was actively running its own
|
||||
app on it (`com.example.aiapp` had window focus at the time), so it was
|
||||
left for a pass when the emulator is free rather than disrupting that
|
||||
session. Everything reachable without the emulator is clean: `cargo
|
||||
fmt`/`clippy --workspace --all-targets`/`test --workspace`, `cargo ndk
|
||||
build`/`clippy` for `iris-android-app` with `force-gles`, and
|
||||
`gpu-probe` against this VM's own Vulkan and GL(ES 3.2, which still has
|
||||
compute and so would not have reproduced the crash even before this
|
||||
fix — not a substitute for the real ES-3.0 test).
|
||||
|
||||
- **P0's Compose half is built and smoke-tested on the emulator** — the
|
||||
`bench` build type, the shared `app/bench-fixture/` transcript, and an
|
||||
in-process fake backend (`BenchFixture.kt`/`BenchNetwork.kt`) that
|
||||
answers `TranscriptSource`/`EventStream` from an in-memory event log
|
||||
instead of a real server, so the fold and paging under test are the real
|
||||
ones. Full account, the smoke run's report, and what is deliberately
|
||||
left (the iris half, the real on-phone runs) are in RUST.md's P0 box.
|
||||
Not a decision to review so much as the gate itself now being runnable —
|
||||
flagged here because it is the first half of something Iris explicitly
|
||||
asked to see before P1.
|
||||
|
||||
- **P0's iris half is also built and smoke-tested on the emulator,
|
||||
2026-09-05.** A new `bench` Cargo feature on `iris-android-app`, on top
|
||||
of `transcript-screen`: the same checked-in fixture (`include_str!`, no
|
||||
asset pipeline needed), the same 24-swipe scroll loop animated through
|
||||
`List::scroll` and the same 400-event/20s streaming phase through
|
||||
`fold_event`, "Run benchmark"/"Copy report" as named accessible
|
||||
controls, and the same three added report fields (process CPU time,
|
||||
peak RSS, battery current) via direct JNI calls
|
||||
(`bench_jni.rs::PlatformHandle`) since `android_view` has no
|
||||
`BatteryManager`/`ClipboardManager` wrapper of its own. One small public
|
||||
API addition to get there: `AndroidAppState::platform_ready` (`IRIS.md`),
|
||||
a default-no-op lifecycle hook handing an implementor a `JavaVM` +
|
||||
`GlobalRef` it can call Java through from any thread. Packaged with a
|
||||
new `release` build type on `iris-android-app`'s own Gradle project
|
||||
(there was previously only `debug`), signed with the same key
|
||||
`app/build-apk.sh` generates. Smoke run and the full report are in
|
||||
RUST.md's P0 box; not attempted this pass: the real on-phone runs and
|
||||
Iris's pass/fail call, which is the actual gate.
|
||||
|
||||
- **The intermittent touch-scroll dropout is root-caused and fixed: a
|
||||
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
|
||||
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
|
||||
touch event, `DragArbiter` state transition and `Selection::drag`
|
||||
dispatch (removed once confirmed), reproduced on this checkout's own
|
||||
emulator against a real sandbox session. The trace showed the actual
|
||||
mechanism: a gesture's `ACTION_DOWN` lands wherever the finger actually
|
||||
is, which is not guaranteed to fall inside the same row-local sensor
|
||||
region a later `ACTION_MOVE` in the same gesture lands in (a row's own
|
||||
padding/gap, or its non-selectable sender-name header, is
|
||||
pointer-transparent to `iris::sense::CursorSense`). When that happens,
|
||||
the widget that ends up handling the gesture never saw `PressStart`, so
|
||||
`DragArbiter` sits in `Idle` — which answers every subsequent frame with
|
||||
`Undecided` and has no way to tell "no press is happening" from "a press
|
||||
is happening but I missed its start," so it never recovers on its own
|
||||
for the rest of that gesture. One real trace showed exactly this: touch
|
||||
`Down`/`Move`/`Up` all delivered correctly, but zero `PressStart`
|
||||
reaching the arbiter, `state=Idle` unchanged from first frame to last.
|
||||
Fixed at the call site that has the context to recover
|
||||
(`iris::transcript_ui::selection::Selection::drag`,
|
||||
`iris/transcript-ui/src/selection.rs`): a new `DragArbiter::is_idle()`
|
||||
(`iris/src/sense.rs`) lets it notice a `Pressing` frame arriving with the
|
||||
arbiter still `Idle` — which can only mean a missed `PressStart`, since a
|
||||
`Pressing` sense requires the button to genuinely be down — and start the
|
||||
press there instead of where it was missed. Three new unit tests in
|
||||
`sense.rs`'s `drag_arbiter_tests` and one in `transcript-ui`'s
|
||||
`selection::tests` (the latter fails on the code before this fix).
|
||||
Commit follows. Not the same failure the earlier pass's `DECISIONS.md`
|
||||
DEFERRED item speculated about (a coalesced first `ACTION_MOVE` skipping
|
||||
slop detection) — that hypothesis is now ruled out; the arbiter's own
|
||||
slop/long-press logic was never wrong. RUST.md's I5 box,
|
||||
"Touch-scroll dropout root-caused, 2026-09-05" has the full trace.
|
||||
- **P0, a phone benchmark gate before any porting, asked for by Iris
|
||||
2026-09-05**: "before P1 I'd like to see benchmarks & also maybe stress
|
||||
test on my own phone ... If it doesn't match compose reasonably well then
|
||||
I don't think I'd wanna continue." Design (RUST.md's P0 box has the
|
||||
detail): the same embedded synthetic fixture in both apps with no server
|
||||
needed; the same scripted scroll loop then a streaming phase, run
|
||||
programmatically since the phone has no usable system tracing and no
|
||||
agent can drive it; the same report from both (frames, janky %, p50/p90/
|
||||
p99, process CPU time, peak RSS, battery current where readable) with a
|
||||
copy button; the iris app under its own id and the Compose one as a new
|
||||
`bench` build type with an id suffix, so neither replaces her production
|
||||
install; two arm64 APKs plus instructions delivered under `~/host/bench/`.
|
||||
The gate is hers: iris within a reasonable margin of Compose release on
|
||||
p50, p99 and CPU time, no crashes, no visible stutter. If it fails, the
|
||||
port stops.
|
||||
- **The rest of the port is one UI crate, `iris/app-ui`, grown out of
|
||||
`iris/transcript-ui` rather than started beside it.** It holds a
|
||||
`Screen` enum plus a back stack — the Rust equivalent of `AppRoot.kt`'s
|
||||
`when` — and `iris/desktop-app`/`iris/android-app` become thin entry
|
||||
points over it. Chosen over a fresh crate because `transcript-ui`
|
||||
already has the right generic shape (`Rsc: HasEvents` +
|
||||
`Rsc::State: FocusHost`) and the `client-core`/`event-model` path
|
||||
dependencies every later screen needs, so growing it in place is the
|
||||
smaller diff. Platform-only code (notification service, share target,
|
||||
QR scanner, Keystore token, deep-link enrolment) stays in the E3/E5
|
||||
Java shell (`android-shell/` + `app/shellApp`) rather than moving into
|
||||
this crate, since none of it is a screen. The Android APK is built by
|
||||
`cargo xtask apk` (E5), merging the app-ui cdylib into the E3 shell so
|
||||
there is one app rather than a demo shell plus a service shell.
|
||||
`app/androidApp` (the Compose app) stays untouched and is the baseline
|
||||
every step is measured against, until parity is reached (P7 decides
|
||||
the switch, and is itself a load-bearing decision left to Iris). Order
|
||||
is by risk to the daily-use path: session screen first (P1, where
|
||||
every hard behaviour already lives), then the shell merge and a real
|
||||
phone install (P2), then root tabs (P3), the explorer (P4),
|
||||
settings/enrolment (P5), desktop parity (P6), and the cutover itself
|
||||
(P7). Full plan: RUST.md's "The port, in order (decided 2026-09-05)".
|
||||
- **iris gets its own measured frame report, rather than waiting on a
|
||||
`dumpsys`/`gfxinfo` answer that cannot see a `SurfaceView`'s GPU-drawn
|
||||
frames.** `iris_core::FrameReport` (`iris/core/src/render/frame_report.rs`)
|
||||
times each frame's wall clock from the same point `render()`'s redraw
|
||||
starts to just after `queue.submit` + `present()` — the span Compose's
|
||||
own render report and `gfxinfo` both count — into a fixed 4096-entry
|
||||
ring (no allocation per frame; `report()` is the only place that
|
||||
allocates, and only on a button tap). The report gives total frames,
|
||||
janky % over the same 16.7ms budget `gfxinfo` uses, P50/P90/P99 and the
|
||||
worst, plus a reset. Exposed the way the Compose app's copy-button
|
||||
report already is: two named controls ("Frame report", "Reset frame
|
||||
report") on the transcript screen, tappable by accessibility name via
|
||||
`ui-trace`, logging under this crate's fixed `android_logger` tag
|
||||
(`iris-android-app`) so a script can grep `"iris frame report"` the way
|
||||
`transcript-bench.sh` greps `"ai-app render report"`. The report's own
|
||||
`Display` line says plainly that it measures up to the `present()` call
|
||||
returning, not GPU/compositor completion — wgpu's `present()` is not
|
||||
fenced against either, so presenting that span as "time to reach the
|
||||
screen" would be a measured-looking number that is actually inferred,
|
||||
which the standing UI rule forbids.
|
||||
- **`ui-trace` gains a hold-then-drag gesture, additive, in
|
||||
`emulator-tools`.** Neither of its two existing actions can produce
|
||||
"hold stationary for `LONG_PRESS`, then move without lifting" — `tap`
|
||||
has no hold and `swipe X1 Y1 X2 Y2 MS` interpolates motion across its
|
||||
whole duration from t=0. A new action presses, waits, then moves to a
|
||||
second point and releases as one continuous touch (raw
|
||||
`sendevent`/`MotionEvent` injection, extending whatever mechanism the
|
||||
existing `swipe` already uses), so `DragArbiter`'s pan-vs-select rule
|
||||
(`iris/src/sense.rs`, already covered by 8 unit tests against a
|
||||
synthetic clock) can finally be driven on a real device instead of only
|
||||
in a test harness.
|
||||
- **Touch drag on a transcript row follows Android's own rule**: a vertical
|
||||
drag pans the list immediately; a stationary press held 500 ms starts a
|
||||
text selection which further dragging extends; a horizontal drag while
|
||||
something is already selected extends that selection without the wait.
|
||||
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
|
||||
"text layer always wins" or "list always wins" rule because either loses
|
||||
one of the two gestures a reader expects.
|
||||
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
|
||||
holding `transcript-ui`'s screen beside a session list, talking to a real
|
||||
`ai-server` through `client-core`. It enrols by pasting the same
|
||||
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
|
||||
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
|
||||
pinned CA is a path given on the command line, not baked in. Chosen so
|
||||
the phone and desktop share one enrolment format and no second one is
|
||||
invented.
|
||||
- **I5's Android integration extends `iris-android-app` (I2's shell)
|
||||
behind a Cargo feature (`transcript-screen`), rather than a third
|
||||
shell crate.** That project already has the Gradle module, the
|
||||
`IrisView`/`MainActivity` Java, and the JNI registration; the only
|
||||
thing a second screen needs on top is a different `AndroidAppState`,
|
||||
the same axis `tabs_ui::build`/`transcript_ui::build` already vary
|
||||
along on the winit side. `tabs-screen`/`transcript-screen` are
|
||||
mutually exclusive and each pulls in only its own deps, so the plain
|
||||
tabs build (I2/I4) is untouched.
|
||||
- **Order of remaining work, updated 2026-09-05**: the two in-flight
|
||||
pieces and I5's Android integration are all done; next is giving iris
|
||||
its own frame-timing report so item 3 below can be decided by a number.
|
||||
- **DECIDED by Iris, 2026-09-05: iris is the app's framework; Masonry was
|
||||
the calibration.** Her words: "I think iris definitely makes more sense
|
||||
based on the limitations we've found." The limitations: Masonry has no
|
||||
touch scroll on Android (E2), no per-span rich text and no cross-row
|
||||
selection on the pinned commit (E2), and its keyboard bridge is a TODO
|
||||
(E1); iris carries the same screen under the Compose baseline on the
|
||||
host GPU (p50 15.0 ms against Compose's 20.0 ms, RUST.md's I5 box). What
|
||||
follows: the E-steps are closed as calibration, and the port proceeds
|
||||
on iris — screens, the shell (E3/E5), and `client-core` underneath.
|
||||
The item below is kept as the record of what she decided from.
|
||||
- **Was DEFERRED — whether to commit to iris over Masonry for `ai-app`.**
|
||||
Updated 2026-09-05 with the clean comparison the recommendation wanted:
|
||||
same sandbox session content, same emulator, `EMU_GPU=software`, one
|
||||
session. Headline numbers (RUST.md's I5 box, "Clean scroll comparison,
|
||||
2026-09-05," has the full table and every caveat):
|
||||
|
||||
| app | build | frames | janky % | p50 | p90 | p99 | worst |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Compose (in-app report) | debug | 1102 | 99.0% late | 33.8ms | 50.6ms | 79.5ms | -- |
|
||||
| Compose (`dumpsys gfxinfo`) | debug | 1499 | 21.15% (95.66% legacy) | 32ms | 48ms | 150ms (p99) | -- |
|
||||
| iris (`FrameReport`) | **release** | 299 | 94.65% | 79.1ms | 98.6ms | 117.8ms | 212.6ms |
|
||||
| iris (`FrameReport`, repeat) | **release** | 233 | 94.42% | 109.3ms | 130.8ms | 147.1ms | 150.5ms |
|
||||
|
||||
**Not a clean apples-to-apples reading, stated plainly rather than
|
||||
smoothed over**: iris had to be built **release** (debug `SIGSEGV`s on
|
||||
this emulator's Vulkan loader, I4's finding) against Compose's mandated
|
||||
**debug** build, so this asymmetry likely *understates* iris's gap
|
||||
rather than the reverse; the three frame-time sources measure different
|
||||
things (Compose's own phase accounting vs. Android's HWUI deadline-miss
|
||||
definition vs. iris's redraw-start-to-present window, the last of which
|
||||
`dumpsys gfxinfo` cannot see at all for iris's `SurfaceView`); and both
|
||||
figures are emulator numbers under software rasterisation, which
|
||||
Compose's *own* in-app report shows already costs 20-34ms/frame in
|
||||
`swap`+`gpu` alone under this GPU mode, so a same-mode iris number well
|
||||
above 16.7ms was expected going in for either app. A second pair under
|
||||
`-gpu host` was not taken this pass. The earlier session's suspected
|
||||
intermittent touch-delivery dropout was **not reproduced** this pass —
|
||||
the zero-frame results this time traced to this pass's own script bug
|
||||
(a `cd` that changed which emulator `ui-trace` targeted), not the
|
||||
emulator; a CPU-load rise during the gesture was observed by a sampler
|
||||
running throughout, but did not correlate with any failure, so the
|
||||
original candidate is neither confirmed nor ruled out.
|
||||
The choice in front of Iris, updated: decide now on the
|
||||
structural-plus-functional case already made (iris works end-to-end
|
||||
where Masonry's scroll gesture doesn't exist at all on Android) plus
|
||||
this table — reading the two build profiles and three jank definitions
|
||||
with the caveats above rather than as a single number — or ask for a
|
||||
same-profile, same-GPU-mode rerun first. RUST.md's I5 box has the full
|
||||
account.
|
||||
|
||||
**Updated 2026-09-05, the `-gpu host` pair taken.** Real GPU rendering
|
||||
(`force-gles` -- the default Vulkan backend has no adapter at all under
|
||||
plain host-GPU boot, confirmed by the exact `wgpu` error) reverses the
|
||||
software-mode shape:
|
||||
|
||||
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
|
||||
| iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms |
|
||||
|
||||
Under real GPU rendering iris's median frame is *faster* than
|
||||
Compose's, not the 2-3x-slower shape the software-mode table shows. A
|
||||
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
|
||||
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
|
||||
~1ms -- almost the entire frame is time spent handing the frame to the
|
||||
driver, not in iris's layout/text/primitive code. This is consistent
|
||||
with the earlier software-mode gap being mostly SwiftShader's CPU
|
||||
rasterisation cost rather than an iris-specific slowness. **Still not
|
||||
proof, and now closed as unanswerable rather than merely untaken**: a
|
||||
same-mode software `force-gles` run to isolate the backend was retried
|
||||
2026-09-05 after fixing the compute-limit crash the first attempt hit,
|
||||
and hit a second, structural wall instead — SwiftShader's ES 3.0 GL
|
||||
path has no storage-buffer capacity at all, and `shader.wgsl` reads
|
||||
`var<storage>` buffers unconditionally, so reaching that path needs a
|
||||
shader rewrite, not a limits fix (RUST.md's I5 box, "The three
|
||||
remaining I5 verifications, closed 2026-09-05," item 2). The
|
||||
intermittent touch-scroll dropout this pass also reproduced is
|
||||
root-caused and fixed as of the same date (a missed `ACTION_DOWN` on a
|
||||
row's padding/header left `DragArbiter` stuck in `Idle`); three clean
|
||||
`iris-scroll.sh` runs post-fix each scrolled all 24/24 swipes, replacing
|
||||
the single-attempt 62-frame reading this table used to carry. RUST.md's
|
||||
I5 box, "Where iris's frame time goes, 2026-09-05, the `-gpu host`
|
||||
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
||||
have the full account. The iris-vs-Masonry choice itself is still
|
||||
Iris's to make.
|
||||
@@ -0,0 +1,957 @@
|
||||
# iris: known problems and things still to build
|
||||
|
||||
Iris's own list for the library, recorded 2026-09-04 in her words where it
|
||||
matters, so the agents working through RUST.md pick these up in a sensible
|
||||
order rather than rediscovering them. Each item says where it sits in the
|
||||
order and what "done" looks like. Tick and date them in place.
|
||||
|
||||
## Fix
|
||||
|
||||
- [x] **`request_device` asked for compute-shader limits it never uses
|
||||
(2026-09-05).** `Limits::default()` (both `iris/src/android/render.rs`
|
||||
and `iris/src/default/render.rs`) requests desktop-tier compute limits
|
||||
unconditionally, even though nothing in `iris`/`iris-core` creates a
|
||||
`ComputePipeline` or writes a `@compute` shader stage — confirmed by
|
||||
grepping the whole tree, not assumed. That crashed device creation
|
||||
outright on the Android emulator's software GL path (`EMU_GPU=software`,
|
||||
`--features force-gles`): SwiftShader's GL reports itself as OpenGL ES
|
||||
3.0, which has no compute shaders, so the adapter's real limit is 0
|
||||
against the unconditional request for 65535 — the same would happen on
|
||||
any real GLES-3.0-only Android device. Fixed by a new, shared
|
||||
`iris_core::device_limits()` (`iris/core/src/render/mod.rs`) that zeros
|
||||
exactly the six `max_compute_*` fields rather than switching to a
|
||||
downlevel `Limits` preset — `downlevel_webgl2_defaults()` also zeros
|
||||
`max_storage_buffers_per_shader_stage`, which `shader.wgsl`'s vertex
|
||||
stage needs (four `var<storage>` buffers), so that preset would trade
|
||||
this crash for a bind-group-layout one on the same hardware.
|
||||
`rigs/gpu-probe`'s own hand-mirrored `Limits` (it is deliberately its
|
||||
own crate, not able to call `device_limits()` directly) was updated to
|
||||
match. See `DECISIONS.md` and RUST.md's I5 box for the account,
|
||||
including what could not be re-verified on-device this pass (the
|
||||
emulator was in concurrent use by another session).
|
||||
|
||||
- [x] **Input does not fall through by input type (2026-09-04).**
|
||||
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
|
||||
stop checking lower layers" from mere hover — a widget registered for
|
||||
nothing but `click()` blocked a `Scroll` meant for whatever was behind
|
||||
it, since "the cursor is over this widget" and "this widget handled the
|
||||
event" were the same check. Fixed by judging consumption per input
|
||||
kind: with no button transition and no scroll happening this frame
|
||||
("momentary" activity), the topmost hovered widget still wins, same as
|
||||
before; when something momentary *is* happening, only a widget whose
|
||||
registered senses actually include a matching non-hover one (checked
|
||||
via a new `TypeEventManager::registered`, which lists what a widget
|
||||
registered without running anything) consumes it, so a widget with only
|
||||
`Hovering`/click handlers can no longer block a scroll from reaching a
|
||||
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
|
||||
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
|
||||
directions: a scroll over the button reaches the list, and a real click
|
||||
still reaches the button — confirmed to fail on the pre-fix code and
|
||||
pass after.
|
||||
|
||||
- [x] **Appending one image to an already-loaded list rebuilds every other
|
||||
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
|
||||
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
|
||||
triggered `rebuild_image_bind_groups` — a loop over *every live
|
||||
standalone image*, rebuilding its `BindGroup` — whenever the shared
|
||||
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
|
||||
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
|
||||
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
|
||||
every widget gets one on first draw) could be exactly what grows that
|
||||
buffer. So one new message with one new image, appended to a transcript
|
||||
that already has N images loaded, did not cost O(1): it cost one
|
||||
`create_image` for the new image plus one `make_image_bind_group` per
|
||||
*existing* image, because the new widget's own move slot pushed the
|
||||
arena past its capacity. Measured directly in
|
||||
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
|
||||
already-settled ones reported **1,001** bind-group creates for that one
|
||||
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
|
||||
|
||||
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
|
||||
bind group (group 2) in the first place — the group also holds that
|
||||
image's own texture view, which is the only thing that is genuinely
|
||||
per-image, so a buffer shared by *everything* forced a rebuild of
|
||||
*every* group the moment it moved. Gave masks/move_offsets their own
|
||||
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
|
||||
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
|
||||
once per draw call, instead of duplicating them into every per-image
|
||||
group. `GpuTextures` and its image bind groups now know nothing about
|
||||
either buffer — `rebuild_image_bind_groups` is called only from
|
||||
`grow_array` (the atlas array texture growing, which genuinely does
|
||||
change what every image's own bind group must reference) — so a
|
||||
masks/move_offsets resize now touches exactly one bind group, ever,
|
||||
regardless of how many images are live. Numbers after the fix, same
|
||||
benchmark and command:
|
||||
|
||||
./run-bench.sh images
|
||||
frame=1 bind_group_creates=1000 (cold load, unchanged)
|
||||
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
|
||||
frame=3 bind_group_creates=0
|
||||
frame=4 bind_group_creates=0
|
||||
(append one image here)
|
||||
frame=5 bind_group_creates=1 (was 1001)
|
||||
frame=6 bind_group_creates=0
|
||||
|
||||
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
|
||||
confirming the bind-group restructuring changed nothing about what is
|
||||
drawn.
|
||||
- [x] **Bind-group creation takes two frames to reach the steady state, not
|
||||
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
|
||||
loading 1,000 images cold used to report 1,000 creates on frame 1
|
||||
(expected — `create_image`, one per new image) *and again* 1,000 on
|
||||
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
|
||||
firing a second time for the same masks/move-offsets buffer-growth
|
||||
reason as the item above, confirming the guess recorded here — the two
|
||||
were exactly the same root cause measured two different ways. Frame 2
|
||||
now reports 0 (see the numbers above); not a separate fix.
|
||||
|
||||
- [ ] **A read-only text display has no widget of its own — P0's bench
|
||||
report area is a `TextEdit` standing in for one (2026-09-05).** The only
|
||||
way to get selectable text on screen today is `.editable(...)` plus
|
||||
`.attr::<Selectable>(())` (`Selectable` is only implemented for
|
||||
`TextEdit`, `iris/src/attr.rs`), which also makes the field focusable —
|
||||
tapping the bench report opens the soft keyboard over text nothing lets
|
||||
you type into. Harmless for a bench-only debug screen (not fixed this
|
||||
pass), but a real "selectable, not editable" text primitive would
|
||||
remove the keyboard side effect and is worth having before another
|
||||
screen wants the same thing (P1's own transcript rows already read
|
||||
their content from a `TextEdit` for the same reason).
|
||||
|
||||
## From the phone, 2026-09-06
|
||||
|
||||
Found on Iris's own phone while working RUST.md's P0 box's phone-report
|
||||
follow-ups. Recorded here rather than fixed in that pass, so a follow-up
|
||||
agent takes them without colliding with that pass's `bench_client.rs`/
|
||||
`android/view.rs`/`android/sense.rs` changes.
|
||||
|
||||
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
|
||||
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
|
||||
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
|
||||
Wired through `Selection::drag`'s release path, cancelled by the next
|
||||
touch-down, clamped at the loaded content's start/end. Verified by unit
|
||||
test (fling distance against the closed-form spline result, cancel-on-
|
||||
touch, the clamp), not yet by an on-device or emulator feel-check --
|
||||
that is still open.
|
||||
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
|
||||
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
|
||||
transition rather than by an on-device trace (no emulator was used this
|
||||
pass): it was the first named suspect, not the second. `self.last` stays
|
||||
at the press origin for every `Undecided` frame (nothing pans while the
|
||||
gesture might still be a selection), so the frame that finally crosses
|
||||
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
|
||||
the *whole* pre-threshold drag, applied to the list in one step, however
|
||||
many frames it had taken to get there. Fixed by applying only the
|
||||
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
|
||||
(dy)`), the same "consume the slop, don't replay it" rule Android's own
|
||||
touch handling follows. New regression test,
|
||||
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
|
||||
**Not yet done**: an emulator trace of the real per-frame offset
|
||||
confirming this was the whole story on real touch input rather than
|
||||
only the arbiter's own unit tests -- worth a follow-up pass before
|
||||
calling it fully closed.
|
||||
- [x] **Composing text held back until a space, caret not moving, fixed
|
||||
2026-09-06.** `InputMethodManager.updateSelection` was never called --
|
||||
see IRIS.md's 2026-09-06 entry and RUST.md's P0 box, item 1, for the
|
||||
full account and the emulator evidence.
|
||||
- [x] **Swipe over the composer summons the keyboard, fixed 2026-09-06.**
|
||||
`Selector`/`Selectable` now wait for a completed tap -- see IRIS.md's
|
||||
2026-09-06 entry and RUST.md's P0 box, item 5. Verified via `dumpsys
|
||||
input_method`'s `mInputShown` on the emulator, not yet on the phone.
|
||||
- [x] **Text disappears again after leaving and returning to the app,
|
||||
fixed 2026-09-06.** `GlyphAtlas::clear`/`Textures::reset` on a
|
||||
genuinely new renderer -- see IRIS.md's 2026-09-06 entry and RUST.md's
|
||||
P0 box, item 4. Verified on the emulator (home, reopen, screenshot);
|
||||
not yet on the phone.
|
||||
- [x] **Composed/typed text never becomes visible at all -- root-caused
|
||||
and fixed 2026-09-06.** Not the renderer at all: **the composer's buffer
|
||||
was empty the whole time.** `TextEditCtx::select` (`iris/src/widget/
|
||||
text/edit.rs`) compared the tap against the *laid-out text's* box and
|
||||
set `selection = None` for anything outside it -- and an empty field's
|
||||
layout is a zero-width box, so tapping an empty composer granted focus
|
||||
and opened the keyboard while leaving no caret; `insert_str` returns
|
||||
early with no caret, so every keystroke after that was dropped in
|
||||
silence. Gboard's suggestion strip is its own composing state, not a
|
||||
read of our buffer, which is what made the earlier pass conclude the
|
||||
buffer held the text. Fixed by letting parley clamp a tap outside the
|
||||
layout to the nearest cursor position (a press that reaches `select`
|
||||
has already been hit-tested to the widget, so there is no "outside"),
|
||||
plus a `debug_assert!` in `insert_str` so an insert with no caret fails
|
||||
at the mistake instead of dropping input -- it immediately caught
|
||||
`layout_tests::composing_text_after_a_keyboard_resize_...` typing into
|
||||
an unfocused field. Three new tests in `edit.rs`
|
||||
(`tapping_an_empty_field_places_a_caret_so_typing_lands`,
|
||||
`tapping_past_the_end_of_the_text_clamps_to_the_end`,
|
||||
`dragging_without_a_previous_selection_selects_nothing`); the first
|
||||
fails on the pre-fix code. Emulator evidence: `adb shell input text`
|
||||
after `tap 'Message'` now shows the text in the bar
|
||||
(`/tmp/final-typing.png`) and logs `iris text render: chars=5 ...
|
||||
glyphs=5`, against `glyphs=0` on every keystroke before.
|
||||
|
||||
**The old, superseded diagnosis, kept because it was wrong in an
|
||||
instructive way:** The composer bar stays empty even once the
|
||||
buffer genuinely holds the typed text (confirmed indirectly: Gboard's
|
||||
own suggestion strip reacts correctly to each keystroke). A new unit
|
||||
test proves the widget tree's own layout math resolves the field's
|
||||
region correctly across a keyboard resize, so the bug is downstream of
|
||||
that -- most likely `UiRenderState::redraw`'s single-widget redraw path,
|
||||
or specific to this emulator's forced `force-gles` backend (untested on
|
||||
Vulkan or the real phone). RUST.md's P0 box, item 2, has the full
|
||||
writeup, what was ruled out, and where to look next. **Also unverified
|
||||
because of this**: item 3's composer rebuild (one `Stack`-based widget,
|
||||
a capped/scrollable height, bottom padding tied to the IME/nav-bar
|
||||
inset) -- structurally in place and unit-tested, but its own visual
|
||||
correctness cannot be screenshotted until text actually renders.
|
||||
- [x] **The composer has no touch-drag scroll for overflowing text.**
|
||||
**Done 2026-09-06.** `field.scrollable().masked()` in
|
||||
`transcript-ui/src/composer.rs`: a finger drag inside the bar pans the
|
||||
message, the bar stays capped at six lines, and a vertical drag in the
|
||||
focused field no longer extends a selection (Android `EditText`'s own
|
||||
behaviour). Verified on this checkout's emulator with the
|
||||
`transcript-screen bench force-gles` debug build -- six repetitions of a
|
||||
13-word sentence typed in, then
|
||||
`ui-trace record --do "swipe 540 1200 540 1460 300"`: the field's
|
||||
`Message` box moved `31,1041..1048,1509` -> `31,1131..1048,1651` (the
|
||||
content panned down with the finger) with its **height unchanged at
|
||||
468px** (the bar did not grow), and the two screenshots either side show
|
||||
different text in the same band.
|
||||
Three real defects had to be fixed first, each with a headless
|
||||
regression test in `iris/src/layout_tests.rs` and each confirmed to fail
|
||||
without its fix (docs/RUST.md's plan box has the measurements):
|
||||
a `MaxSize` reporting its cap as an unresolved `dp` (`Len::fold_dp`), a
|
||||
`Masked` allocating a fresh mask slot per draw (`ActiveData::own_mask`),
|
||||
and a panned widget's own hit box moving twice (`move_applied`).
|
||||
`Scroll` itself turned out to measure the right number by a misleading
|
||||
route -- it is written against `painter.px_size()` now, and the claim
|
||||
below that it "measures against the window" was wrong.
|
||||
**The grey background was not missing** -- that note (written here on
|
||||
2026-09-06 and repeated as still open) is withdrawn. Re-measured the
|
||||
same day on the same AVD by decoding the screencap rather than reading
|
||||
it: the bar is `rgb(41,40,49)`, the declared `UiColor::new(40, 40, 46)`
|
||||
after sRGB rounding, **full width and y2245..y2365** on 1080x2424, with
|
||||
the field at `31,2277..1048,2329` and the 63px nav strip below it. It
|
||||
is dark by design and sits on black, which is very likely what the
|
||||
earlier reading was: at a glance the band and the background are hard
|
||||
to tell apart. If it should read as a bar rather than as a slightly
|
||||
different black, the colour is the thing to change, not the tree.
|
||||
|
||||
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
|
||||
|
||||
Iris's report on the build with the composing-text, tap-vs-swipe and
|
||||
atlas-reset fixes, with a screenshot, verbatim. Each is open until an
|
||||
agent ticks it here with the evidence.
|
||||
|
||||
- [x] **"The app definitely does not start with keyboard spacing
|
||||
correct. This is how it looks without me doing anything initially."**
|
||||
**Not an inset bug at all -- fixed 2026-09-06.** The black third is the
|
||||
bench shell's own empty *benchmark report* pane: `bench_client.rs`'s
|
||||
root tree gave it `.height(rest(1))` beside `content.height(rest(2))`,
|
||||
so an empty `TextEdit` reserved a third of the window at every launch
|
||||
and pushed the composer up by exactly that. Measured on this checkout's
|
||||
emulator at the phone's own size (1080x2424, density 420, gesture nav),
|
||||
which reproduced Iris's screenshot exactly: new `iris insets:` log line
|
||||
reported `bottom=63 ime_bottom=0` at launch (a nav bar, no keyboard --
|
||||
so the inset the composer was fed was never large), while `ui-trace
|
||||
show -m Message --field box` put the field at `31,1488..1048,1540` on a
|
||||
2282px-tall surface, 789px clear of the bottom -- that pane's third.
|
||||
**Unit mixing checked explicitly and cleared**: `set_bottom_inset` takes
|
||||
physical px and stores `Len::abs`, `MainActivity.java`'s `1`/`0`
|
||||
`ime_bottom` only ever reaches `insets.bottom.max(ime_bottom)` and
|
||||
`> 0.0`, and every `dp` in the composer resolves at layout time. Fix:
|
||||
the report pane is sized to its content (`.max_height(dp(260))
|
||||
.scrollable()`), and moved above the transcript so it cannot eat the
|
||||
composer's nav-bar clearance. After: field box `31,2277..1048,2329`,
|
||||
grey bar ending at device y2361 with the 63px nav strip below it
|
||||
(`/tmp/fix1.png` this pass).
|
||||
The screenshot shows the composer bar (the grey band) sitting about
|
||||
two thirds of the way down a 704x1568 screen, with black below it to
|
||||
the bottom, and the transcript ending at "Claude / Results" just above
|
||||
it -- at launch, no keyboard. So the composer's bottom padding, which
|
||||
the 2026-09-06 rebuild tied to the IME/nav-bar inset, is being fed a
|
||||
large value at start on the phone. Suspects, in order: the initial
|
||||
inset delivery on the phone (GrapheneOS, gesture navigation) versus
|
||||
the emulator; `ime_bottom` now carrying a `1`/`0` boolean through a
|
||||
field the composer may still read as pixels or dp; a stale value from
|
||||
before the first `on_insets_changed`. Reproduce with the phone's
|
||||
screen size and density on the emulator before guessing.
|
||||
- [~] **"Swiping still gets caught by the grey bar but keeps working
|
||||
after I go past it."** Improved 2026-09-06 by the focused-field rule
|
||||
below, still needs her phone to close. `attr.rs`'s `on_press` treated an
|
||||
already-focused composer as the plain drag-to-select case, so a swipe
|
||||
starting inside it dragged a highlight through the typed text for the
|
||||
whole gesture; it now abandons that the moment the press passes
|
||||
`DRAG_SLOP` vertically (Android `EditText`'s own rule), which removes one
|
||||
of the two things that made the bar feel like it caught the swipe. The
|
||||
residual `DRAG_SLOP` measured from the boundary crossing, described
|
||||
below, is unchanged. Original note follows.
|
||||
Not closeable from the emulator, annotated
|
||||
2026-09-06 after the `DragGesture` merge. `attr.rs`'s `on_press` never
|
||||
calls `capture_pointer` and never consumes a `Pressing` frame past
|
||||
`DRAG_SLOP` (it just stops watching), so once the finger's *current*
|
||||
position leaves the composer's box and enters the list's, `List`
|
||||
starts receiving ordinary hit-tested `Pressing` frames there --
|
||||
`DragArbiter::is_idle()`'s 2026-09-05 recovery (a missed `PressStart`)
|
||||
picks it up rather than leaving it stuck. What this does **not** do is
|
||||
what "wherever it began" implies literally: `DragArbiter::press_start`
|
||||
restarts from the *boundary-crossing* position, not from the original
|
||||
touch-down inside the composer, so the pan still needs a fresh
|
||||
`DRAG_SLOP` of travel measured from the boundary rather than from the
|
||||
start of the gesture -- composer and list are adjacent, non-overlapping
|
||||
widgets (`lib.rs`'s `(list, composer_bar).span(Dir::DOWN)`), and only
|
||||
the composer forwarding its own drag to the list would remove that
|
||||
residual slop entirely, which is more than this pass's merge changes.
|
||||
RUST.md's merge-pass box has the reasoning in full and an emulator
|
||||
swipe confirming the composer's own box never moves/resizes during it;
|
||||
whether the residual slop is still perceptible as "caught" needs Iris's
|
||||
phone, since the emulator's per-widget boundary is a few dp wide and
|
||||
easy to cross without noticing on a real screen too.
|
||||
- [ ] **"Flinging still does not work."** No longer expected to reproduce
|
||||
after the `DragGesture` merge (`e12c708`, pointer capture +
|
||||
`CursorSense::Drop`), 2026-09-06. Emulator evidence (RUST.md's
|
||||
merge-pass box, check (b)): a real `ui-trace` finger swipe followed by
|
||||
screenshot-hash sampling caught a post-release frame distinct from the
|
||||
drag's own last frame in one run, and every run showed 28-32
|
||||
`render()` frames per gesture against an idle baseline of 0 and ~8
|
||||
expected from the drag alone -- redraw kept being requested well past
|
||||
the finger lifting, which only happens while a fling is still
|
||||
animating. Left unticked in spirit until Iris's phone confirms it,
|
||||
since only she can say whether it *feels* like a fling now; the
|
||||
emulator's screenshot timing could not always catch the tail of a
|
||||
fast-settling one visually (same caveat noted in RUST.md).
|
||||
- [~] **"Text still disappears if I leave and come back to the app."**
|
||||
**Instrumented 2026-09-06 so the phone can answer it**, since no
|
||||
emulator here has a Vulkan adapter. `iris/src/android/view.rs` now logs
|
||||
one `log::info!` line per surface event with the glyph/atlas counts:
|
||||
`iris surface: surface_destroyed, tearing the renderer down
|
||||
(glyphs_cached=387 atlas_pages=1)`, `iris surface: surface_changed
|
||||
1080x2424 already_live=false glyphs_cached=387 atlas_pages=1`, `iris
|
||||
surface: new renderer built (Gl), clearing glyph atlas: glyphs=387
|
||||
pages=1`, plus `iris insets: ... window=(1080, 2424)` on every insets
|
||||
change. That is the emulator's own healthy app-switch cycle, verified
|
||||
this pass (home, reopen, screenshot: all text intact,
|
||||
`/tmp/appswitch.png`). **The one line to look for on the phone is
|
||||
`already_live=`**: `true` on the return from backgrounding would mean
|
||||
the surface came back *without* a `surface_destroyed`, so
|
||||
`surface_changed` reconfigured a renderer whose Vulkan swapchain and
|
||||
atlas textures belong to a window that is gone -- the reuse branch
|
||||
never clears the atlas, by design. `false` with no `new renderer built`
|
||||
line after it would mean the renderer failed to rebuild. Either answer
|
||||
names the fix; guessing between them from here does not.
|
||||
The `GlyphAtlas::clear`/`Textures::reset` fix was verified on the
|
||||
emulator under `force-gles` only; the phone runs Vulkan. So either the
|
||||
reset is not reached on the phone's path (a different surface-
|
||||
lifecycle sequence -- `surface_destroyed`/`surface_created` ordering,
|
||||
or the renderer not being rebuilt but its textures lost), or the CPU
|
||||
glyph cache and the GPU atlas still disagree after it. Needs logging
|
||||
of the renderer lifecycle on the phone build, readable from `adb
|
||||
logcat` when Iris next runs it, since no emulator here has a Vulkan
|
||||
adapter under host GPU.
|
||||
|
||||
## From the phone, 2026-09-06, 22:16 (build from 20303e0, delivered via ai-app-bench 95e25fe)
|
||||
|
||||
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
||||
|
||||
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
|
||||
*"flinging now does technically do something, but it seems to just be
|
||||
linear velocity with an abrupt stop."* **It was exactly that, and the
|
||||
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
|
||||
-- a constant-speed slide for the whole duration, then a stop at full
|
||||
distance -- because two halves of AOSP's spline build loop were
|
||||
transposed, which made `SPLINE_POSITION` and `SPLINE_TIME` identical, and
|
||||
the lookup bracketed `t` between `SPLINE_TIME` entries rather than
|
||||
between even time steps. The two cancelled to the identity. Ported
|
||||
exactly now from `OverScroller.java` and
|
||||
`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt`
|
||||
(they agree line for line), with `iris/benches/fling_spline_reference.py`
|
||||
as an independent transcription supplying the numbers the tests assert
|
||||
on. Emulator, 2026-09-07: a released `v=3750` decelerates
|
||||
`3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s` across 32
|
||||
frames; a flick into the end of the list stops there in one tick with no
|
||||
overshoot; a tap 200ms into a fling ends it at 11 ticks instead of 32.
|
||||
**Open until the phone says so** -- a flick should now visibly slow
|
||||
before it stops. Its earlier three defects (the velocity, the missing
|
||||
animation registration, the 56x coefficient) are all still fixed and were
|
||||
never the linear part.* Second report; the emulator's
|
||||
`ui-trace` swipe flings (verified 2026-09-06 with `render()` counts),
|
||||
a finger on the phone does not. What differs: a real flick at 120Hz is
|
||||
batched by Android into few `MotionEvent`s with *historical* samples
|
||||
(`getHistoricalX/Y/EventTime`), and can be DOWN, one or two MOVEs, UP
|
||||
inside `DRAG_SLOP`'s worth of frames; a `ui-trace` swipe is many
|
||||
evenly-spaced MOVEs. Suspects, in order: `android/sense.rs` reading
|
||||
only each event's final position (the velocity tracker sees two
|
||||
samples, or one); the release path starting a fling only from a
|
||||
gesture already in `Panning`, so a flick that crosses the slop on its
|
||||
last sample is treated as a tap; `ACTION_CANCEL`/pointer-capture
|
||||
delivering no `Drop`. Log the release decision (samples, span,
|
||||
velocity, outcome) at `info` so the next logcat settles it.
|
||||
- [x] **"I can't reopen keyboard by tapping on message box after it
|
||||
already happened once."** *(Fixed 2026-09-07: `attr.rs`'s already-
|
||||
focused branch calls `focus_gained` on a tap inside `DRAG_SLOP`.
|
||||
Emulator: first tap `mInputShown=true`, back gesture, second tap
|
||||
`mInputShown=true`. Negative control with that one call removed leaves
|
||||
the second tap at `false`; a horizontal and a vertical swipe over the
|
||||
focused field both leave it at `false`, so the earlier "swiping over
|
||||
the input bar brings up the keyboard" has not returned.)* The field stays focused after the keyboard
|
||||
is dismissed (back gesture, or the IME's own hide), so `on_press`'s
|
||||
already-focused branch never requests the IME again. Android's
|
||||
`EditText` shows the IME on every tap of a focused field; do the same
|
||||
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
||||
when it is already shown).
|
||||
- [ ] **"Message box does not push up the scroll area."**
|
||||
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
|
||||
raising up does not push things upwards"* -- after being ticked on
|
||||
emulator evidence the day before (`ime_bottom=883`, composer box
|
||||
`31,2277..1048,2329` -> `31,1457..1048,1509`). The JNI half was right;
|
||||
what was wrong is one line of `iris/android-app/app/build.gradle`:
|
||||
**`targetSdk = 34`** against `compileSdk = 37`, while the Compose app in
|
||||
`app/` targets 37 and *does* push up on her phone. Below target 35 the
|
||||
window keeps the legacy behaviour, where `adjustResize` shrinks it for
|
||||
the IME and `getInsets(ime()).bottom` therefore measures zero;
|
||||
`setDecorFitsSystemWindows(false)` opts out of that and still takes on
|
||||
the API 36 emulator here, which is why every test run passed. Now
|
||||
`targetSdk = 37`, plus a `WindowInsetsAnimation.Callback` for the devices
|
||||
where only the animation path carries the height -- which also makes the
|
||||
push-up animate (`ime_bottom=509, 663, 833, 881, 883` instead of one
|
||||
jump). **This is a reading, not a measurement**: no Android 17 device is
|
||||
reachable from here. So the Diagnostics pane now prints
|
||||
`insets: dispatches=N left=… ime_bottom=… ime_visible=…` --
|
||||
**screenshot that line with the keyboard open.** `ime_bottom` in the
|
||||
hundreds and the composer risen means fixed; `dispatches` climbing with
|
||||
`ime_bottom=0` means the reading was wrong and the window is still being
|
||||
resized; `dispatches=0` means the listener is not firing at all, which is
|
||||
a third thing again.* Since
|
||||
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
||||
longer resizes the window, so the app owns the IME inset -- but
|
||||
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
||||
2026-09-06 "(b)" fix), so nothing has the inset's *height* to pad the
|
||||
transcript and composer with. Pass both: `isVisible(ime())` and
|
||||
`getInsets(ime()).bottom` in px; the list's bottom padding and the
|
||||
composer's position follow the height, the visibility drives the
|
||||
boolean the `imePadding` rule in AGENTS.md's "Things that have bitten"
|
||||
describes.
|
||||
- [x] **"Picture is what happens if I leave the app and come back,
|
||||
which completely removes text, and then I tap on the debug info. The
|
||||
textures are definitely getting cooked for some reason after leaving
|
||||
the app and resuming."** Screenshot: every glyph drawn *before* the
|
||||
resume is fragments; the diagnostics text drawn *after* is perfect;
|
||||
the report says `atlas format: Rgba8Unorm, views live: 0`. Reading:
|
||||
`Textures::reset`/`GlyphAtlas::clear` on the new renderer emptied the
|
||||
GPU atlas, but the per-widget cached text primitives (`TextView`'s
|
||||
render cache -- the one `c3cfc67`'s shape counter is keyed on) still
|
||||
carry the old atlas coordinates and are re-submitted as-is; only
|
||||
widgets drawn fresh after the resume shape and upload again. Fix: a
|
||||
renderer rebuild invalidates every cached text render (one
|
||||
generation counter on the atlas, checked at `TextView::render`, or
|
||||
a full-tree redraw with caches dropped), with a `debug_assert!` that
|
||||
no submitted glyph quad references an atlas generation older than the
|
||||
live one. Reproducible on the emulator by forcing a renderer rebuild
|
||||
(home + return, or `surface_destroyed`/`surface_created`) on a screen
|
||||
with text already drawn -- the earlier "verified" home/reopen check
|
||||
screenshotted the emulator's GLES path, where a resume may not
|
||||
destroy the surface at all.
|
||||
|
||||
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
|
||||
"the resume glyph corruption is fixed").** Closed. The emulator could
|
||||
never have settled it -- no Vulkan adapter here, and the GLES path may
|
||||
not destroy the surface at all -- so the phone was the only place this
|
||||
could be answered, and it has been. `clearing_the_atlas_re_renders_
|
||||
cached_text_instead_of_reusing_it` is what keeps it.
|
||||
|
||||
The reading above is right and the mechanism is one step narrower than
|
||||
"cached text primitives". `IrisViewPeer::surface_changed`
|
||||
(`iris/src/android/view.rs`) *does* already force a full-tree redraw
|
||||
after a rebuild: it calls `render.resize(...)` unconditionally, which
|
||||
sets `UiRenderState::resized`, which makes the next `update` take
|
||||
`redraw_all` rather than `redraw_updates`. So every widget's `draw`
|
||||
really does run again after the resume. What survives it is one cache
|
||||
further in: `TextView::render` (`iris/src/widget/text/mod.rs`) returns
|
||||
its cached `RenderedText` whenever the wrap width, buffer and attrs are
|
||||
unchanged -- true of every pre-resume row -- so `TextData::place` is
|
||||
never reached, nothing is re-rasterised into the fresh atlas, and the
|
||||
*old* atlas's `uv_min`/`uv_max`/`layer` are re-submitted verbatim. Only
|
||||
text whose content changed after the resume (the diagnostics pane Iris
|
||||
tapped) re-shapes, which is exactly the split in her screenshot.
|
||||
`Painter::glyphs` has one call site in the whole workspace, that one,
|
||||
so there is no second holder of a `RenderedText` to fix.
|
||||
|
||||
The fix, in `ba2afba`: `GlyphAtlas::generation`, bumped by
|
||||
`GlyphAtlas::clear`; `RenderedText::generation` recording which atlas
|
||||
its glyphs were placed against; `Painter::atlas_generation()`;
|
||||
`TextView::render`'s cache key gains it; and a `debug_assert_eq!` in
|
||||
`Painter::glyphs` that a submitted quad's generation is the live one.
|
||||
Headless test
|
||||
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
|
||||
(`iris/src/widget/text/mod.rs`): draw, `atlas.clear()`, `resize`, draw
|
||||
again, and assert the atlas holds the same glyph count again -- it
|
||||
stays at 0 without the fix, because the cache short-circuits before
|
||||
`place`.
|
||||
|
||||
## Build
|
||||
|
||||
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
||||
`benches/` or a script under `iris/`, never in `cargo test`). The
|
||||
scenario that matters most is a **message list** — chat apps and this
|
||||
app's transcript alike — stressed with many messages and many images.
|
||||
One case in particular: **resizing an input box** (typing enough text to
|
||||
grow it) that pushes a long list of messages above it must stay very
|
||||
fast and recalculate almost nothing — a move of everything above, not a
|
||||
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
||||
benchmark is what proves it. Done when the numbers are in this file with
|
||||
the command, and the input-box case reports draws re-run, not just frame
|
||||
time.
|
||||
|
||||
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
|
||||
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
|
||||
most of this runs as an ordinary binary — the same property
|
||||
`layout_tests.rs` relies on):
|
||||
|
||||
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
|
||||
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
|
||||
the file's own header for why (short version — every scenario here
|
||||
reduces to a *count* `UiRenderState::take_counters` already produces,
|
||||
which criterion's statistical machinery adds nothing to and which a
|
||||
new dependency is not worth pulling in for). Covers (a) first-frame
|
||||
cost of a message list of N wrapped-text rows (one in 20 also carrying
|
||||
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
|
||||
of scrolling that list, 200 ticks; (c) the input-box case — a
|
||||
fixed-height field at the bottom of the screen growing by a line 40
|
||||
times, with the message list above it filling the rest of the screen.
|
||||
Run: `cd iris && cargo bench --bench message_list` (always release —
|
||||
`cargo bench` builds the `bench` profile, which is optimized).
|
||||
- `iris/examples/bench_images.rs` — needs a real device, so it runs
|
||||
through `iris/run-headless.sh bench_images`, printing
|
||||
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
|
||||
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
|
||||
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
|
||||
image rows, checked both cold (does bind-group creation reach zero
|
||||
once loaded) and after appending one more image once settled (does
|
||||
*that* stay cheap) — the second question is what actually matters for
|
||||
a live transcript and is what turned up the two Fix items above.
|
||||
- `iris/run-bench.sh [list|images]` runs either or both and is what to
|
||||
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
|
||||
chain, or `GpuTextures`.
|
||||
|
||||
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
|
||||
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
|
||||
|
||||
cd iris && cargo bench --bench message_list
|
||||
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
|
||||
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
|
||||
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
|
||||
(b) scroll, N=100/1000/10000, 200 ticks each:
|
||||
draws=200 rewrites=0 moves=200 (identical at every N)
|
||||
per-tick average: 0.0002ms (identical at every N)
|
||||
(c) input grows 40 lines, N=100/1000/10000 rows above it:
|
||||
draws=320 rewrites=40 moves=160 (identical at every N)
|
||||
per-line average: 0.0012-0.0013ms (identical at every N)
|
||||
|
||||
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
|
||||
frame=1 bind_group_creates=1000 (cold load)
|
||||
frame=2 bind_group_creates=1000 (see Fix item above)
|
||||
frame=3 bind_group_creates=0
|
||||
frame=4 bind_group_creates=0
|
||||
(append one image here)
|
||||
frame=5 bind_group_creates=1001 (see Fix item above)
|
||||
frame=6 bind_group_creates=0
|
||||
|
||||
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
|
||||
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
|
||||
frame=2 bind_group_creates=0
|
||||
frame=3 bind_group_creates=0
|
||||
frame=4 bind_group_creates=0
|
||||
(append one image here)
|
||||
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
|
||||
frame=6 bind_group_creates=0
|
||||
|
||||
**Reading it**: (a) is real, necessary work — shaping and laying out N
|
||||
never-before-seen text rows — and scales with N as it must, ~10x cost
|
||||
per 10x N. (b) and (c) are the pass conditions that matter: both are
|
||||
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
|
||||
move chain holds for both scrolling and for a growing input box pushing
|
||||
the message list — draws/moves per tick or per line do not grow with
|
||||
list size, and the per-operation cost (a fraction of a microsecond) is
|
||||
nowhere near a frame budget. (d)'s cold-load and steady-state halves
|
||||
behave as designed; its *append* half did not, until the fix above moved
|
||||
masks/move_offsets out of the per-image bind group — now flat at O(1)
|
||||
the same way (b) and (c) are.
|
||||
|
||||
- **I5's transcript screen (`iris/transcript-ui/`, 2026-09-05) — what it
|
||||
left, each recorded at the point in the code it would go rather than
|
||||
silently dropped. See RUST.md's I5 box for the full account of what
|
||||
*was* built (the screen, `SpanStyle`, cross-row selection, the growing
|
||||
composer).**
|
||||
- [x] **Android integration for this screen — done, 2026-09-05.**
|
||||
`iris-android-app`'s `transcript-screen` Cargo feature
|
||||
(`transcript_client.rs`) runs this screen against a real `ai-server`
|
||||
through `client-core`, confirmed on-device: real scrolling, real
|
||||
touch-drag panning, tap-by-name on the composer. Two real bugs found
|
||||
and fixed along the way (a missing `INTERNET` permission; a
|
||||
background-thread redraw request that crashed via a `Looper`
|
||||
requirement, fixed by routing through `View::post_delayed` — see
|
||||
`IRIS.md`'s `Tasks::redraw_handle` entry). See RUST.md's I5 box,
|
||||
"The Android integration, done 2026-09-05" for the full account.
|
||||
- [x] **A render-time number for iris, comparable to Compose's
|
||||
`transcript-bench.sh` report — instrumentation done and a real number
|
||||
obtained, 2026-09-05 (later the same day); the clean comparable loop
|
||||
is not.** `iris_core::FrameReport` (`iris/core/src/render/
|
||||
frame_report.rs`, `IRIS.md`'s new entry) times every frame from
|
||||
`render()`'s redraw start to after `queue.submit`+`present()`, exposed
|
||||
as two named on-screen controls ("Frame report", "Reset frame
|
||||
report"). Driven against a real on-device touch-drag it read
|
||||
`frames=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms
|
||||
worst=98.1ms` — real, not inferred, but accumulated across several
|
||||
gestures rather than one clean 24-swipe loop, because of the new
|
||||
finding below. See RUST.md's I5 box, "Update, 2026-09-05, later the
|
||||
same day" for the full account.
|
||||
- [ ] **New, 2026-09-05: intermittent touch delivery to iris's
|
||||
`SurfaceView` under this checkout's `EMU_GPU=software` emulator.**
|
||||
The same swipe coordinates, confirmed (by scanning a screenshot
|
||||
column for the first non-black pixel) to sit over real row text,
|
||||
sometimes produced 30+ real frames and a screenshot diff and
|
||||
sometimes produced zero of either, across otherwise-identical
|
||||
`ui-trace` invocations. Not the already-understood "already at that
|
||||
scroll edge" case (reproduced with content confirmed taller than the
|
||||
viewport, in both directions). Leading candidate, not yet confirmed:
|
||||
this checkout's emulator was independently observed at ~78% of one
|
||||
CPU core, continuously, while idle on-screen — SwiftShader's software
|
||||
rasterisation is CPU-bound by design, and a synthetic touch competing
|
||||
with that load for delivery is plausible but unmeasured *during* a
|
||||
failing gesture (the standing rule against diagnosing from
|
||||
after-the-fact measurements applies here). Needs a sampler (load,
|
||||
`dumpsys input`, a `-i 0` `ui-trace` capture) running while a failing
|
||||
gesture is driven, and ideally a comparison under `-gpu host` (real
|
||||
Vulkan) to see whether it is specific to software rendering. This is
|
||||
what blocks the clean, comparable 24-swipe loop above.
|
||||
- [x] **Long-press-then-drag-to-select — confirmed on-device, 2026-09-05
|
||||
(later the same day).** `ui-trace` gained a `holddrag X1 Y1 X2 Y2
|
||||
HOLD_MS MOVE_MS` action (`emulator-tools`, additive, extends the same
|
||||
`MotionEvent`/`injectInputEvent` mechanism `swipe` already used):
|
||||
press, hold past `LONG_PRESS`, move, release, as one continuous touch.
|
||||
Driven against a real row (`holddrag 300 1850 300 2050 600 300`) it
|
||||
produced `iris selection: begin at row ...` then a sequence of
|
||||
`iris selection: extend to row ...` log lines
|
||||
(`transcript-ui/src/selection.rs`, a new small `log` dependency since
|
||||
selection has no accessibility label of its own yet — see the next
|
||||
item), and a screenshot taken right after shows the expected
|
||||
highlighted selection spanning multiple rows. `DragArbiter`'s own
|
||||
unit tests already covered this sequence against a synthetic clock;
|
||||
this is the first time it has been driven by a real device touch.
|
||||
- [x] **Touch-drag panning over a row's own rendered text — done,
|
||||
2026-09-05.** `row.rs` used to register `CursorSense::click_or_drag()`
|
||||
on each row's `TextEdit` for cross-row selection; `TextEdit::draw`'s
|
||||
`painter.child_layer()` (`iris/src/widget/text/edit.rs:87`) meant that
|
||||
registration won `core/src/sense.rs::run_sensors`'s per-layer
|
||||
arbitration on every frame it was pressed, not just the frame the
|
||||
press started, so a list pan gesture registered on `List` itself never
|
||||
got a turn while a row was under the finger. Fixed with
|
||||
`iris::sense::DragArbiter` (recorded in `IRIS.md`), one small state
|
||||
machine per list deciding pan vs. select the way Android does (a
|
||||
vertical drag pans immediately; a stationary press held `LONG_PRESS`
|
||||
(500ms) starts a selection which further drag extends; a horizontal
|
||||
drag while something is already selected extends immediately) —
|
||||
`transcript-ui/src/selection.rs`'s `Selection::drag` is the one place
|
||||
every row's drag now routes through. 8 new unit tests
|
||||
(`iris/src/sense.rs`'s `drag_arbiter_tests`); `cargo fmt/clippy/test
|
||||
--workspace` and `cargo ndk` (both `iris` and `transcript-ui`) all
|
||||
clean; `run-headless.sh` screenshot byte-identical to before the
|
||||
change (38578 bytes). See RUST.md's I5 box, "Gap closed, 2026-09-05".
|
||||
- [x] **Intermittent touch-scroll dropout — root-caused and fixed,
|
||||
2026-09-05.** Not the coalesced-`ACTION_MOVE` hypothesis the earlier
|
||||
pass suspected (ruled out): a gesture's `ACTION_DOWN` can land on a
|
||||
row's own padding/gap or its header, which no `CursorSense` covers,
|
||||
so `DragArbiter` never gets `press_start` and sits in `Idle`
|
||||
(answers `Undecided` forever) for that whole gesture. Fixed via a new
|
||||
`DragArbiter::is_idle()` that `Selection::drag`
|
||||
(`transcript-ui/src/selection.rs`) checks to recover a missed press
|
||||
on the next `Pressing` frame. Four new unit tests. See RUST.md's I5
|
||||
box, "Touch-scroll dropout root-caused, 2026-09-05", for the trace and
|
||||
what a peer session sharing this checkout's emulator mid-pass
|
||||
prevented from being re-verified end-to-end (the aggregate
|
||||
`iris-scroll.sh` three-run confirmation and a re-taken FrameReport
|
||||
row) — a future pass should finish that once the emulator is free.
|
||||
- [ ] **Row-level accessibility names.** The composer carries
|
||||
`.label("Message")`; transcript rows do not carry a `.label()` of
|
||||
their own yet, so `Widgets::named()` (I4) does not include them —
|
||||
`row.rs`'s `build_text_row` is where one would go, keyed to something
|
||||
stable per row (its sender + a short excerpt, matching what a screen
|
||||
reader announcing a chat message would say).
|
||||
- [x] **A tappable link** — done 2026-09-06 (P1a). `TextEditCtx::
|
||||
byte_at(pos, size)` answers which byte a tap landed on without
|
||||
handing out the parley layout, `GestureOutcome::Tapped` says the
|
||||
press committed to neither a pan nor a selection, and
|
||||
`iris::platform::OpenUrl` is the capability each backend implements
|
||||
(`xdg-open`/`open`/`start`; an `ACTION_VIEW` intent on Android,
|
||||
deferred to `after_input` the way `pending_show_keyboard` is).
|
||||
- [ ] **A background chip behind inline code.** Still needs per-range
|
||||
glyph *geometry* — a run's boxes, not one offset — which
|
||||
`TextEditCtx` does not expose outside `iris::widget::text`
|
||||
(`edit.rs`'s `layout()` helper is private). The same primitive
|
||||
`TextEdit::draw`'s own selection highlight uses internally,
|
||||
`iris/src/widget/text/edit.rs:99`. `byte_at` above deliberately did
|
||||
not open that up: a tap needs one offset and a chip needs the run.
|
||||
- [ ] **`Selection`'s anchor-row shortcut.** The row a drag started in
|
||||
is selected in full (`select_all`) the moment the drag leaves it,
|
||||
rather than "from the click point to whichever edge points away from
|
||||
the drag" — needs the same private `layout()` access as the item
|
||||
above. `selection.rs`'s module doc has the exact reasoning.
|
||||
- [x] **Syntax highlighting inside a fenced code block** — done
|
||||
2026-09-06 (P1a). `client_core::highlight::spans_of` by language,
|
||||
converted from its char indices to `SpanStyle`'s byte offsets, in
|
||||
the same Catppuccin palette `Theme.kt` uses. A language the scanner
|
||||
has no rules for stays plain rather than being coloured by the
|
||||
nearest one's.
|
||||
|
||||
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
||||
by something *and also* applies mask B — a mask can reference a parent
|
||||
mask, the way the move chain references a parent offset. Today masks
|
||||
are independent regions. Design it beside the move chain (same shape:
|
||||
a parent index and a bounded walk in the shader); do it when a real
|
||||
widget needs it, not before.
|
||||
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
||||
rejected, letting a scroll update one float rather than positions:
|
||||
input handling cares about most elements in a list, so absolute
|
||||
positions must be computed on the CPU anyway. LAYOUT.md's design
|
||||
already lands here (GPU walks the chain, CPU resolves on demand for
|
||||
hit tests). Keep the CPU resolution lazy and per query; do not
|
||||
materialise every row's absolute position per frame.
|
||||
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
|
||||
**modular — a piece of the library rather than a core part forced into
|
||||
everything, the same way input is**. Whatever the mechanism, a widget
|
||||
that does not animate must pay nothing and import nothing for it.
|
||||
|
||||
## Found by P1a (2026-09-06)
|
||||
|
||||
- [x] **`Rect` claimed to be size-independent, and it is not.** A `Rect`
|
||||
fills whatever region it is handed, so `draw_inner`'s size-independent
|
||||
fast path -- which rewrites primitives with
|
||||
`r.outside(&from).within(®ion)` rather than redrawing -- could not
|
||||
reproduce its `draw`, and a `.background(rect(..))` kept the size of
|
||||
the *provisional* full-region pass `Span` does in phase 1. One fenced
|
||||
code block's panel covered every block below it and every row below
|
||||
that. Fixed in `iris/src/widget/rect.rs`; the reason is written at the
|
||||
definition. Suspect the same cause for anything else tinted with a
|
||||
background rect.
|
||||
- [x] **A wrapped transcript row tripped `reposition`'s debug assert.**
|
||||
Settled 2026-09-06 by giving the move slot one owner instead of two.
|
||||
`mov` accumulates a delta on it, `reposition` overwrote it, and both
|
||||
legitimately land on one widget in one frame: `List::place`'s
|
||||
Bottom-known branch offers a row a same-size box that has *moved*
|
||||
(`mov`), then corrects the placement inside it when the row's cached
|
||||
height no longer matches what the row reports (`reposition`). The
|
||||
slot now always means `move_applied + repositioned`
|
||||
(`ActiveData::repositioned`, `iris/core/src/ui/render_state.rs`), so
|
||||
`reposition` adds the move rather than dropping it -- the assert is
|
||||
gone and the arithmetic is right. Test:
|
||||
`a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`
|
||||
in `layout_tests.rs`, which lands the child at the *offered* position
|
||||
(-100px) instead of the placement (100px) without the fix, and a
|
||||
`debug_assert_eq!` in `reposition` that nothing but those two ever
|
||||
writes the slot. Verified with the `.wrap(true)` repro (draws, no
|
||||
panic) and an emulator bench run with assertions live.
|
||||
- [ ] **Desktop colours are washed out: the winit surface is sRGB and
|
||||
the shader writes the palette's bytes as linear.** Mocha Crust
|
||||
(17,17,27) is drawn as (73,73,91), measured off
|
||||
`run-headless.sh --shot`. Android is correct, so this is the surface
|
||||
format rather than the palette -- but it makes the desktop build
|
||||
useless as a colour reference, which is exactly what P1a needed it for
|
||||
when the emulator could not draw glyphs.
|
||||
- [x] **Every glyph was a solid box on the GLES backend -- iris's bug,
|
||||
not the emulator's.** Fixed 2026-09-06. The atlas is one
|
||||
`texture_2d_array` and `GpuTextures::new` created it with **one
|
||||
layer**; wgpu-hal picks the GL target from the descriptor
|
||||
(`(false, 1) => TEXTURE_2D`), so under GLES that array was a
|
||||
`GL_TEXTURE_2D` bound to the shader's `sampler2DArray`, the unit was
|
||||
incomplete, every `textureSample` returned (0,0,0,1), and
|
||||
`draw_glyph`'s `color.a *= texel.a` filled the quad. `MIN_ARRAY_LAYERS
|
||||
= 2` in `iris/core/src/render/texture.rs`, with a `debug_assert!` at
|
||||
`create_array_texture`. Vulkan (the phone, the desktop's default
|
||||
backend) was never affected. Reproduce the class in seconds without an
|
||||
emulator: `iris`'s `force-gles` feature now switches the **desktop**
|
||||
backend too -- `./run-headless.sh transcript --shot /tmp/x.png -- -p
|
||||
transcript-ui --features iris/force-gles`.
|
||||
|
||||
- [ ] **The bench report pane draws over the transcript rows instead of
|
||||
replacing them.** Visible on the emulator for the first time now that
|
||||
glyphs render there (`/tmp/emu-final.png`, 2026-09-06): after a bench
|
||||
run the report's lines and the transcript's occupy the same rows in the
|
||||
top third of the screen, both legible, neither on top. Pre-existing --
|
||||
the same overlap is in a screenshot taken before the move-slot fix -- so
|
||||
it is its own item, most likely the report pane not masking or not
|
||||
claiming its region.
|
||||
|
||||
## Found by P1b (2026-09-06), all with a headless repro
|
||||
|
||||
Each was found by looking at `iris/run-headless.sh transcript -- -p
|
||||
transcript-ui` rather than at a diff, and each is worked around in
|
||||
`transcript-ui/src/tool.rs` rather than fixed here. docs/RUST.md's P1b box
|
||||
has the fuller account.
|
||||
|
||||
- [ ] **A `Span` of `Pad`ded children inside another `Span` places those
|
||||
children a slot out of step.** Each child drew its content one sibling's
|
||||
height below its own box. Repro: `IRIS_TOOLS_EXPANDED=1
|
||||
iris/run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui`
|
||||
with `tool.rs`'s group built as `Span(DOWN)[header, Pad(Span(DOWN)
|
||||
[cards]), bar]` instead of the single `Span` it uses now. Bisected:
|
||||
removing the inner `Span` fixes it, and so does removing the children's
|
||||
own `Pad`; the background `Stack`, the `Sized` wrappers and the
|
||||
`WidgetPtr` per child make no difference. **Not** the `mov`-vs-
|
||||
`reposition` fault f5b8893 fixed -- it survives that commit. The
|
||||
workaround costs the group the 4dp inset its Compose counterpart holds
|
||||
its cards off the edge by, so this is worth fixing.
|
||||
- [ ] **`scrollable_on(Axis::X)` on a non-editable `Text` draws nothing.**
|
||||
The panel is drawn and the text inside it is not. A markdown fence does
|
||||
the same to a `TextEdit` and is fine, so it is the widget kind rather
|
||||
than the chain. `tool.rs`'s `raw_block` is `masked()` only until this is
|
||||
fixed, which means a long command is clipped rather than pannable.
|
||||
- [ ] **No overflow ellipsis.** `TextAttrs` can wrap or not wrap; there is
|
||||
no "one line, ellipsised" the way `maxLines = 1` + `TextOverflow.
|
||||
Ellipsis` gives Compose. A tool card's summary is clipped instead, so
|
||||
nothing on screen says it was cut. Whichever end is cut has to be a
|
||||
choice when this lands: a path is identified by its tail, a command by
|
||||
its head.
|
||||
- [ ] **A drawn chevron.** `Chevron.kt` draws its own strokes precisely
|
||||
because a chevron from a font is a glyph a system font may not have --
|
||||
and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4,
|
||||
while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the
|
||||
monospace face as a result. A real fix needs a line/path primitive;
|
||||
iris has rects, text and textures only.
|
||||
- [ ] **A tool card's text is not selectable.** `Selection` is keyed
|
||||
`(RowKey, block index)` and a card has no markdown blocks, so nothing in
|
||||
a card registers. Compose's `SelectionContainer` covers tool output,
|
||||
which is the text people most want to copy. Needs a key for "the nth
|
||||
text of this row" that a card can mint without colliding with a
|
||||
message's blocks.
|
||||
|
||||
## Build (for the port)
|
||||
|
||||
Widgets `RUST.md`'s "The port, in order (decided 2026-09-05)" needs and
|
||||
iris does not have yet, one entry per gap, named against the P-step that
|
||||
first needs it. Move an entry up to "Fix" or tick it in place once built;
|
||||
do not duplicate it there.
|
||||
|
||||
- [ ] **A history-paging cushion measured in on-screen viewports, not a
|
||||
row count.** (**P1**.) `iris::widget::List` has no equivalent of the
|
||||
Compose app's `HISTORY_SCREENS` — AGENTS.md's "Things that have
|
||||
bitten" is explicit that a fixed row count under-fills a screen on a
|
||||
tool-heavy transcript and over-fills one on a text-heavy one, so
|
||||
whatever loads the next page has to ask the list how many viewports
|
||||
are actually on screen, not assume a constant.
|
||||
- [ ] **A scaled thumbnail/image widget for an in-transcript image.**
|
||||
(**P1**.) `SessionImage.kt`'s bitmap decode-and-downscale has no iris
|
||||
counterpart; iris's own image widget (used by `bench_images.rs`) draws
|
||||
a loaded texture but does nothing about sourcing or scaling one from a
|
||||
server-produced attachment.
|
||||
- [ ] **A modal/dialog primitive.** (**P1**, reused by **P3** and
|
||||
**P5**.) Needed for the session settings dialog, `UsageDialog`'s
|
||||
equivalent, and the delete-with-`deleteForeign` confirmation with its
|
||||
toggle switch. Build once, wherever it is first needed, rather than
|
||||
once per screen that wants one.
|
||||
- [ ] **A horizontal gauge/bar widget.** (**P1**.) For
|
||||
`SessionUsageBar`'s equivalent — a bounded fill reflecting a fraction,
|
||||
nothing fancier.
|
||||
- [ ] **A `BusyItem` equivalent: a dimmed row carrying an operation
|
||||
label that does not block its list's own scroll/drag.** (**P3**.) The
|
||||
Compose version tried an overlay first and it swallowed the drag along
|
||||
with the tap (AGENTS.md's "Shared appearance") — worth not repeating
|
||||
that attempt in iris before building the row-level version directly.
|
||||
- [ ] **A toggle switch.** (**P3**.) For the delete dialog's
|
||||
`deleteForeign` control; iris has no switch/checkbox widget yet as far
|
||||
as this pass found.
|
||||
|
||||
## Reconsider
|
||||
|
||||
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
|
||||
to compose a widget from others (a button is the main case). With
|
||||
sizing folded into `draw`, composing may be easy enough that `View` is
|
||||
redundant. Decide after the layout change lands, by writing a button
|
||||
both ways and keeping the one that is shorter to explain; delete the
|
||||
other rather than keeping two ways.
|
||||
|
||||
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
|
||||
|
||||
- [x] **A third length kind beside relative and pixels, so display scales
|
||||
"just work".** Done 2026-09-06 — `Len::dp`/`len_fns::dp`, resolved
|
||||
against `UiRenderState`/`Painter::density()` at `apply_rest` time; text
|
||||
additionally rasterises at the resolved (physical) size instead of
|
||||
scaling a low-resolution bitmap afterward, which was making text blurry.
|
||||
`Span::gap`/`Padding` moved from `f32` to `Len` so they take `dp(...)`
|
||||
too; transcript-ui's row/composer padding and one example migrated.
|
||||
`em` was not added — nothing in this pass needed a text-relative unit,
|
||||
and `dp`'s own doc says why it and physical pixels are kept as separate
|
||||
fields rather than one the caller pre-multiplies. Not yet verified on
|
||||
Iris's own phone at two densities (this pass had no device) — see
|
||||
docs/RUST.md's P0 box and docs/IRIS.md's 2026-09-06 entry for what to
|
||||
check. Iris's words: "another length type similar to absolute &
|
||||
relative, so instead there would be relative, pixels, and another unit
|
||||
like em or whatever is standard. That way different display scales
|
||||
should just work." Today a length is either a fraction of the parent
|
||||
(`rest`/relative) or physical pixels, and the phone drew 16 px text at
|
||||
roughly a third of its intended size until the P0 fixes applied the
|
||||
display's scale factor globally. That global scale is a stopgap for the
|
||||
benchmark; the real shape is a unit resolved against the display's
|
||||
density at layout time — Android's `dp` / CSS's reference pixel is the
|
||||
standard (1 unit = 1/160 in), with `em` as the text-relative option —
|
||||
so a widget author writes `16.dp()` once and never sees the scale.
|
||||
Done when: `Length` (or whatever the enum is called) has the third
|
||||
variant; every place that resolves a length takes the density; the
|
||||
examples and `transcript-ui` use the new unit for text sizes, padding
|
||||
and control sizes; the emulator at two densities and the phone draw the
|
||||
same layout at the same physical size. After the bench setup is
|
||||
finished, before P1 draws any new screen.
|
||||
|
||||
## From the phone, bench v2 (2026-09-06): streaming re-lays out the whole message
|
||||
|
||||
- [x] **Streaming a delta into a long message costs a full text layout of
|
||||
that message.** **Done 2026-09-06** -- a row is a column of one
|
||||
`TextEdit` per markdown block (`client_core::markdown_blocks`,
|
||||
`row::RowBlocks::apply_delta`), so a delta re-shapes the last block and
|
||||
keeps every earlier block's layout. A block is the selection unit now
|
||||
(`Selection`'s `SelKey`); selection across blocks and rows still works,
|
||||
checked on the emulator with a real long-press drag. Pass condition met
|
||||
in `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`:
|
||||
a delta into a 100-paragraph reply redraws the same widget count as one
|
||||
into a one-paragraph reply (30 either way). Emulator stream phase, same
|
||||
AVD before and after: **p50 61.5 -> 54.5ms, p90 211.7 -> 113.1ms, p99
|
||||
342.6 -> 137.4ms, worst 403.6 -> 143.0ms**, 202 -> 293 frames in the same
|
||||
21 seconds. docs/RUST.md's Task B box has the detail and the two dead
|
||||
ends. **The phone is the measurement that decides it** -- these are
|
||||
emulator numbers and only the ratio transfers.
|
||||
|
||||
The original entry, for the record: Iris's phone report (`docs/bench/iris-phone-v2-2026-09-06.md`):
|
||||
the stream phase is the one place iris is behind Compose (p50 18.2 ms vs
|
||||
13.4 ms; p99 level at ~43 ms). `TranscriptScreen::apply` replaces only
|
||||
the last row, but that row is the growing message, and replacing it
|
||||
re-renders its markdown and re-shapes the entire paragraph run through
|
||||
parley on every event. Compose pays a reparse (8.6 ms mean) for the
|
||||
same event. What "done" looks like: a streamed delta re-lays out only
|
||||
the block it lands in (the last paragraph or code block), with earlier
|
||||
blocks' layouts kept -- which needs a row to be a column of per-block
|
||||
`Text`s rather than one `TextEdit` for the whole message, or parley's
|
||||
layout to be split at block boundaries; measured by the stream phase's
|
||||
p50 dropping below Compose's on the phone. Do this after the four bench
|
||||
v2 defects (stale primitives, finger fling, decay curve, IME show) are
|
||||
closed, since they are what make the run unrepresentative today.
|
||||
|
||||
## From the phone, 2026-09-07 (build from ed04d4c)
|
||||
|
||||
- [ ] **"Some transcript blocks will be hidden until I uncover enough of
|
||||
them."** Two screenshots of the bench app's transcript at the top
|
||||
edge, both wrong in opposite directions: in one, rows scrolled above
|
||||
the viewport are still drawn and bleed *through* the header bar
|
||||
(`version = "0.1.0"` and a paragraph visible behind "Run benchmark /
|
||||
Copy report / Diagnostics"), so the list's mask is not clipping at
|
||||
the header's bottom edge; in the other, scrolled a little further,
|
||||
the row that straddles the top edge is not drawn at all -- black from
|
||||
the header down to "You", where the previous shot showed a paragraph
|
||||
-- so a row is culled as soon as its *top* leaves the viewport rather
|
||||
than when its *bottom* does. Suspects: the list's visible-range test
|
||||
(`iris/src/widget/list.rs`) comparing a row's top against the
|
||||
viewport top; the mask region for the transcript set from the
|
||||
window rather than from the area under the header; and the two-phase
|
||||
provisional/real draw noted in `03c6be8`'s header-duplicate
|
||||
investigation, which was never root-caused and has the same shape.
|
||||
Reproduce at layer 1 of the test rig: a headless screen with a row
|
||||
straddling the top edge must place that row, and a primitive above
|
||||
the header's bottom must be masked. Fix both with one rule: a row is
|
||||
drawn if any part of it intersects the viewport, and the viewport is
|
||||
the list's own region.
|
||||
@@ -0,0 +1,214 @@
|
||||
# Review: iris changes since 0e46293
|
||||
|
||||
Scope: `git diff 0e46293..HEAD -- iris/ client-core/` (58 files, +5224/-226).
|
||||
Read-only review; no source changed. Ordered likely-bug, then invariant
|
||||
guards, then rules, then tests/docs.
|
||||
|
||||
## Likely bugs
|
||||
|
||||
1. **`iris/transcript-ui/src/lib.rs:152-160` (`RowDiff::Rebuild` arm of
|
||||
`TranscriptScreen::apply`) never unregisters the rows it drops from
|
||||
`Selection`, so a stale `WeakWidget<TextEdit>` outlives the widget it
|
||||
points to and the next touch on *any* row panics.**
|
||||
`Selection::rows: BTreeMap<RowKey, WeakWidget<TextEdit>>` documents its
|
||||
own contract at `selection.rs:69-71`: "every addition here needs its
|
||||
removal ... called when `List` evicts the row." The `ReplaceLast` arm
|
||||
above it honours this (`lib.rs:143-145`, `self.selection.borrow_mut()
|
||||
.unregister(old_key)` when the key changes). The `Rebuild` arm calls
|
||||
`(self.list)(rsc).clear()` and rebuilds every row from `new_rows`, but
|
||||
never touches `self.selection` — any key present in `old_rows` and
|
||||
*absent* from `new_rows` (exactly what `group_tool_runs` regrouping two
|
||||
separate tool-call rows into one produces — see `diff_tests::
|
||||
a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback`,
|
||||
which tests the diff decision but not `apply` itself) is left in
|
||||
`self.rows` pointing at a widget `List::clear()` just freed.
|
||||
`TextEditable::edit` (`iris/src/widget/text/edit.rs:582-587`) resolves
|
||||
that handle with `ui.widgets.get_mut(self).unwrap()` — an unconditional
|
||||
panic on the freed slot. `Selection::begin` (`selection.rs:88-101`)
|
||||
iterates *every* registered row (`w.edit(ui).deselect()`) on an
|
||||
ordinary fresh press, so the crash fires on the next tap anywhere in
|
||||
the transcript after a regroup, not only on a tap targeting the
|
||||
orphaned row.
|
||||
Fix: give `Selection` a way to reconcile against the row set that
|
||||
survived a rebuild (e.g. `Selection::retain(&self, keys: &BTreeSet<RowKey>)`
|
||||
removing everything else, called from the `Rebuild` arm before
|
||||
rebuilding), or simplest — call `self.selection.borrow_mut()` cleared
|
||||
the same way `List::clear()` clears the list, then let the rebuild's
|
||||
`push_row` calls re-`register` everything as they already do.
|
||||
|
||||
## Guarded invariants missing
|
||||
|
||||
2. **`iris/src/widget/list.rs:751` (`List::place`) indexes/expects on
|
||||
`slot` with no assertion that it exists.** `slot_widget` (`:563-575`)
|
||||
panics via `.expect(...)` for a sentinel with no widget set, and does
|
||||
an unchecked `&self.items[s as usize]` for a real index — a bare
|
||||
"index out of bounds" with no context if `place` is ever reached with a
|
||||
stale slot. Every current caller happens to derive `slot` from
|
||||
`repair_anchor`/`prev_slot`/`next_slot`, which already check existence,
|
||||
but that invariant is enforced by convention across three call sites,
|
||||
not by the function that depends on it. Add
|
||||
`debug_assert!(self.slot_exists(slot), "place() called with a slot that doesn't exist: {slot:?}");`
|
||||
at the top of `place`.
|
||||
3. **`iris/src/widget/list.rs:426` (`List::fling`) and `sense.rs`'s
|
||||
`FlingCalculator::distance`/`duration`/`position_at` never check that
|
||||
the incoming velocity is finite.** A `NaN`/`inf` velocity (a
|
||||
`VelocityTracker::velocity()` divide-by-near-zero span, or a caller
|
||||
passing a raw device value straight through) propagates through
|
||||
`deceleration_for`'s `.ln()` silently — the fling either never settles
|
||||
(`settled_on_schedule` compares against a `NaN` `duration()`, which is
|
||||
always `false`) or jumps to `NaN` positions with nothing on screen
|
||||
saying why. Add `debug_assert!(velocity_px_per_s.is_finite())` in
|
||||
`List::fling` and `FlingCalculator::new`/`distance`.
|
||||
4. **`iris/src/sense.rs:592-604` (`VelocityTracker::velocity`) has no
|
||||
assertion that samples are chronological.** `add_sample` trusts its
|
||||
caller's `Instant` ordering; a caller that samples out of order (a
|
||||
restored/replayed gesture, a test) would silently produce a negative
|
||||
`span` handled only by the `span <= 0.0 => 0.0` catch-all, masking the
|
||||
bug that produced it rather than surfacing it. Add
|
||||
`debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last))`
|
||||
in `add_sample`.
|
||||
5. **`iris/core/src/render/frame_report.rs:247-252` (`mark_phase`) has no
|
||||
assertion that phases are pushed in non-decreasing `start_index`
|
||||
order.** `phase_stats`'s slicing (`:274`, `idx >= phase.start_index &&
|
||||
idx < end_index`) silently produces an empty or nonsensical slice for
|
||||
an out-of-order phase rather than surfacing the misuse — cheap to add
|
||||
given `self.phases.last()` is already in scope:
|
||||
`debug_assert!(self.phases.last().is_none_or(|p| self.total_frames >= p.start_index));`
|
||||
|
||||
## Rules
|
||||
|
||||
6. **Two mechanisms answer "what row selection points at, still valid?"**
|
||||
`Selection` relies on callers remembering to `unregister` (finding 1);
|
||||
`List` relies on callers deriving slots only from already-checked
|
||||
sources (finding 2). Both are the same class of problem — a derived
|
||||
handle that silently outlives what it points to — solved ad hoc twice
|
||||
rather than once. Not asking for a shared abstraction here, but the two
|
||||
should at minimum cross-reference each other's doc comment so the next
|
||||
caller who adds a third handle-into-`List`-rows type (the code rules'
|
||||
"a rule that governs a set belongs to the set") finds both existing
|
||||
examples.
|
||||
7. **`iris/android-app/src/bench_client.rs:224-225` (`battery_line`)
|
||||
calls `.min().unwrap()`/`.max().unwrap()` on `samples` guarded three
|
||||
lines above by `if samples.is_empty()`, which is fine — but the guard
|
||||
and the two unwraps are two statements apart with a `let mean = ...`
|
||||
in between reading the same slice; a future edit reordering those
|
||||
lines loses the guard's protection silently.** Low severity (this is
|
||||
the bench tool, not the app), but worth a one-line comment tying the
|
||||
unwraps back to the guard, or restructuring as
|
||||
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`
|
||||
pattern so the empty case can't be separated from the check by a future
|
||||
edit.
|
||||
|
||||
## Tests
|
||||
|
||||
8. **No test exercises `TranscriptScreen::apply`'s `Rebuild` arm through
|
||||
`Selection`.** `lib.rs`'s `diff_tests` module (`:284-379`) tests only
|
||||
the pure `diff_rows` decision function, never `apply` itself wired to a
|
||||
real `Selection`; `selection.rs`'s own tests (`a_missed_press_start_
|
||||
recovers_on_the_next_pressing_frame`, `unregister_forgets_the_row_and_
|
||||
clears_a_matching_anchor`) never go through `apply`/`List::clear`
|
||||
either. This is exactly the gap that let finding 1 through: the two
|
||||
pieces (`apply`'s fallback, `Selection`'s registration contract) are
|
||||
each tested in isolation and never together. Add: build a
|
||||
`TranscriptScreen`, force a `RowDiff::Rebuild` (two adjacent tool-call
|
||||
rows regrouping, per the existing `diff_tests` case), then call
|
||||
`selected_text`/simulate a fresh press on a surviving row and assert no
|
||||
panic.
|
||||
9. **`iris/src/widget/list.rs`'s fling tests check total distance and the
|
||||
start/end clamp but not the speed profile in between.**
|
||||
`fling_moves_the_list_and_then_settles`/`fling_distance_is_positive_
|
||||
toward_the_end` only assert the fling started, moved in the right
|
||||
direction, and eventually stopped — none checks that
|
||||
`tick_fling`'s per-tick delta is *monotonically decreasing* once past
|
||||
the fling's peak (the property `fling_calculator_tests::position_at_
|
||||
is_monotonic_and_clamped_past_the_end` already checks one level down,
|
||||
for `FlingCalculator` alone, but never through `List::tick_fling`'s own
|
||||
`scroll`/`anchor.offset` accumulation). A regression that made
|
||||
`tick_fling` apply the *total* distance every tick instead of the
|
||||
incremental one, for instance, would still pass both existing tests
|
||||
(final position and direction are unaffected by how the interior ticks
|
||||
split it up) while being wildly wrong every intermediate frame.
|
||||
10. **`iris/src/widget/list.rs::replacing_the_last_row_stays_pinned_to_
|
||||
the_bottom` and its sibling test `replace_back`'s effect on the
|
||||
displayed row, never that the row it evicted is actually gone from
|
||||
`heights`/`extents`.** Both tests assert the *new* row's position;
|
||||
neither asserts `old.key` is absent from `list_ref.heights`/`extents`
|
||||
after the replace (the "stale primitive" class finding 1 is a
|
||||
production instance of). A cheap addition: assert
|
||||
`!list_ref.heights.contains_key(&old.key)` after `replace_back` in the
|
||||
existing test, since `old.key` is already returned to the test as
|
||||
`evicted`... (`lib.rs` calls it that way; the `list.rs` test would need
|
||||
to capture the key from `old` similarly.)
|
||||
|
||||
## Docs
|
||||
|
||||
No missing `IRIS.md` entry found for a *public* API change in this diff —
|
||||
`List::fling`/`VelocityTracker`/`FlingCalculator`, `List::
|
||||
anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/
|
||||
`late_at_hz`, `UiRenderNode::new`'s `Result` change, `Len::dp`, and
|
||||
`List::replace_back`/`clear`/`TranscriptScreen::apply` all have entries.
|
||||
The `List::replace_back`/`clear`/`TranscriptScreen::apply` entry
|
||||
(`docs/IRIS.md:526`) predates this review's finding 1 and does not mention
|
||||
`Selection`'s registration contract at all — once finding 1 is fixed,
|
||||
that entry should gain a line noting what the fix requires of a caller
|
||||
that keeps its own row-keyed side table (the same shape `Selection` is),
|
||||
so the next such table doesn't reproduce the same gap.
|
||||
|
||||
## Fixed, 2026-09-06
|
||||
|
||||
All ten findings addressed after the `DragGesture` merge (`selection.rs`
|
||||
was rewritten by that merge, but finding 1's shape and location were
|
||||
unchanged — `TranscriptScreen::apply`'s `Rebuild` arm, `iris/transcript-ui/
|
||||
src/lib.rs`).
|
||||
|
||||
1. **Fixed.** `Selection::clear()` (`selection.rs`) drops `rows` and
|
||||
`anchor`, called from `apply`'s `Rebuild` arm right before
|
||||
`List::clear()` — `push_row` re-`register`s whatever survives as it
|
||||
rebuilds each row, the "simplest" fix option the finding named.
|
||||
2. **Fixed.** `debug_assert!(self.slot_exists(slot), ...)` at the top of
|
||||
`List::place` (`iris/src/widget/list.rs`).
|
||||
3. **Fixed.** `debug_assert!(velocity_px_per_s.is_finite())` in
|
||||
`List::fling`, and `debug_assert!(velocity.is_finite())` in
|
||||
`FlingCalculator::distance`/`duration` (`iris/src/sense.rs`).
|
||||
`position_at` calls both, so it inherits the guard rather than needing
|
||||
its own.
|
||||
4. **Fixed.** `debug_assert!` on chronological sample order in
|
||||
`VelocityTracker::add_sample` (`iris/src/sense.rs`).
|
||||
5. **Fixed.** `debug_assert!` on non-decreasing `start_index` in
|
||||
`FrameReport::mark_phase` (`iris/core/src/render/frame_report.rs`).
|
||||
6. **Fixed (doc cross-reference only, as asked).** `Selection::register`'s
|
||||
doc now points at `List::place`'s `slot_exists` assertion and vice
|
||||
versa isn't needed since finding 2's fix already cites this file in
|
||||
its own comment; both are grep-able on "docs/REVIEW-2026-09-06.md" and
|
||||
on each other's type names.
|
||||
7. **Fixed.** `bench_client.rs::battery_line` restructured to
|
||||
`let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())`,
|
||||
so the empty-guard and the two lookups can no longer be separated by a
|
||||
future edit.
|
||||
8. **Fixed.** `transcript-ui`'s new `apply_tests::
|
||||
a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection`
|
||||
(`lib.rs`) builds a real `TranscriptScreen`, forces the same regroup
|
||||
shape `diff_tests` already covers at the pure-diff level, calls `apply`,
|
||||
and then `Selection::begin` on a surviving row — which panicked before
|
||||
fix 1, resolving a `WeakWidget` `List::clear()` had just freed.
|
||||
9. **Fixed.** `list.rs`'s new `tick_fling_applies_shrinking_incremental_
|
||||
deltas` flings toward the end from `jump_to_start` and asserts each
|
||||
tick's `extents[&0]` delta is no larger than the previous one — would
|
||||
fail against a `tick_fling` that applied the total spline distance
|
||||
every tick instead of the incremental slice, which the two pre-existing
|
||||
fling tests cannot catch.
|
||||
10. **Fixed.** `list.rs`'s new `replace_back_forgets_the_evicted_keys_own_
|
||||
height` replaces row 4 with a row keyed `100` (the two existing
|
||||
`replace_back` tests always reuse the same key, so neither actually
|
||||
exercises the removal) and asserts `heights` no longer contains the
|
||||
evicted key.
|
||||
|
||||
Docs: `docs/IRIS.md`'s 2026-09-05 `List::replace_back`/`clear`/
|
||||
`TranscriptScreen::apply` entry now has a line on what the fix requires of
|
||||
a caller with its own row-keyed side table, naming `Selection` as the
|
||||
example and dating the fix.
|
||||
|
||||
Verification run alongside the rest of this pass's checks: `cargo fmt
|
||||
--all`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`
|
||||
from `iris/` — see docs/RUST.md's plan box for the pass/fail and any
|
||||
caveats from this same session.
|
||||
@@ -0,0 +1,496 @@
|
||||
# How iris should render an unbounded number of images
|
||||
|
||||
## Status (2026-09-04)
|
||||
|
||||
**Implemented**, on the `rustify` branch of `ai-app-2`, in `iris/core` and
|
||||
`iris/src/default/render.rs`. See "Implemented, 2026-09-04" at the bottom for
|
||||
what landed, what differs from the proposal below and why, and what was
|
||||
verified versus merely reasoned about. The short version: the binding array
|
||||
is gone, `request_device` asks for no features and no binding-array limits,
|
||||
and that is now proven on the emulator's software Vulkan
|
||||
(`rigs/gpu-probe`), not just read from the code. `RUST.md`'s blocking item
|
||||
is resolved.
|
||||
|
||||
Iris (the person) asked whether iris's (the library's) approach to
|
||||
"draw however many images happen to be on screen" — relevant here because a
|
||||
transcript can hold an unbounded number of attached screenshots — actually
|
||||
works on mobile, her recollection being that it does not. Checked rather
|
||||
than assumed, on 2026-09-04, on the `rustify` branch of `ai-app-2`. This
|
||||
file is that investigation and the resulting recommendation, written for a
|
||||
second agent to review before anything in iris's render core changes — no
|
||||
code has been written against this yet.
|
||||
|
||||
## The problem
|
||||
|
||||
Every texture iris ever creates — every `Image` widget
|
||||
(`iris/src/widget/image.rs`) and every glyph atlas page — gets a permanent
|
||||
slot in one array via `Textures::add` (`iris/core/src/primitive/texture.rs:65`).
|
||||
Both of iris's texture-sampling primitives (`TEXTURE` and `GLYPH`) read that
|
||||
array by index: `core/src/render/shader.wgsl:56` declares
|
||||
`var views: binding_array<texture_2d<f32>>`, sized by
|
||||
`UiLimits::default()` (`core/src/render/mod.rs:347`) at **100,000 textures,
|
||||
1,000 samplers**. Getting a device to accept that layout needs three wgpu
|
||||
features — `TEXTURE_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
|
||||
`PARTIALLY_BOUND_BINDING_ARRAY` — which correspond to Vulkan's
|
||||
`VK_EXT_descriptor_indexing` ("bindless"), promoted to Vulkan core at 1.2.
|
||||
|
||||
A transcript with an unbounded number of image attachments is exactly the
|
||||
case that grows this array without bound: each attachment becomes its own
|
||||
`Image` widget, which takes its own permanent array slot until dropped.
|
||||
|
||||
## What was measured
|
||||
|
||||
**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features
|
||||
and limits with no window and no APK — a plain executable pushed with
|
||||
`adb push` and run from `/data/local/tmp`. It has two parts:
|
||||
`wgpu::Adapter::request_device` with iris's exact `Features`/`Limits`
|
||||
(`src/main.rs`), and a raw Vulkan query bypassing wgpu entirely via `ash`
|
||||
(`src/vk.rs`), to tell "the driver doesn't have it" apart from "wgpu didn't
|
||||
detect it."
|
||||
|
||||
- **On this VM's own GPU** (Vulkan via Venus onto an RX 7900 XT):
|
||||
`IRIS DEVICE: ok`. Not the case that matters — nobody's phone is a
|
||||
discrete desktop GPU — but it is why the design was never checked before
|
||||
now: it always worked in the one place it was tried.
|
||||
- **On the Android emulator's guest Vulkan**, both ICDs it ships
|
||||
(`vk_swiftshader_icd.json` and, cold-booted, `lvp_icd.json`/lavapipe):
|
||||
`request_device` **fails** —
|
||||
`Unsupported features were requested: TEXTURE_BINDING_ARRAY |
|
||||
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
|
||||
PARTIALLY_BOUND_BINDING_ARRAY`. The raw `ash` query on lavapipe shows the
|
||||
driver itself reporting all seven descriptor-indexing sub-features as
|
||||
`true` at device API version 1.3 — so wgpu-hal's own feature detection is
|
||||
being more conservative than the driver here, for a reason not chased
|
||||
further (a likely instance-version negotiation gap, since the extension
|
||||
only promoted to core at 1.2). That part is a wgpu-hal/emulator question,
|
||||
not the finding that matters, and is **not** why this design is rejected.
|
||||
|
||||
**The finding that matters is about real phones, sourced rather than
|
||||
recalled:**
|
||||
|
||||
- The **Android Vulkan Profile 2025** — Google and Khronos's current
|
||||
baseline, covering **80.1% of active Vulkan-capable Android devices** as
|
||||
of October 2025
|
||||
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile)) —
|
||||
does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
|
||||
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
|
||||
(indexing by a value uniform across the invocation — Vulkan 1.0 baseline,
|
||||
unrelated to bindless) and stops there; true of the 2021 and 2022
|
||||
profiles as well.
|
||||
- Arm's own developer documentation states **"`VK_EXT_descriptor_indexing`
|
||||
is supported on all Valhall and 5th Gen GPUs"**
|
||||
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
|
||||
Mali generations from roughly 2019 (Mali-G77) onward, with no claim made
|
||||
for Bifrost, Midgard or Utgard, which are still common in budget and
|
||||
older Android phones that are still in daily use.
|
||||
- A search engine's summarized claim of "1% support on Android" for this
|
||||
extension was checked against its cited source (an Arm blog post from
|
||||
2021) and **was not actually there** — that number does not appear in
|
||||
any primary source found and should not be repeated. The baseline-
|
||||
profile finding above is the one with an attributable source; use it
|
||||
instead.
|
||||
|
||||
So this is not a software-renderer artifact. A real, currently-shipping
|
||||
share of the Android fleet lacks the feature iris's texture pipeline asks
|
||||
for unconditionally, and neither the emulator's failure nor the current
|
||||
official hardware baseline gives any reason to expect that to change soon.
|
||||
|
||||
## What growth already costs today, before any redesign
|
||||
|
||||
Checked directly in `core/src/render/mod.rs` and `core/src/render/texture.rs`,
|
||||
because "does this redesign make things worse" needs the current baseline
|
||||
first:
|
||||
|
||||
- The `RenderPipeline` (`UiRenderNode::new`) is created **once** and never
|
||||
rebuilt for any reason related to texture count — its bind group
|
||||
*layouts* declare fixed slot counts (`limits.max_textures`,
|
||||
`limits.max_samplers`) up front and that never changes at runtime. Growth
|
||||
was never at risk of recreating the pipeline, in the current design or
|
||||
any redesign discussed below.
|
||||
- What **does** get rebuilt: `UiRenderNode::update` calls
|
||||
`self.textures.update(&mut ui.textures)`, and if that reports any change,
|
||||
rebuilds `self.rsc_group` — one `BindGroup` whose entries are
|
||||
`BindingResource::TextureViewArray(&tex_manager.views())`, collected
|
||||
fresh over **every currently-live texture**, plus the sampler array and
|
||||
the mask buffer. This happens on every texture `Push`, `Set`, or `Free`
|
||||
— an image added anywhere in the whole app rebuilds one shared structure
|
||||
referencing every other image too.
|
||||
- The one path already excluded from this, on purpose, is a `Patch` —
|
||||
writing into an existing texture's pixels without changing which
|
||||
textures exist. The code says why directly
|
||||
(`core/src/render/texture.rs`, in `GpuTextures::update`): *"A patch
|
||||
changes texture contents, not the binding array, so it must not report
|
||||
`changed` — rebuilding the bind group per glyph is the cost this exists
|
||||
to avoid."* This is exactly the mechanism I1 built for the glyph atlas:
|
||||
growing an existing atlas page costs a `write_texture` into a sub-rect,
|
||||
nothing else.
|
||||
|
||||
So today, growth that stays inside an existing texture (glyphs added to an
|
||||
atlas page) is already free. Growth that adds a *new* texture — a new atlas
|
||||
page, or any standalone image — already rebuilds the one shared array
|
||||
regardless of how the array is populated, before any change discussed
|
||||
below. That existing cost is O(live texture count) in CPU work to collect
|
||||
the view list and in however expensive the driver finds a
|
||||
descriptor-set-sized-for-N-descriptors to be.
|
||||
|
||||
## Prior art, checked rather than assumed
|
||||
|
||||
Two independent projects were checked to see whether "atlas for images"
|
||||
is actually how this is normally done, rather than a guess:
|
||||
|
||||
- **egui_wgpu** (`crates/egui-wgpu/src/renderer.rs` in emilk/egui), the
|
||||
closest prior art to iris — an immediate-mode wgpu-backed UI library that
|
||||
ships on Android. It keeps a `HashMap<TextureId, Texture>` and gives
|
||||
**each texture its own ordinary `BindGroup`** — one texture, one sampler,
|
||||
no array, no descriptor indexing of any kind. Draw calls are batched by
|
||||
texture id and the bind group is switched between batches within the
|
||||
render pass.
|
||||
- **Vello** — the renderer Masonry (E1/E2's Linebender stack) draws
|
||||
through — hit the identical problem and wrote down why in their own
|
||||
roadmap document
|
||||
([github.com/linebender/vello/blob/main/doc/roadmap_2023.md](https://github.com/linebender/vello/blob/main/doc/roadmap_2023.md)):
|
||||
*"The number of images that may appear in a scene is not bounded, which
|
||||
is not a good fit for the basic descriptor binding model... Until then,
|
||||
we'll do a workaround of having a single atlas image containing all the
|
||||
images in the scene."* Their reason is broader than Android — WebGPU 1.0
|
||||
has no descriptor indexing at all — but it reaches the same conclusion
|
||||
for the same shape of problem: atlas, not a bigger bindless array.
|
||||
|
||||
**This is also a live hazard, not a solved one.** Vello's own changelog
|
||||
(Sparse Strips v0.2.0) lists a fix titled *"WebGL image-atlas allocation
|
||||
and growth on Mali-G52 GPUs, avoiding application-not-responding errors"*
|
||||
— an actual ANR, from atlas growth, on an actual mid-range Android GPU,
|
||||
in the renderer Masonry is built on. The same release added
|
||||
`AtlasSpaceDiagnostics`/`AtlasLayerDiagnostics` (per-layer free-space,
|
||||
utilization, fragmentation) because growth needed instrumenting in
|
||||
production, not because it turned out to be free.
|
||||
|
||||
## Recommendation (not yet implemented)
|
||||
|
||||
1. **Small, plentiful textures** — glyphs (already done, I1), thumbnails,
|
||||
downscaled attachment previews, icons — go through a shared atlas, the
|
||||
same technique as `core/src/render/atlas.rs` generalized beyond glyphs.
|
||||
Adding one to an existing page is a `Patch`, already free per the
|
||||
section above.
|
||||
2. **Large or one-off images** — a photo attachment opened at full
|
||||
resolution, anything that would fragment a shared page — get their
|
||||
**own ordinary, non-array bind group**, the egui_wgpu way. Creating one
|
||||
is O(1): it references only itself, and does not touch any other
|
||||
texture's binding, unlike today's shared array where every push
|
||||
rebuilds a structure listing everything.
|
||||
3. **Opening a new atlas page** is the one case that still resembles
|
||||
today's rebuild — infrequent (bounded by how many *pages* are needed,
|
||||
not by how many images have ever been attached) but not free, and
|
||||
Vello's Mali-G52 fix says this specifically deserves care: it should
|
||||
never be allowed to block a frame, and it is worth having the
|
||||
equivalent of Vello's atlas diagnostics before trusting it under load.
|
||||
4. **Net effect**: dropping `TEXTURE_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`, and
|
||||
`PARTIALLY_BOUND_BINDING_ARRAY` from iris's device request entirely.
|
||||
Every path above is plain Vulkan 1.0 / GLES-level texture sampling.
|
||||
This is also what fixes the emulator failure measured above, regardless
|
||||
of the unresolved wgpu-hal question: a device that never asks for the
|
||||
feature cannot be refused for lacking it.
|
||||
|
||||
## What this touches, and what is still open
|
||||
|
||||
Implementing this reworks iris's rendering core: the shader's binding
|
||||
group layout (`shader.wgsl`), `Textures` and `GpuTextures`
|
||||
(`core/src/primitive/texture.rs`, `core/src/render/texture.rs`), both
|
||||
texture-sampling primitives, and `core/src/ui/painter.rs`'s draw-call
|
||||
batching (today one draw call can reference any texture by index; the
|
||||
per-texture-bind-group path needs draws grouped by which bind group they
|
||||
use). Nothing has been started.
|
||||
|
||||
Open questions a reviewer should weigh in on:
|
||||
|
||||
- **The size threshold** between "goes in an atlas page" and "gets its own
|
||||
bind group." Too low and ordinary attachment thumbnails end up as
|
||||
one-off bind groups, losing the batching benefit the atlas exists for;
|
||||
too high and a page fragments on a handful of medium images.
|
||||
- **Eviction policy** for atlas pages once the working set does not fit —
|
||||
today's `GlyphAtlas` never evicts, because a font's glyph set is small
|
||||
and bounded; images are not. An LRU at the page level, or at the
|
||||
individual-image level within a page, has not been designed.
|
||||
- **Whether iris should keep any binding array at all**, even a small
|
||||
fixed one (say, capped at a few dozen slots) for atlas pages themselves,
|
||||
or whether every atlas page should also be its own ordinary bind group
|
||||
like standalone images — the array's only remaining justification would
|
||||
be avoiding a bind-group-per-draw-call switch cost that has not been
|
||||
measured on this project's actual target hardware.
|
||||
- **How this interacts with I2/E2's virtualised list** (I3): a
|
||||
bottom-anchored transcript composes only visible rows, so the live
|
||||
texture set should already be bounded by what is on screen rather than
|
||||
by the whole conversation — worth confirming that invariant holds before
|
||||
relying on it to keep atlas/bind-group churn small.
|
||||
|
||||
## Review, 2026-09-04
|
||||
|
||||
A second pass over the file above against the code, done before anything
|
||||
is implemented. Iris's worry going in: a bind group per texture means a
|
||||
draw call per image, and she wants this as efficient as it can be.
|
||||
|
||||
### What checked out
|
||||
|
||||
Every code reference above is accurate as of this commit: the 100,000 /
|
||||
1,000 limits, the one-time pipeline, the `rsc_group` rebuild on every
|
||||
`Push`/`Set`/`Free`, and the `Patch` exclusion. The device request that
|
||||
asks for the three features is `iris/src/default/render.rs:96`, which the
|
||||
text above does not name. egui-wgpu and Vello are described correctly.
|
||||
|
||||
### The emulator refusal is a wgpu-hal gap, now located
|
||||
|
||||
The file guessed "a likely instance-version negotiation gap." It is
|
||||
narrower than that and it is in wgpu-hal, not the emulator. wgpu-hal
|
||||
28.0.0 (`src/vulkan/adapter.rs:1618`) only queries
|
||||
`PhysicalDeviceDescriptorIndexingFeaturesEXT` **when the device advertises
|
||||
the `VK_EXT_descriptor_indexing` extension string**. A Vulkan 1.2+ driver
|
||||
that has descriptor indexing as core need not list the extension, and
|
||||
lavapipe at 1.3 evidently does not, so wgpu never asks and reports the
|
||||
features absent, which is why `ash` sees seven `true`s and wgpu sees none.
|
||||
The properties query beside it (line 1486) correctly accepts
|
||||
`device_api_version >= 1.2 || extension`; the features query does not.
|
||||
wgpu-hal 30.0.1 in the local registry has the same asymmetry (lines
|
||||
1872 and 2036). Worth an upstream issue, but not a reason to keep the
|
||||
design: on real phones the gate that matters is stricter still.
|
||||
|
||||
**wgpu's `TEXTURE_BINDING_ARRAY` needs six sub-features, not one**
|
||||
(`adapter.rs:160-177`): non-uniform indexing *and* update-after-bind for
|
||||
sampled images, storage images and storage buffers, all together, because
|
||||
wgpu marks every array-bearing descriptor set update-after-bind. So Arm's
|
||||
"the extension is supported on Valhall" is necessary but not sufficient;
|
||||
a driver with sampled-image indexing and without storage-buffer
|
||||
update-after-bind is refused too. That widens the excluded set beyond
|
||||
what the Arm quote suggests and strengthens the conclusion.
|
||||
|
||||
### A live bug in the current code, found on the way
|
||||
|
||||
`GpuTextures::update` (`core/src/render/texture.rs:33`) implements
|
||||
"a patch must not report changed" as `changed = false`, unconditionally,
|
||||
which also **cancels a `Push` earlier in the same batch**. That ordering is
|
||||
exactly what opening a new atlas page produces: `GlyphAtlas::allocate`
|
||||
pushes the page and `insert` patches it in the same frame, so the bind
|
||||
group is not rebuilt and the new page's view is not bound until some
|
||||
unrelated texture change happens to rebuild it. It is hidden today only
|
||||
because the masks path also sets `changed`. The fix is one line
|
||||
(`changed |= !matches!(update, Patch)` in spirit); it should go in with
|
||||
the redesign since that code is being replaced, and it is recorded here
|
||||
so it is not rediscovered.
|
||||
|
||||
### In-layer draw order is already undefined
|
||||
|
||||
Relevant to any batching redesign: `Primitives::apply_free`
|
||||
(`core/src/render/primitive.rs:147`) uses `swap_remove`, so the instance
|
||||
order within a layer is permuted whenever anything is freed. Overlap order
|
||||
inside one layer is therefore not something the renderer promises today;
|
||||
ordering is done with layers. That means grouping a layer's draws by
|
||||
texture, or drawing a layer's images after its rects and glyphs, loses
|
||||
nothing that currently exists. It should be written down as an invariant
|
||||
when the redesign lands, because the new code will depend on it.
|
||||
|
||||
### On "a draw call per image"
|
||||
|
||||
Two corrections to the worry. First, it is a draw per *distinct texture per
|
||||
layer*, not per image primitive: every glyph quad in a layer shares the
|
||||
atlas and stays one instanced draw, and a thumbnail atlas would do the same
|
||||
for previews. Second, the count is bounded by what is on screen, which I3's
|
||||
virtualised transcript already bounds, and a mobile GPU is not draw-call
|
||||
bound at tens of draws per frame; egui ships exactly this on Android. What
|
||||
does cost is per-frame *bind group creation* and per-frame *sorting*, and
|
||||
the current code already creates a `primitive_group` bind group every time
|
||||
a layer updates (`render/mod.rs:103`), so one more per new image is not a
|
||||
regression in kind.
|
||||
|
||||
### Recommended shape (proposal, for Iris to accept or change)
|
||||
|
||||
Aimed at the fewest moving parts that need no feature beyond Vulkan 1.0:
|
||||
|
||||
1. **Atlas pages become layers of one `texture_2d_array`**, not separate
|
||||
textures. Every page is already `PAGE`x`PAGE` RGBA8, which is the one
|
||||
constraint an array texture imposes. A layer index is an ordinary
|
||||
sampling operand in WGSL and needs no indexing feature, so `GLYPH`
|
||||
(and any future atlased-image primitive) carries a layer instead of a
|
||||
`view_idx` and all of a layer's text stays **one draw**. This answers
|
||||
the open question above about keeping a small binding array: no. Cost
|
||||
of opening a page: recreate the array with one more layer and
|
||||
`copy_texture_to_texture` the old ones, GPU-side, no readback; grow
|
||||
with headroom (double) so it is rare. wgpu's default
|
||||
`max_texture_array_layers` is 256, at 4 MB each, so the cap is memory
|
||||
rather than the API.
|
||||
2. **Every standalone image is its own texture with its own bind group**,
|
||||
and its instances live in a **separate per-layer instance list**, not
|
||||
the main one. Then the main instance buffer never contains an image,
|
||||
there is nothing to sort, no handle remapping beyond what
|
||||
`apply_free` already does, and each image is `draw(0..4, k..k+1)` with
|
||||
its bind group set first. Group 2's layout becomes `{atlas array,
|
||||
one image texture, sampler, masks}`; the main draw binds a 1x1 null
|
||||
image in the image slot, each image draw binds its own. One pipeline,
|
||||
one shader, one layout.
|
||||
3. **No thumbnail atlas in the first version.** With images on their own
|
||||
textures, the threshold and eviction questions above disappear: an
|
||||
image is freed when the row that owns its `TextureHandle` scrolls out.
|
||||
Add an image atlas only if a measured screen shows enough small images
|
||||
to matter, which a transcript rarely does.
|
||||
4. **Drop the three features and the two `max_binding_array_*` limits from
|
||||
`src/default/render.rs`**, and the `UiLimits` counts with them.
|
||||
5. **Sampling is `NonFiltering` today** (`render/mod.rs:290,299`), so a
|
||||
downscaled attachment will alias. Either request a filtering sampler
|
||||
for the image slot or downscale on the CPU before upload; decide when
|
||||
the image widget is touched, not as part of this.
|
||||
|
||||
What this costs against the file's original recommendation: `Textures`
|
||||
needs to know an image from a page (two kinds of handle, or a kind on
|
||||
`TextureHandle`), and `Primitives` gets a second instance list per layer.
|
||||
What it saves: the sort, the size threshold, the eviction policy, and any
|
||||
per-page bind group switch.
|
||||
|
||||
## Implemented, 2026-09-04
|
||||
|
||||
The shape above, built as proposed with one structural addition the proposal
|
||||
didn't need to spell out and one bug it predicted made moot rather than
|
||||
literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`,
|
||||
`TextureHandle`), `core/src/render/texture.rs` (`GpuTextures`),
|
||||
`core/src/render/primitive.rs` (`Primitives`, `GlyphPrimitive`),
|
||||
`core/src/render/atlas.rs`, `core/src/ui/painter.rs`,
|
||||
`core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed),
|
||||
`core/src/render/shader.wgsl`, `src/default/render.rs`, and
|
||||
`rigs/gpu-probe/src/main.rs`.
|
||||
|
||||
**1. Atlas pages as array layers.** `GpuTextures` owns one
|
||||
`texture_2d_array` (`array_texture`/`array_view`), grown by doubling
|
||||
(`grow_array`): a new texture is created at twice the layer capacity, the
|
||||
old layers are copied across with `copy_texture_to_texture` (GPU-side, no
|
||||
readback), and every bind group that referenced the old view — the main
|
||||
one and every live standalone image's — is rebuilt, since the view's
|
||||
identity changed. `GlyphPrimitive` carries `layer: u32` instead of
|
||||
`view_idx`/`sampler_idx`; the layer number is assigned synchronously in
|
||||
`Textures::add_page` (a plain counter, `next_page_layer`), not by the
|
||||
renderer, because `GlyphAtlas::insert` needs it in the same call, before
|
||||
any GPU sync happens — the renderer only finds out later, when it
|
||||
processes the queued `Push`.
|
||||
|
||||
**2. Standalone images, one bind group each.** `TextureKind` on
|
||||
`TextureHandle`/`Textures` distinguishes `Image` (a plain bind-group index,
|
||||
`slot`) from `Page { layer }`. `Primitives` gained a second per-layer list
|
||||
— `images: Vec<PrimitiveInstance>`, tagged `IMAGE_BINDING` — separate from
|
||||
`instances` (rects and glyphs), written by `Painter::write_image` rather
|
||||
than through the generic `Primitive` trait, since an image has nowhere in
|
||||
`PrimitiveData` to put a per-instance entry once the bind group already
|
||||
picks the texture. `UiRenderNode::draw` draws a layer's `instance` buffer
|
||||
once as before, then walks `image_instance` one entry at a time, binding
|
||||
that texture's `BindGroup` (`GpuTextures::image_bind_group`) and issuing
|
||||
`draw(0..4, k..k+1)` per image. Group 2's layout is exactly the proposed
|
||||
`{atlas array, one image texture, sampler, masks}`; the main draw binds a
|
||||
1x1 null view in the image slot.
|
||||
|
||||
**The one addition beyond the proposal**: the masks storage buffer lives
|
||||
in every per-image bind group (group 2, binding 3), and `ArrBuf<Mask>`
|
||||
recreates its buffer whenever the mask count changes size
|
||||
(`render/util/mod.rs`'s `ArrBuf::update` now returns whether it resized).
|
||||
A resize invalidates every bind group holding the old buffer, not just the
|
||||
main one, so `GpuTextures::update` takes a `masks_resized: bool` and calls
|
||||
`rebuild_image_bind_groups` when it's set, alongside the same rebuild the
|
||||
array-growth path already needed. This wasn't a design question the
|
||||
proposal had to answer (it treated bind-group construction as a given),
|
||||
but it's exactly the shape of trap layer growth already had, so it uses
|
||||
the same fix.
|
||||
|
||||
**3. No thumbnail atlas.** Not built, as proposed.
|
||||
|
||||
**4. Removed**: `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY`,
|
||||
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` from
|
||||
`src/default/render.rs`'s `request_device`, and `UiLimits` (the type
|
||||
itself, not just its binding-array methods — once its two fields were
|
||||
gone there was nothing left in it, and `UiRenderNode::new` no longer takes
|
||||
a limits parameter). `binding_array` no longer appears anywhere in
|
||||
`shader.wgsl`.
|
||||
|
||||
**5. Sampling** is still `NonFiltering`, unchanged, per the proposal's own
|
||||
note that this is a separate decision for whenever the image widget itself
|
||||
is touched.
|
||||
|
||||
**The `changed = false` bug is structurally gone, not patched.** The old
|
||||
`GpuTextures::update` held one `changed: bool` that a `Patch` reset
|
||||
unconditionally, which could erase an earlier `Push` in the same batch (a
|
||||
new atlas page's `Push` immediately followed by `GlyphAtlas::insert`'s
|
||||
`Patch`, both queued before the renderer ever runs). The new `update`
|
||||
computes the rebuild signal by OR-ing each event's own answer
|
||||
(`rebuild_main |= self.push(...)`), and `Patch`'s arm simply never
|
||||
contributes to it — there is no shared mutable flag left for a `Patch` to
|
||||
stomp on. Documented at the call site
|
||||
(`core/src/render/texture.rs`, `GpuTextures::update`'s doc comment and the
|
||||
`Patch` match arm's comment) rather than fixed as a one-line diff, since
|
||||
the mechanism that could go wrong no longer exists.
|
||||
|
||||
**In-layer draw order is an explicit invariant now, not just a fact about
|
||||
`swap_remove`.** `UiRenderNode::draw` draws every layer's images after its
|
||||
rects and glyphs, and `Primitives::apply_free`'s doc comment states
|
||||
directly that both of a layer's lists (`instances` and `images`) free with
|
||||
`swap_remove` and that nothing may assume adjacency survives a free —
|
||||
recorded there because `apply_free` is the one place a change to either
|
||||
list's ordering would have to be reconciled.
|
||||
|
||||
**Verified:**
|
||||
|
||||
- `cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
|
||||
`cargo clippy --all-targets`, `cargo test --workspace` all clean in
|
||||
`iris/`, on the pinned `nightly-2026-09-03` toolchain. 14 tests pass
|
||||
(unchanged from I1; nothing here is pure-logic enough to add a unit
|
||||
test to — it's all GPU resource wiring).
|
||||
- `iris/run-headless.sh minimal --shot /tmp/minimal.png` and
|
||||
`iris/run-headless.sh tabs --shot /tmp/tabs.png`: both render correctly
|
||||
on this VM's GPU (Venus) — `tabs`'s glyph-atlas text renders in every
|
||||
panel, confirming `GlyphPrimitive.layer` addresses the array correctly.
|
||||
- The standalone-image path specifically: a throwaway example (not
|
||||
committed) with an `image(...)` widget as part of the root, run the same
|
||||
way, rendered the image next to glyph-atlas text in one frame —
|
||||
confirming a live `BindGroup` built by `GpuTextures::create_image` and
|
||||
bound per-`draw()` call actually samples the right texture. `tabs`'s own
|
||||
"image span" tab exercises the same widget but needs a click to reach,
|
||||
which the headless compositor can't deliver (no seat devices, per I1's
|
||||
own note on this file) — the throwaway example is what stood in for it.
|
||||
- **Exercised, 2026-09-04: `grow_array` under real load, on `tabs`.**
|
||||
Rather than building a purpose-made glyph flood, `PAGE`
|
||||
(`core/src/render/atlas.rs`) was temporarily dropped from 1024 to 64 —
|
||||
small enough that `tabs`'s ordinary mix of sizes and families (nothing
|
||||
exotic: a handful of `Text` widgets at a few sizes, one at
|
||||
`Family::Monospace`) already exceeds one page's worth of distinct
|
||||
glyphs. A one-line `eprintln!` in `grow_array` confirmed two real grows
|
||||
in a single run (`GROW_ARRAY: 1 -> 2` then `GROW_ARRAY: 2 -> 4`, i.e.
|
||||
glyphs landed on at least a third layer), and
|
||||
`iris/run-headless.sh tabs --shot` showed every tab's text rendering
|
||||
correctly with no corruption or missing glyphs — confirming the
|
||||
`copy_texture_to_texture` grow-and-relocate path and cross-layer
|
||||
sampling (`GlyphPrimitive.layer` addressing a layer beyond the first)
|
||||
both work. Command:
|
||||
`sed -i 's/PAGE: u32 = 1024/PAGE: u32 = 64/' core/src/render/atlas.rs`,
|
||||
rebuild, `./run-headless.sh tabs --shot /tmp/x.png`, then
|
||||
`git checkout -- core/src/render/atlas.rs` to revert — this is a
|
||||
throwaway diagnostic value, never a committed change, since a real
|
||||
1024px page holding only a handful of glyphs at a time would be mostly
|
||||
wasted space in normal use. Confirmed the revert left `tabs` and
|
||||
`minimal` byte-identical to the pre-check screenshots afterward.
|
||||
- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new
|
||||
(empty) feature/limit set and run on this checkout's own emulator
|
||||
(`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets
|
||||
a real Vulkan device (SwiftShader) rather than the `-gpu host` default,
|
||||
which disables Vulkan in this VM entirely (`-feature -Vulkan`, because
|
||||
gfxstream can't pair Venus with the real GPU here — worth remembering,
|
||||
since the *default* `emu up` gives a device with **no** Vulkan adapter
|
||||
at all, which reads exactly like the old bindless failure if you don't
|
||||
know to ask for `EMU_GPU=software`):
|
||||
|
||||
cd rigs/gpu-probe
|
||||
ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \
|
||||
cargo ndk -t arm64-v8a -P 26 build --release
|
||||
EMU_GPU=software emu up # from ~/repos/emulator-tools
|
||||
adb push target/aarch64-linux-android/release/gpu-probe /data/local/tmp/
|
||||
adb shell chmod 755 /data/local/tmp/gpu-probe
|
||||
adb shell /data/local/tmp/gpu-probe
|
||||
|
||||
Output: `adapters: 1 — Vulkan SwiftShader Device (Subzero) (Cpu)`,
|
||||
`features iris requires:` (none listed — the set is empty),
|
||||
`max_buffer_size … ok`, and **`IRIS DEVICE: ok`**. This is the fix
|
||||
measured working, on the exact rig that first measured it failing.
|
||||
Emulator stopped afterward (`emu down`); nothing was left running.
|
||||
@@ -33,3 +33,14 @@ one in place when it turns out to need a decision.
|
||||
that would work today, for Claude sessions, and it is the option that was
|
||||
not chosen.
|
||||
|
||||
|
||||
## From Iris's phone log export, 2026-09-07 (Compose app)
|
||||
|
||||
- [ ] **Crash on 2026-09-03 11:40, `IllegalArgumentException: Reversed
|
||||
range is not supported`** at `ToolInput.kt:200` (`highlighted`, inside
|
||||
`ToolInputView` -> `RawBlock` -> `ToolCard`). An `AnnotatedString`
|
||||
range was built with end before start while highlighting a tool
|
||||
input. Found in the per-package system log she exported; the tool
|
||||
input that triggered it is not in the log. Reproduce by fuzzing
|
||||
`highlighted` with inputs whose token boundaries collapse, and guard
|
||||
the range construction.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Compose bench report from Iris's phone, 2026-09-06
|
||||
|
||||
The Compose half of P0 (RUST.md), run by Iris on her own phone and pasted
|
||||
back verbatim. The iris half's report goes beside it in this directory
|
||||
when it exists. Her caveat, worth keeping with the numbers: "I don't think
|
||||
this is entirely fair because the UI for iris is more minimal" -- the
|
||||
Compose screen also draws the usage bar, the status row and tool cards,
|
||||
which the iris bench screen does not yet. Her impression of the iris build
|
||||
before its first-touch bug: "it already feels very smooth so far".
|
||||
|
||||
What to read first: the phone runs at 120 Hz, so the budget is 8.3 ms;
|
||||
`late` is measured against that. Compose's tail is the streaming phase --
|
||||
`markdown reparsed while streaming: 396, 8.5ms mean, 25.8ms worst` and
|
||||
`record: one block: 398, 6.3ms mean, 19.9ms worst` -- which is exactly the
|
||||
path iris's `TranscriptScreen::apply` (replace the last row only) is meant
|
||||
to beat. Process CPU over the run is 20.9 s of a 38.5 s run; peak RSS
|
||||
587 MB; battery current mean 419 mA.
|
||||
|
||||
```
|
||||
ai-app render report
|
||||
device: Pixel 9 Pro XL (Google), Android 17
|
||||
build: release
|
||||
|
||||
transcript:
|
||||
43 events, 41 rows, 93 units loaded
|
||||
viewport 1333px, 2 units visible
|
||||
on screen: the list's own 0px, AssistantMsg 24520px
|
||||
0 tool calls and 0 groups open
|
||||
|
||||
frames:
|
||||
1613 frames over 38.5s at 120Hz (8.3ms budget)
|
||||
late: 742 (46.0%)
|
||||
total p50 7.7ms p90 29.2ms p99 41.1ms
|
||||
waited p50 0.5ms p90 12.9ms p99 27.2ms
|
||||
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||
anim p50 1.1ms p90 5.8ms p99 9.5ms
|
||||
layout p50 0.0ms p90 0.1ms p99 0.2ms
|
||||
draw p50 0.4ms p90 15.9ms p99 27.9ms
|
||||
sync p50 0.1ms p90 0.5ms p99 1.0ms
|
||||
issue p50 1.1ms p90 1.7ms p99 3.0ms
|
||||
swap p50 0.4ms p90 0.5ms p99 0.7ms
|
||||
gpu p50 1.8ms p90 2.1ms p99 6.6ms
|
||||
|
||||
where the draw phase went:
|
||||
draw phase 3.83ms per frame, of which:
|
||||
the transcript: 0.25ms (measure 0.15, place 0.10, record 0.00)
|
||||
everything else: 3.58ms (93%)
|
||||
|
||||
work since this was last copied:
|
||||
draw: the whole transcript: 12, 0.2ms total, 0.0ms mean, 0.0ms worst
|
||||
grouped tool runs: 398, 10.3ms total, 0.0ms mean, 0.1ms worst
|
||||
markdown cut into pieces: 1, 0.0ms total, 0.0ms mean, 0.0ms worst
|
||||
markdown parsed while composing: 7, 3.0ms total, 0.4ms mean, 0.5ms worst
|
||||
markdown ready: 46
|
||||
markdown reparsed while streaming: 396, 3384.6ms total, 8.5ms mean, 25.8ms worst
|
||||
markdown warmed: 1, 1.4ms total, 1.4ms mean, 1.4ms worst
|
||||
measure: the whole transcript: 957, 248.7ms total, 0.3ms mean, 15.7ms worst
|
||||
message composed: 403
|
||||
message cut into parts: 1, 0.1ms total, 0.1ms mean, 0.1ms worst
|
||||
place: the whole transcript: 1319, 162.4ms total, 0.1ms mean, 1.3ms worst
|
||||
record: one block: 398, 2498.7ms total, 6.3ms mean, 19.9ms worst
|
||||
session screen recomposed: 413
|
||||
status row recomposed: 1
|
||||
unit composed: 538
|
||||
units flattened: 399, 44.3ms total, 0.1ms mean, 0.5ms worst
|
||||
usage bar recomposed: 413
|
||||
|
||||
bench:
|
||||
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||
process CPU time over this run: 20907ms
|
||||
peak RSS: 587356kB
|
||||
battery current: mean -418509µA over 39 samples (min -1988281, max -107812)
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Compose bench v2 report from Iris's phone, 2026-09-06
|
||||
|
||||
Bench v2 (fling / stream / type / keyboard, RUST.md's P0 box) on the
|
||||
Compose `bench` build, run by Iris on her Pixel 9 Pro XL, verbatim. Note
|
||||
the display was at **60 Hz** for this run (16.7 ms budget) where the v1
|
||||
run was at 120 Hz -- the phone's adaptive refresh rate decides, and
|
||||
`late` is judged against whichever it was, so compare a run with a run at
|
||||
the same rate. The iris v2 report goes beside this when it exists.
|
||||
|
||||
What it says: fling, type and keyboard are all essentially clean on
|
||||
Compose (0.1%, 0.9% and 0% late; fling p50 5.5 ms, p99 11.6 ms). The
|
||||
whole tail is the streaming phase again -- 41.9% late, p99 42.5 ms,
|
||||
driven by `markdown reparsed while streaming` (8.6 ms mean, 30.3 ms
|
||||
worst) and `record: one block` (6.3 ms mean, 25.5 ms worst). Process CPU
|
||||
69.6 s over the 125.5 s run; peak RSS 577 MB; battery current mean
|
||||
571 mA over 126 samples.
|
||||
|
||||
```
|
||||
ai-app render report
|
||||
device: Pixel 9 Pro XL (Google), Android 17
|
||||
build: release
|
||||
|
||||
transcript:
|
||||
108 events, 26 rows, 58 units loaded
|
||||
viewport 1531px, 2 units visible
|
||||
on screen: the list's own 0px, AssistantMsg 24520px
|
||||
0 tool calls and 0 groups open
|
||||
|
||||
per phase:
|
||||
fling: 3278 frames over 32.7s
|
||||
late: 4 (0.1%)
|
||||
total p50 5.5ms p90 8.7ms p99 11.6ms
|
||||
worst 49.0ms
|
||||
stream: 1041 frames over 21.3s
|
||||
late: 436 (41.9%)
|
||||
total p50 13.4ms p90 31.7ms p99 42.5ms
|
||||
worst 52.5ms
|
||||
type: 2446 frames over 61.5s
|
||||
late: 23 (0.9%)
|
||||
total p50 7.3ms p90 13.2ms p99 16.5ms
|
||||
worst 38.9ms
|
||||
keyboard: 358 frames over 10.0s
|
||||
late: 0 (0.0%)
|
||||
total p50 6.3ms p90 8.6ms p99 11.1ms
|
||||
worst 12.0ms
|
||||
|
||||
frames:
|
||||
7122 frames over 125.5s at 60Hz (16.7ms budget)
|
||||
late: 463 (6.5%)
|
||||
total p50 6.0ms p90 13.8ms p99 34.0ms
|
||||
waited p50 0.5ms p90 1.1ms p99 19.9ms
|
||||
input p50 0.0ms p90 0.0ms p99 0.0ms
|
||||
anim p50 0.7ms p90 4.5ms p99 7.6ms
|
||||
layout p50 0.1ms p90 0.1ms p99 0.2ms
|
||||
draw p50 0.7ms p90 2.9ms p99 21.9ms
|
||||
sync p50 0.1ms p90 0.2ms p99 0.6ms
|
||||
issue p50 1.4ms p90 2.4ms p99 3.2ms
|
||||
swap p50 0.4ms p90 0.8ms p99 1.2ms
|
||||
gpu p50 1.5ms p90 2.1ms p99 6.6ms
|
||||
|
||||
where the draw phase went:
|
||||
draw phase 1.74ms per frame, of which:
|
||||
the transcript: 0.24ms (measure 0.10, place 0.14, record 0.00)
|
||||
everything else: 1.51ms (86%)
|
||||
|
||||
work since this was last copied:
|
||||
draw: the whole transcript: 280, 1.9ms total, 0.0ms mean, 0.0ms worst
|
||||
grouped tool runs: 407, 18.6ms total, 0.0ms mean, 0.2ms worst
|
||||
markdown cut into pieces: 40, 0.4ms total, 0.0ms mean, 0.0ms worst
|
||||
markdown parsed while composing: 2, 1.6ms total, 0.8ms mean, 1.1ms worst
|
||||
markdown ready: 323
|
||||
markdown reparsed while streaming: 395, 3406.9ms total, 8.6ms mean, 30.3ms worst
|
||||
markdown warmed: 40, 38.2ms total, 1.0ms mean, 4.3ms worst
|
||||
measure: the whole transcript: 1978, 683.9ms total, 0.3ms mean, 15.9ms worst
|
||||
message composed: 397
|
||||
message cut into parts: 40, 2.9ms total, 0.1ms mean, 0.2ms worst
|
||||
place: the whole transcript: 4308, 996.0ms total, 0.2ms mean, 2.4ms worst
|
||||
record: one block: 394, 2472.7ms total, 6.3ms mean, 25.5ms worst
|
||||
session screen recomposed: 1630
|
||||
status row recomposed: 1
|
||||
transcript page from server: 10
|
||||
unit composed: 927
|
||||
units flattened: 408, 85.5ms total, 0.2ms mean, 1.8ms worst
|
||||
|
||||
bench:
|
||||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=188/off=182px end=idx=0/off=0px
|
||||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||
type: 600 characters inserted then deleted, one per 50ms
|
||||
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
|
||||
process CPU time over this run: 69564ms
|
||||
peak RSS: 577452kB
|
||||
battery current: mean -571483µA over 126 samples (min -2361718, max -99218)
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# iris bench report from Iris's phone, 2026-09-06, before the phone fixes
|
||||
|
||||
Build 46246ea (Vulkan, bench v1: 24-swipe scroll loop then 400 streamed
|
||||
events), run by Iris on her Pixel 9 Pro XL before the first-touch wipe,
|
||||
the missing bold faces, the density scale and the status-bar inset were
|
||||
fixed -- so the rows were drawn at roughly a third of their intended size
|
||||
and the run may have included frames after the wipe. Preliminary, kept
|
||||
because it is the first iris number from real hardware. Compare with
|
||||
`compose-phone-2026-09-06.md`, taken on the same phone with the same
|
||||
fixture and gesture loop.
|
||||
|
||||
Reading it: the phone is 120 Hz (8.3 ms budget). `janky%` here counts
|
||||
frames over 16.7 ms, so it is not Compose's `late` (over 8.3 ms). Like for
|
||||
like: iris p50 6.2 ms vs Compose 7.7 ms; p90 32.0 vs 29.2; p99 42.1 vs
|
||||
41.1. `cpu_p50=4.7ms` is iris's own per-frame CPU work on the phone,
|
||||
against 0.2-0.4 ms on the emulator's x86 cores. Process CPU 15.6 s vs
|
||||
20.9 s, but over a shorter run (692 frames vs 1613 -- iris only renders on
|
||||
change and had no fling settle time), so per-second CPU is not directly
|
||||
comparable; peak RSS 365 MB vs 587 MB. Battery current mean 563 mA vs
|
||||
419 mA is the one figure that reads worse, and it is the least
|
||||
comparable: 22 samples vs 39, over runs of different length and different
|
||||
idle share. Bench v2's per-phase accounting is what makes these comparable.
|
||||
|
||||
```
|
||||
iris bench report
|
||||
frames=692 janky%=32.37 p50=6.2ms p90=32.0ms p99=42.1ms worst=52.6ms (measures redraw-start to after present() is called, not GPU/compositor completion) cpu_p50=4.7ms gpu_wait_p50=1.3ms (redraw-start-to-submit vs. submit-to-after-present)
|
||||
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
|
||||
process CPU time over this run: 15554ms
|
||||
peak RSS: 365328kB
|
||||
battery current: mean -563493µA over 22 samples (min -1807812, max -132812)
|
||||
```
|
||||
@@ -0,0 +1,66 @@
|
||||
# iris bench v2 report from Iris's phone, 2026-09-06
|
||||
|
||||
Build 2e3f4ad (bench v2, fling physics, keyboard-wipe fix, dp unit), run
|
||||
by Iris on her Pixel 9 Pro XL, verbatim. The display was at **120 Hz**
|
||||
(8.3 ms budget) where `compose-phone-v2-2026-09-06.md` ran at 60 Hz, so
|
||||
compare the millisecond percentiles, not `late`.
|
||||
|
||||
Side by side (Compose 60 Hz / iris 120 Hz, p50 / p90 / p99 ms): fling
|
||||
5.5/8.7/11.6 vs 3.8/6.9/12.6; stream 13.4/31.7/42.5 vs 18.2/35.8/43.1;
|
||||
type 7.3/13.2/16.5 vs 7.2/9.2/11.2; keyboard: iris could not show the IME
|
||||
(phase invalid). Process CPU 69.6 s over 125 s vs 40.6 s over 150 s; peak
|
||||
RSS 577 MB vs 379 MB; battery current mean 571 mA vs 452 mA.
|
||||
|
||||
Iris's observations on the same run: "the scrolling is not similar at
|
||||
all. It does not fling for me yet [with a finger], and the test also seems
|
||||
to give it a constant velocity and abruptly stop it at some point. Also
|
||||
unsure what's going on in that image with the compaction" -- her
|
||||
screenshot shows the `Compacted: 180000 -> 20000 tokens.` row drawn twice
|
||||
overlapping, and once more below the composer bar: primitives of a
|
||||
replaced/removed row surviving in the GPU buffers, the same shape as the
|
||||
header drawn twice after a keyboard resize.
|
||||
|
||||
**Root-caused and fixed 2026-09-06** (commit `76b1f99`): the diagnosis in
|
||||
that sentence was right and the location was not -- `UiRenderState::
|
||||
draw_inner` read the `needs_redraw` mark without consuming it and skipped
|
||||
the branch that frees a redrawn widget's old primitives. docs/RUST.md's
|
||||
"Stale primitives, the phone's half" box has the full account, the guard
|
||||
(`orphaned_primitives`, `debug_assert`ed every frame) and the emulator run
|
||||
that exercises it.
|
||||
|
||||
```
|
||||
iris bench report
|
||||
per phase:
|
||||
fling: 1783 frames over 53.2s
|
||||
late: 104 (5.8%)
|
||||
total p50 3.8ms p90 6.9ms p99 12.6ms
|
||||
worst 29.1ms
|
||||
stream: 401 frames over 21.3s
|
||||
late: 306 (76.3%)
|
||||
total p50 18.2ms p90 35.8ms p99 43.1ms
|
||||
worst 43.8ms
|
||||
type: 1202 frames over 65.7s
|
||||
late: 309 (25.7%)
|
||||
total p50 7.2ms p90 9.2ms p99 11.2ms
|
||||
worst 15.3ms
|
||||
keyboard: 9 frames over 9.7s
|
||||
late: 9 (100.0%)
|
||||
total p50 12.0ms p90 12.9ms p99 12.9ms
|
||||
worst 12.9ms
|
||||
|
||||
frames:
|
||||
3395 frames over 149.9s at 120Hz (8.3ms budget)
|
||||
late: 728 (21.4%)
|
||||
total p50 5.0ms p90 10.9ms p99 36.6ms
|
||||
worst 43.8ms
|
||||
cpu_p50 2.0ms gpu_wait_p50 2.6ms
|
||||
|
||||
bench:
|
||||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1217px outward=idx=651/off=101536px end=idx=651/off=1022px
|
||||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||
type: 600 characters inserted then deleted, one per 50ms
|
||||
keyboard: could not be shown (5 attempts, 0 confirmed visible)
|
||||
process CPU time over this run: 40603ms
|
||||
peak RSS: 379156kB
|
||||
battery current: mean -452353µA over 149 samples (min -1753125, max -204687)
|
||||
```
|
||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,107 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "event-model"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "event-model"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# The common event model, extracted from `server/session/driver.rs` and
|
||||
# `session/transcript.rs` so a Rust client (`client-core`) can share one
|
||||
# definition with the server instead of hand-mirroring it the way
|
||||
# `app/.../Events.kt` used to. Nothing here talks to a process, a file, or a
|
||||
# socket -- it is exactly the wire shape in PLAN.md's "common event model",
|
||||
# plus the transcript envelope and the context-token rule three different
|
||||
# readers (the pump, the transcript, and a phone folding the same events)
|
||||
# have to agree on.
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# `Event::ToolStart.input` is a tool's raw call arguments, whatever shape the
|
||||
# dialect gave them -- typing it further would mean this crate knowing every
|
||||
# driver's tool schema.
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
@@ -0,0 +1,409 @@
|
||||
//! The common event model: what a driver's process turns into before it
|
||||
//! touches the transcript or the phone (see `PLAN.md`'s "The common event
|
||||
//! model"). Extracted from `server/src/session/driver.rs` and
|
||||
//! `session/transcript.rs` on 2026-09-04 so `client-core` shares this
|
||||
//! definition instead of hand-mirroring it, which is what
|
||||
//! `app/.../Events.kt` used to do. `server/`'s `session::driver` module
|
||||
//! re-exports everything here, so nothing downstream of it had to change.
|
||||
//!
|
||||
//! What stayed behind in `server/`: the `Driver` trait, `SessionCommand`,
|
||||
//! `Unqueued` and `EventSink`. Those are how *this* server runs a session,
|
||||
//! not part of what a client reads off the wire.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The name a session's image is stored and served under -- minted for an
|
||||
/// upload or for one a tool produced, and fetched back from
|
||||
/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript
|
||||
/// renders them identically.
|
||||
pub type ImageRef = String;
|
||||
|
||||
/// The name an upload is stored and served under: an image is
|
||||
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
|
||||
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what
|
||||
/// the reader attached and what the session is told. Told apart by
|
||||
/// `crate::media::media_type_for`.
|
||||
pub type AttachmentRef = String;
|
||||
|
||||
/// One choice offered in answer to a [`Event::Question`]. More than a label
|
||||
/// because the reader is deciding rather than confirming: what an option
|
||||
/// means, and what picking it would produce, are what decide it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
/// A sentence about what this option means.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// A block to show as written -- a mockup, a diff, a config file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
impl QuestionOption {
|
||||
pub fn plain(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a session can tell the outside world. Every event is appended
|
||||
/// to the transcript with a sequence number, then fanned out to SSE
|
||||
/// subscribers, so reconnecting is just "events after seq N" -- no separate
|
||||
/// history path to drift from the live one.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
// `rename_all` renames the variants; `rename_all_fields` renames what is
|
||||
// inside them. Both are needed and only the first is obvious: every field
|
||||
// here was one lowercase word until `pre_tokens` arrived, so a multi-word
|
||||
// field went out as snake_case, the app looked for camelCase and found
|
||||
// nothing, and the event still rendered -- as the "no counts reported" case,
|
||||
// which is a state it is allowed to be in.
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum Event {
|
||||
/// What the user sent, written into the transcript by the manager (not by
|
||||
/// drivers) so every device renders the conversation from one stream.
|
||||
/// Recorded when the session reads it, which is what `MessageTaken` reports.
|
||||
UserMessage {
|
||||
/// The [`Event::MessageQueued`] this resolves, when it waited. The
|
||||
/// phone has a bubble on screen for the waiting message and needs to
|
||||
/// know *which* one this is, rather than matching on the text and
|
||||
/// clearing the wrong one when the same thing was sent twice.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
/// What was attached, by the ref the files route serves. On the
|
||||
/// message rather than beside it: these used to be their own `Image`
|
||||
/// events just before, which left the phone deciding from adjacency
|
||||
/// which message an image belonged to. `images` on disk until
|
||||
/// 2026-09-03, when files joined them; the alias reads the older rows.
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// A message accepted from the phone that the session cannot read yet.
|
||||
///
|
||||
/// Recorded, unlike the message itself, and that difference is the point:
|
||||
/// the message belongs in the transcript where the session read it, but
|
||||
/// something has to say it is waiting, and it has to be the server. The
|
||||
/// phone used to remember its own outgoing messages, so leaving the
|
||||
/// screen showed nothing pending when something was.
|
||||
///
|
||||
/// Carries no row of its own; resolved by the `UserMessage` bearing the
|
||||
/// same id, as `CommandQueued` is resolved by `CommandSent`.
|
||||
MessageQueued {
|
||||
id: String,
|
||||
text: String,
|
||||
/// Carried for the same reason [`Event::UserMessage`] carries it,
|
||||
/// and it matters more here: a waiting message is on screen for as
|
||||
/// long as the turn runs, so its attachment has nowhere else to be.
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// A message taken out of the queue before the session read it.
|
||||
///
|
||||
/// Recorded for the same reason `MessageQueued` is: the queue is the
|
||||
/// server's, so what is waiting has to be answerable from the transcript
|
||||
/// alone. Without it a phone that reconnects replays the `MessageQueued`
|
||||
/// and puts back a bubble nothing will ever resolve -- the `UserMessage`
|
||||
/// that normally does is exactly what is not coming.
|
||||
///
|
||||
/// Only ever sent for a message that had not been handed over; see
|
||||
/// [`Unqueued::AlreadySent`].
|
||||
MessageDropped {
|
||||
id: String,
|
||||
},
|
||||
/// A driver has taken one of the user's messages and started reading it.
|
||||
/// The manager turns this into the `UserMessage` above, so it never
|
||||
/// reaches a phone itself.
|
||||
///
|
||||
/// It exists because sending and being read are not the same moment. A
|
||||
/// message sent into a running turn waits, and recording it among things
|
||||
/// already read puts it in the transcript above output that predates it.
|
||||
MessageTaken {
|
||||
/// The `MessageQueued` this answers, or `None` when it never waited.
|
||||
/// Carried through onto the `UserMessage`.
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// Streaming assistant text; the phone renders the concatenation as
|
||||
/// markdown.
|
||||
AssistantText {
|
||||
delta: String,
|
||||
},
|
||||
ToolStart {
|
||||
id: String,
|
||||
tool: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
ToolUpdate {
|
||||
id: String,
|
||||
output: String,
|
||||
},
|
||||
ToolEnd {
|
||||
id: String,
|
||||
output: String,
|
||||
/// Whether the tool reported that the call *failed*, from the
|
||||
/// CLI's own `is_error` on the `tool_result`.
|
||||
///
|
||||
/// Added 2026-09-06 with the tool-call cards (RUST.md's P1b),
|
||||
/// because without it a result is the only thing a card has and a
|
||||
/// failed call is drawn as confidently as a successful one -- the
|
||||
/// missing state, not a wrong one. `#[serde(default)]` so a
|
||||
/// transcript written before this field, or a peer on an older
|
||||
/// build, reads back as "not reported to have failed" rather than
|
||||
/// failing to parse; that is the same claim the field's absence
|
||||
/// used to make implicitly.
|
||||
#[serde(default)]
|
||||
is_error: bool,
|
||||
},
|
||||
/// An image the session produced or was sent, saved under the session
|
||||
/// dir and referenced by id; the phone fetches it by URL.
|
||||
Image {
|
||||
#[serde(rename = "ref")]
|
||||
image: ImageRef,
|
||||
/// The tool call whose result carried it, when one did. A screenshot
|
||||
/// belongs under the call that took it, not floating beside it -- the
|
||||
/// reader has to pair them by position otherwise, and position is
|
||||
/// exactly what a page boundary breaks.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
about: Option<String>,
|
||||
},
|
||||
/// Anything the session needs a human for: AskUserQuestion, and
|
||||
/// permission requests, are the same shape with different options.
|
||||
Question {
|
||||
id: String,
|
||||
prompt: String,
|
||||
/// A few words naming what the question is about, when the asker
|
||||
/// offered one. `None` for a permission, which is about the call
|
||||
/// above it.
|
||||
header: Option<String>,
|
||||
options: Vec<QuestionOption>,
|
||||
/// Whether several options may be chosen at once. Here rather than
|
||||
/// left for a phone to work out from the dialect underneath: how many
|
||||
/// answers a question takes is a fact about the question, and the
|
||||
/// alternative was Claude Code's tool-input schema written out a
|
||||
/// second time in Kotlin, where no other dialect could reach it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
multi_select: bool,
|
||||
/// The tool call this is permission for, when it is one, so a phone
|
||||
/// can draw the ask on the tool's own row rather than as a second
|
||||
/// card repeating its input. `None` for anything not about a tool.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
about: Option<String>,
|
||||
},
|
||||
/// A message another agent sent this session.
|
||||
///
|
||||
/// Its own kind rather than a `UserMessage`, because it is not something
|
||||
/// the reader said and a transcript that renders it in their voice is
|
||||
/// claiming they did. It also explains what would otherwise be
|
||||
/// inexplicable: a session working on something nobody here asked for.
|
||||
PeerMessage {
|
||||
/// The sending session's own name, which is what the reader
|
||||
/// recognises it by -- the socket path it came from is not.
|
||||
from: String,
|
||||
text: String,
|
||||
/// The seq of the `Status::Running` that opened the turn this message
|
||||
/// started, so a reader can draw it above that turn.
|
||||
///
|
||||
/// The CLI says nothing about a peer message until the turn's
|
||||
/// `result`, so the event is appended after everything it caused, and
|
||||
/// an append-only transcript cannot go back and insert it. Carrying
|
||||
/// the position instead keeps one order on the wire and one on screen.
|
||||
///
|
||||
/// Filled in by the pump, the only place that knows a seq, and only
|
||||
/// where a turn was open: `None` for a message replayed by `import`,
|
||||
/// which already has it in the right place.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
turn_start: Option<u64>,
|
||||
},
|
||||
/// The manager's record of a question being answered, so a rendered
|
||||
/// question card resolves on every device rather than only the one that
|
||||
/// answered.
|
||||
///
|
||||
/// A list because a question can take several answers, and one that took
|
||||
/// one is the list of length one rather than a different shape.
|
||||
Answered {
|
||||
id: String,
|
||||
answers: Vec<String>,
|
||||
},
|
||||
Status {
|
||||
state: SessionStatus,
|
||||
},
|
||||
/// What the session is set to, as the session itself reports it.
|
||||
///
|
||||
/// Asking for a change and having one are different things, and only this
|
||||
/// is a measurement: a model name the dialect does not know, a mode it
|
||||
/// refuses, or a driver whose model is fixed at startup all leave a
|
||||
/// request that was sent and nothing that changed. Reporting from the
|
||||
/// request put the answer on the phone before the question was answered.
|
||||
///
|
||||
/// Either field alone, because the two are confirmed separately.
|
||||
Settings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
permission_mode: Option<String>,
|
||||
},
|
||||
/// Per-turn token counts, where the dialect reports them.
|
||||
UsageDelta {
|
||||
/// What this turn cost: the tokens it was charged for.
|
||||
tokens: u64,
|
||||
/// What the model was holding when the turn ended -- see
|
||||
/// [`context_tokens`].
|
||||
///
|
||||
/// Carried rather than summed by whoever is reading, because it is
|
||||
/// not a sum: context goes *down* at a compaction and a clear, so
|
||||
/// adding turns up would report a figure the session stopped being
|
||||
/// true of long ago.
|
||||
///
|
||||
/// `None` where the dialect did not say, which every reader has to be
|
||||
/// able to draw.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
context: Option<u64>,
|
||||
},
|
||||
/// A compaction that finished, and how much context it recovered.
|
||||
///
|
||||
/// The counts are the point, and a spinner is not. They are optional
|
||||
/// because the record has shipped without them, and "the compaction
|
||||
/// happened, we don't know by how much" is a state this has to be able to
|
||||
/// say -- a plausible number would be indistinguishable from a counted one.
|
||||
Compacted {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pre_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
post_tokens: Option<u64>,
|
||||
/// What asked for it, in the dialect's own word -- `auto` when the
|
||||
/// session compacted on its own. Carried rather than reduced to a bool
|
||||
/// so an unrecognised trigger stays unrecognised: an automatic
|
||||
/// compaction is the one worth naming, because it explains a wait
|
||||
/// nobody asked for.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
trigger: Option<String>,
|
||||
},
|
||||
/// A command the session was asked to run on itself, held because it
|
||||
/// cannot run yet. These are not messages: `/compact` and `/rename` are
|
||||
/// instructions about the session, and a session mid-turn reads a line
|
||||
/// written to it as something the model should see. So they wait, and
|
||||
/// this is what a phone draws while they do.
|
||||
CommandQueued {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
/// The same command, now handed to the session. Its [`CommandQueued`]
|
||||
/// stops being pending when this arrives, matched by `id`; a command
|
||||
/// that ran immediately has only this.
|
||||
CommandSent {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
/// The conversation was cleared: everything above this is still in the
|
||||
/// record but is no longer in the session's context.
|
||||
///
|
||||
/// Nothing is deleted. A transcript is the thing a person scrolls back
|
||||
/// through, so this is a divider, not a truncation.
|
||||
///
|
||||
/// **Load-bearing, not decorative.** For any driver that rebuilds its
|
||||
/// conversation from the transcript, this marker decides what the model
|
||||
/// is given -- dropping it, or treating it as something only the phone
|
||||
/// draws, silently puts a cleared conversation back in front of the model
|
||||
/// at full cost. Today `llama::conversation` is the only fold that reads
|
||||
/// it, which is why this is written down rather than left to be inferred
|
||||
/// from a second example that does not exist.
|
||||
Cleared,
|
||||
/// The account behind this session has no quota left, so the turn stopped
|
||||
/// without finishing.
|
||||
///
|
||||
/// Its own event rather than an [`Event::Error`] carrying the dialect's
|
||||
/// sentence, because two things act on it that cannot read English: the
|
||||
/// transcript draws it as a state the session is in rather than as a
|
||||
/// failure of something it did, and `crate::resume` schedules the message
|
||||
/// that picks the work back up. Recognising it belongs to the driver, which
|
||||
/// is the only layer that knows its dialect's wording -- above here nothing
|
||||
/// matches on strings.
|
||||
///
|
||||
/// `resets_at` is epoch seconds, and `None` is a real state: the dialect
|
||||
/// said the limit was hit without saying when it lifts. Nothing here
|
||||
/// invents one -- what the wait is actually decided against is the usage
|
||||
/// endpoint, and this is the hint that starts the waiting.
|
||||
LimitReached {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
resets_at: Option<f64>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How much the model was holding, from the three figures a turn reports:
|
||||
/// the input side only, prompt plus both cache figures. A cached token is
|
||||
/// cheaper but it is still one the model was given; output is what the turn
|
||||
/// produced rather than what continuing has to carry.
|
||||
///
|
||||
/// One function so the definition cannot drift, because it is extracted two
|
||||
/// quite different ways -- the live translators have the usage object parsed,
|
||||
/// and `import::context_tokens` scans it out of a raw line without parsing.
|
||||
pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 {
|
||||
input + cache_creation + cache_read
|
||||
}
|
||||
|
||||
/// The context after `event`, given what it was before.
|
||||
///
|
||||
/// The whole rule in one place, because three readers need the same answer:
|
||||
/// the pump keeping a live session's figure, the transcript seeding it at
|
||||
/// startup, and the phone folding the same events into what it draws.
|
||||
///
|
||||
/// The two that *lower* it are the point. A clear takes the conversation away
|
||||
/// and a compaction replaces it with a summary, so a figure measured before
|
||||
/// either stopped being true at that moment -- and carrying it forward is how
|
||||
/// a session that had just been cleared went on reporting the context it no
|
||||
/// longer had.
|
||||
///
|
||||
/// `None` is "we don't know", which each of them can reach.
|
||||
pub fn context_after(current: Option<u64>, event: &Event) -> Option<u64> {
|
||||
match event {
|
||||
// `or`, so a turn the dialect reported no usage for leaves the last
|
||||
// measurement standing: stale by a turn, which every context figure
|
||||
// is, rather than wrong.
|
||||
Event::UsageDelta { context, .. } => context.or(current),
|
||||
Event::Compacted { post_tokens, .. } => *post_tokens,
|
||||
Event::Cleared => None,
|
||||
_ => current,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SessionStatus {
|
||||
Idle,
|
||||
Running,
|
||||
AwaitingInput,
|
||||
Compacting,
|
||||
Exited,
|
||||
/// There is a process recorded for this session and the machine will not
|
||||
/// say whether it is still running.
|
||||
///
|
||||
/// Its own state rather than the nearest of the others, because both
|
||||
/// neighbours are lies with consequences: `Exited` invites starting a
|
||||
/// second process against a conversation that may already have one, and
|
||||
/// `Idle` claims a session is waiting for you when nobody has checked.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One transcript line: an [`Event`] plus its position and time. The event
|
||||
/// is flattened so the wire shape stays one flat object.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SeqEvent {
|
||||
pub seq: u64,
|
||||
/// Epoch seconds.
|
||||
pub ts: f64,
|
||||
#[serde(flatten)]
|
||||
pub event: Event,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
perf.data*
|
||||
@@ -0,0 +1,131 @@
|
||||
[package]
|
||||
name = "iris"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
iris-core = { workspace = true }
|
||||
iris-macro = { workspace = true }
|
||||
parley = { workspace = true }
|
||||
swash = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
image = { workspace = true }
|
||||
accesskit = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
||||
# For diagnostics visible through android_logger (or whatever logger the
|
||||
# app crate installs) -- this crate never installs one itself. Not in the
|
||||
# android-only block below any more: the lines that matter most are in
|
||||
# shared widget code, which the host backend compiles too.
|
||||
log = "0.4.28"
|
||||
|
||||
# winit everywhere except Android; android-view (below) is what stands in
|
||||
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
|
||||
# doc comment) but are never compiled together: winit's own Android support
|
||||
# pulls in `android-activity`, which panics at compile time unless one of
|
||||
# its own backend features is picked, and picking one is exactly what
|
||||
# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it
|
||||
# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside
|
||||
# `android-activity` itself with "Either game-activity or native-activity
|
||||
# must be enabled" before this split existed.
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
winit = { workspace = true }
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
# I4 (RUST.md): the desktop half of the AccessKit push, `winit`'s own
|
||||
# adapter over `accesskit`. No pin needed the way android-view's rev is
|
||||
# pinned -- this is an ordinary crates.io release with no local abort to
|
||||
# track (that finding is Android-only, see below).
|
||||
accesskit_winit = "0.34.0"
|
||||
|
||||
# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this
|
||||
# emulator -- real Vulkan rendering, a working `InputConnection`, and the
|
||||
# accesskit-detach abort, all against this rev specifically. Advancing it
|
||||
# wants re-running E1's checks, the same reason the nightly toolchain pin
|
||||
# is dated rather than floating.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||
# I4 (RUST.md): the Android half of the AccessKit push, over android-view's
|
||||
# `AccessibilityNodeProvider`. **0.8.0 carries the same detach-abort E1
|
||||
# found on 0.4.0** (the `State` enum still never returns to `Inactive`,
|
||||
# and `send_completed_event` still unwraps a Java exception) -- advancing
|
||||
# the version is not the fix, so pinning to a specific rev buys nothing
|
||||
# here the way it does for android-view itself. `android/view.rs`'s
|
||||
# `raise_if_enabled` is the mitigation, carried from E1.
|
||||
accesskit_android = "0.8.0"
|
||||
# Not re-exported by android-view (only `jni` and `ndk` are), and needed
|
||||
# for `android/insets.rs`'s own id -> state map -- the same reason
|
||||
# android-view's own `PEER_MAP` carries one.
|
||||
send_wrapper = "0.6.0"
|
||||
|
||||
[features]
|
||||
# RUST.md's I5 "Where iris's frame time goes" diagnosis: forces the Android
|
||||
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so the
|
||||
# same build can be measured against SwiftShader's software Vulkan ICD (the
|
||||
# default) or virgl's GLES path, without a second env-var plumbing path that
|
||||
# nothing on this machine can hand to an already-launched Android process
|
||||
# (there is no `am start` environment and no system-property reader here to
|
||||
# add one). Read by `android/render.rs` and, so the GLES path can be
|
||||
# reproduced on a machine with a real GPU rather than only in the emulator,
|
||||
# by `default/render.rs`:
|
||||
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
|
||||
# --features iris/force-gles
|
||||
force-gles = []
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
||||
# The tabs example's widget tree. A dev-dependency cycle back to this
|
||||
# package is fine -- cargo excludes dev-dependencies from the graph used
|
||||
# to build the library itself, so this only matters for `--examples`.
|
||||
tabs-ui = { path = "tabs-ui" }
|
||||
|
||||
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
||||
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
||||
# test-crate harness cargo would otherwise want, in favour of an ordinary
|
||||
# `fn main()`.
|
||||
[[bench]]
|
||||
name = "message_list"
|
||||
harness = false
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"core",
|
||||
"macro",
|
||||
"tabs-ui",
|
||||
"transcript-ui",
|
||||
"transcript-fixture",
|
||||
"rig-input",
|
||||
"desktop-app",
|
||||
]
|
||||
# android-app pulls in android-view, which needs the NDK sysroot to link
|
||||
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||
# workspace, since it has no `[workspace]` table of its own and this
|
||||
# exclusion stops it inheriting this one): `cd android-app && cargo ndk
|
||||
# -t x86_64 -P 29 build`.
|
||||
exclude = ["android-app"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
pollster = "0.4.0"
|
||||
winit = "0.30.12"
|
||||
wgpu = "28.0.0"
|
||||
bytemuck = "1.23.1"
|
||||
image = "0.25.6"
|
||||
parley = "0.11.1"
|
||||
swash = "0.2.10"
|
||||
fxhash = "0.2.1"
|
||||
arboard = "3.6.1"
|
||||
accesskit = "0.25.0"
|
||||
iris-core = { path = "core" }
|
||||
iris-macro = { path = "macro" }
|
||||
tokio = "1.49.0"
|
||||
# Current stable as of 2026-09-05 (`cargo search`) -- I5's markdown block
|
||||
# model, the same crate E2's uncommitted `e2-transcript` experiment used for
|
||||
# the identical job (RUST.md), rather than reimplementing a CommonMark
|
||||
# parser.
|
||||
pulldown-cmark = "0.13.4"
|
||||
@@ -0,0 +1,40 @@
|
||||
images
|
||||
settings (sampler)
|
||||
|
||||
text
|
||||
figure out ways to speed up / what costs the most
|
||||
resizing (per frame) is really slow (assuming painter isn't griefing)
|
||||
j is weird / fix x offset
|
||||
|
||||
masks r just made to bare minimum work
|
||||
|
||||
scaling
|
||||
could be just a simple scaling factor that multiplies abs
|
||||
and need to ensure text uses raw abs and not scaled abs
|
||||
naming? (pt, px)
|
||||
want to keep (drawn) regions using px? or should I add another field to UiScalar/Vec
|
||||
field could be best solution so redrawing stuff isn't needed & you can specify both as user
|
||||
|
||||
WidgetRef<W> or smth instead of Id
|
||||
enum that's either an Id or an actual concrete instance of W
|
||||
painter takes them in instead of (or in addition to) id
|
||||
then type wrapper widgets to contain them
|
||||
allows for compile time optimization if a widget wrapper's inner is known at compile time
|
||||
and the id of inner is not needed anywhere
|
||||
maybe introduce InnerWidget trait to allow for editors to expose & modify inner type
|
||||
maybe could also store a parent widget and keep using InnerWidget trait? unsure if possible
|
||||
|
||||
really weird limitation:
|
||||
I don't think you can currently remove an element from a parent and put it in a child of the same parent
|
||||
because it removes the unused children after the entire parent redraw
|
||||
but the child gets drawn during that, so it will think the child is still active !!!
|
||||
or something like that idk, maybe I need a special enum for parent that includes a undecided state where it may or may not get redrawn by the parent
|
||||
or just do ref counting and ensure all drawn things == 1 afterwards (seems like best way)
|
||||
ok so I'm removing the limit for now
|
||||
|
||||
don't forget I'm streaming
|
||||
|
||||
tags
|
||||
vecs for each widget type?
|
||||
|
||||
POTENTIAL BUG: closures that store IDs will not decrement the id!!! need to not increment id if moved into closure somehow??? wait no, need to decrement ID every time an event fn is added...... only if the id is used in it..??
|
||||
@@ -0,0 +1,6 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
# Rebuilt by `cargo ndk -o app/src/main/jniLibs/ build` before every
|
||||
# Gradle build -- see RUST.md's I2 for the exact command.
|
||||
app/src/main/jniLibs/
|
||||
@@ -0,0 +1,78 @@
|
||||
[package]
|
||||
name = "iris-android-app"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# Deliberately outside the `iris` workspace (see that Cargo.toml's
|
||||
# `[workspace] exclude`): this crate exists only to be cross-compiled with
|
||||
# `cargo ndk` for the emulator/a phone, and pulls in android-view, which
|
||||
# needs the NDK sysroot to link. Folding it into the main workspace would
|
||||
# make `cargo build --workspace --all-targets` -- the host command RUST.md
|
||||
# and AGENTS.md both require to stay clean -- try to link a cdylib against
|
||||
# libraries that do not exist on this machine. See RUST.md's I2.
|
||||
|
||||
[lib]
|
||||
name = "main"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
iris = { path = "../" }
|
||||
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||
android_logger = "0.15.0"
|
||||
log = "0.4.28"
|
||||
# `tabs-screen` (default, I2/I4's demo) and `transcript-screen` (I5's
|
||||
# Android integration) are mutually exclusive -- one `ActiveClient` type is
|
||||
# compiled in, never both (`lib.rs`'s doc comment) -- so both sets of deps
|
||||
# are optional and each screen's feature pulls in only its own. Without
|
||||
# this, building `--features transcript-screen` alone (default features
|
||||
# still on) left `tabs-ui` linked but never referenced under that cfg,
|
||||
# which Cargo's `unused_dependencies` lint (on by default) correctly flags.
|
||||
tabs-ui = { path = "../tabs-ui", optional = true }
|
||||
transcript-ui = { path = "../transcript-ui", optional = true }
|
||||
# P0's bench build only: the fixture and the folded screen both bench
|
||||
# clients open, shared with the headless harness and the desktop window
|
||||
# (docs/RUST.md's "Three test layers").
|
||||
transcript-fixture = { path = "../transcript-fixture", optional = true }
|
||||
client-core = { path = "../../client-core", optional = true }
|
||||
event-model = { path = "../../event-model", optional = true }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
||||
# P0's bench build only (docs/RUST.md): `getrusage(RUSAGE_SELF)` for
|
||||
# process CPU time, matching `libc::getrusage`'s mention in that box over
|
||||
# parsing `/proc/self/stat` by hand and assuming `USER_HZ`. Already in the
|
||||
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
|
||||
# at 0.2.179) -- this makes it a direct dependency at the same version
|
||||
# rather than a second, possibly-drifting resolution.
|
||||
libc = { version = "0.2.179", optional = true }
|
||||
# P0's bench build only: the scroll animation and the streaming phase are
|
||||
# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already
|
||||
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
|
||||
# the battery sampler is a second, concurrent task on that same runtime
|
||||
# (`tokio::spawn`) -- so this crate needs `tokio` directly rather than only
|
||||
# through `iris`. `rt`+`time` only: no I/O, no macros, nothing this crate
|
||||
# doesn't call. Version matches the one `iris`'s own dependency tree already
|
||||
# resolves to (`iris/Cargo.lock`), so there is one copy of the runtime, not
|
||||
# two.
|
||||
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||
|
||||
[features]
|
||||
default = ["tabs-screen"]
|
||||
tabs-screen = ["dep:tabs-ui"]
|
||||
transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model", "dep:serde_json"]
|
||||
# RUST.md's I5 "Where iris's frame time goes": forces the GLES backend
|
||||
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
|
||||
# on the feature this forwards to.
|
||||
force-gles = ["iris/force-gles"]
|
||||
# P0's iris half (docs/RUST.md, docs/AGENTS.md's "The rigs"): the same
|
||||
# checked-in fixture, scroll loop and streaming phase the Compose `bench`
|
||||
# build type drives, run here against `transcript-ui`'s real screen with no
|
||||
# server. Depends on `transcript-screen` for `transcript-ui`/`client-core`/
|
||||
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
||||
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
||||
# listed, which is how this crate's build command names both explicitly.
|
||||
bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"]
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
[profile.dev]
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,90 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
// The Rust side (this directory's Cargo.toml) is built separately with
|
||||
// `cargo ndk`, straight into src/main/jniLibs/ -- see the repo-root
|
||||
// AGENTS.md-style comment at the top of Cargo.toml for why this crate
|
||||
// stays outside the main Rust workspace, and RUST.md's I2 for the exact
|
||||
// build command.
|
||||
android {
|
||||
namespace = "dev.iris.android.demo"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.iris.android.demo"
|
||||
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||
// sample with `MotionEvent.getEventTimeNanos` and
|
||||
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
|
||||
// method there is a hard crash on the first touch rather than a
|
||||
// degraded fling. Raised deliberately rather than guarded at
|
||||
// runtime: nothing this app is built for runs below 29, and an
|
||||
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||
// `cargo ndk -P` is kept at the same number.
|
||||
minSdk = 29
|
||||
// 37, matching `compileSdk` and the Compose app in `app/` -- which
|
||||
// is the one part of this that is measured rather than reasoned:
|
||||
// that app targets 37 and its keyboard does push the transcript up
|
||||
// on Iris's phone, and this one targeted 34 and does not
|
||||
// (2026-09-07). The emulator here is API 36 and the push-up works
|
||||
// there at either target, so the target is the only difference the
|
||||
// two devices do not share.
|
||||
//
|
||||
// The mechanism, stated as the reading it is: below targetSdk 35
|
||||
// a window keeps the legacy behaviour, where `adjustResize` shrinks
|
||||
// the window for the IME and `getInsets(ime()).bottom` therefore
|
||||
// measures the overlap with an already-shrunk window -- zero, with
|
||||
// nothing left to push up. `MainActivity`'s
|
||||
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
|
||||
// 36 it still takes; Android 16 deprecated it and Android 17 is
|
||||
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
|
||||
// the app is handed the real overlap without relying on a
|
||||
// deprecated call. If the phone still reports `ime_bottom=0` with
|
||||
// a nonzero `dispatches` in the Diagnostics pane, this reading was
|
||||
// wrong and the `WindowInsetsAnimation.Callback` in
|
||||
// `MainActivity` is the other half to look at.
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
// A release build must be signed, and the key is per machine rather than per repo -- same
|
||||
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
|
||||
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
|
||||
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
|
||||
// environment; without it a release build here is unsigned, which is fine for everything
|
||||
// except installing.
|
||||
def keystore = System.getenv("AI_APP_KEYSTORE")
|
||||
signingConfigs {
|
||||
if (keystore != null) {
|
||||
release {
|
||||
storeFile = file(keystore)
|
||||
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||
keyAlias = "ai-app"
|
||||
keyPassword = storePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
}
|
||||
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
|
||||
// itself is built separately with `cargo ndk --release --features "transcript-screen
|
||||
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
|
||||
// Gradle here only packages and signs whatever is already there, the same division as the
|
||||
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
|
||||
// installable beside a debug build of the tabs demo rather than replacing it.
|
||||
release {
|
||||
applicationIdSuffix ".bench"
|
||||
if (keystore != null) {
|
||||
signingConfig = signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Only needed by the transcript-screen feature (RUST.md's I5),
|
||||
which talks to a real ai-server; the plain tabs demo (I2/I4) makes
|
||||
no network call and never noticed this was missing. Absent,
|
||||
UreqTransport::new's connect failed with EPERM (Operation not
|
||||
permitted), not the ECONNREFUSED/ENETUNREACH a firewall or a dead
|
||||
server would give: a seccomp-level socket denial reads nothing
|
||||
like a network problem, which is what made it worth a comment. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="iris android-view demo"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="android.app.lib_name" android:value="main" />
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,156 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.linebender.android.rustview.RustView;
|
||||
|
||||
/**
|
||||
* android-view's abstract base plus the two native methods it has no hook
|
||||
* for: window insets and unregistering this view's entry in
|
||||
* iris::android::insets's side table. See iris/src/android/insets.rs's doc
|
||||
* comment for why those could not ride along on an existing android-view
|
||||
* callback the way the back gesture does.
|
||||
*/
|
||||
public final class IrisView extends RustView {
|
||||
@Override
|
||||
protected native long newViewPeer(Context context);
|
||||
|
||||
native void applyWindowInsetsNative(
|
||||
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
|
||||
|
||||
native void unregisterInsetsNative(long peer);
|
||||
|
||||
public IrisView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
void applyWindowInsets(
|
||||
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
|
||||
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
unregisterInsetsNative(mViewPeer);
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the Rust side (iris/src/android/view.rs's
|
||||
* `show_renderer_error`) when `AndroidRenderer::new` fails instead of
|
||||
* drawing -- an ordinary instance method rather than a `native` one,
|
||||
* since this call is Rust reaching into Java rather than the other
|
||||
* direction. Replaces the whole activity content with plain,
|
||||
* selectable, scrollable text rather than leaving the last frame (or a
|
||||
* blank surface) on screen with no way to report what happened:
|
||||
* UI_RULES.md's "a failure is reported where it happened, and says
|
||||
* what to do next." No dialog and no styling beyond what is needed to
|
||||
* read and copy the text -- this path exists for exactly the crash it
|
||||
* replaces, so it must not depend on anything that could itself fail
|
||||
* to render.
|
||||
*/
|
||||
void showRendererError(String report) {
|
||||
Context context = getContext();
|
||||
if (!(context instanceof Activity)) {
|
||||
return;
|
||||
}
|
||||
Activity activity = (Activity) context;
|
||||
TextView text = new TextView(activity);
|
||||
text.setText(report);
|
||||
text.setTextIsSelectable(true);
|
||||
text.setGravity(Gravity.TOP | Gravity.START);
|
||||
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
|
||||
text.setPadding(pad, pad, pad, pad);
|
||||
ScrollView scroll = new ScrollView(activity);
|
||||
scroll.addView(text);
|
||||
activity.setContentView(scroll);
|
||||
}
|
||||
|
||||
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
|
||||
|
||||
/**
|
||||
* The bench build's keyboard diagnostics capture
|
||||
* (`bench_client.rs`'s `on_insets_changed` /
|
||||
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
|
||||
* `PlatformHandle::show_diagnostics_overlay`): unlike
|
||||
* `showRendererError` above, this adds a panel *over* this view
|
||||
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
|
||||
* running) rather than replacing the activity's content, and gives it
|
||||
* a Copy button and a Close that removes the panel -- so it draws
|
||||
* (and can be read) whether or not iris itself is still putting
|
||||
* anything on screen, without abandoning the session that produced
|
||||
* it. Runs on the UI thread regardless of which thread calls it,
|
||||
* since the call comes from a background task (a delayed capture
|
||||
* after the keyboard opens), and touching the view tree off the UI
|
||||
* thread is undefined.
|
||||
*/
|
||||
void showDiagnosticsOverlay(String report) {
|
||||
Context context = getContext();
|
||||
if (!(context instanceof Activity)) {
|
||||
return;
|
||||
}
|
||||
Activity activity = (Activity) context;
|
||||
activity.runOnUiThread(() -> {
|
||||
ViewGroup parent = (ViewGroup) getParent();
|
||||
if (parent == null) {
|
||||
return;
|
||||
}
|
||||
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||
if (existing != null) {
|
||||
parent.removeView(existing);
|
||||
}
|
||||
|
||||
float density = activity.getResources().getDisplayMetrics().density;
|
||||
int pad = (int) (16 * density);
|
||||
|
||||
LinearLayout overlay = new LinearLayout(activity);
|
||||
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||
overlay.setOrientation(LinearLayout.VERTICAL);
|
||||
overlay.setBackgroundColor(0xEE000000);
|
||||
overlay.setPadding(pad, pad, pad, pad);
|
||||
|
||||
TextView text = new TextView(activity);
|
||||
text.setText(report);
|
||||
text.setTextIsSelectable(true);
|
||||
text.setTextColor(0xFFFFFFFF);
|
||||
ScrollView scroll = new ScrollView(activity);
|
||||
scroll.addView(text);
|
||||
overlay.addView(scroll, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
|
||||
LinearLayout buttonRow = new LinearLayout(activity);
|
||||
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
buttonRow.setPadding(0, pad, 0, 0);
|
||||
|
||||
Button copy = new Button(activity);
|
||||
copy.setText("Copy");
|
||||
copy.setOnClickListener(v -> {
|
||||
ClipboardManager clipboard =
|
||||
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard != null) {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
|
||||
}
|
||||
});
|
||||
Button close = new Button(activity);
|
||||
close.setText("Close");
|
||||
close.setOnClickListener(v -> parent.removeView(overlay));
|
||||
buttonRow.addView(copy);
|
||||
buttonRow.addView(close);
|
||||
overlay.addView(buttonRow);
|
||||
|
||||
parent.addView(overlay, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowInsetsAnimation;
|
||||
import android.widget.FrameLayout;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
||||
* filling the window, running iris's tabs example through
|
||||
* iris-android-app's Rust side. Mirrors android-view's own
|
||||
* DemoActivity, plus the window-insets wiring that has no android-view
|
||||
* counterpart.
|
||||
*/
|
||||
public final class MainActivity extends Activity {
|
||||
static {
|
||||
System.loadLibrary("main");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
IrisView view = new IrisView(this);
|
||||
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
view.setFocusable(true);
|
||||
view.setFocusableInTouchMode(true);
|
||||
FrameLayout layout = new FrameLayout(this);
|
||||
layout.addView(view);
|
||||
setContentView(layout);
|
||||
view.requestFocus();
|
||||
|
||||
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
|
||||
// `logcat` showed the platform's own IME open/resize happening
|
||||
// while `setOnApplyWindowInsetsListener` fired only once, at
|
||||
// attach, and never again for a pure keyboard toggle -- a plain
|
||||
// (non-edge-to-edge) window is only guaranteed that one initial
|
||||
// dispatch; `adjustResize` handling the IME entirely by resizing
|
||||
// the window is not itself a trigger for a fresh one. Opting into
|
||||
// edge-to-edge (a platform call, API 30+, no new dependency) is
|
||||
// what makes the system redeliver insets on every change,
|
||||
// including the ones this activity actually cares about --
|
||||
// `getSystemWindowInset*` below is unaffected by this (it has
|
||||
// always reported the raw system-bar/IME overlap regardless of
|
||||
// who consumes it), so the on-screen bars and the padding Rust
|
||||
// already derives from those four numbers are unchanged; only the
|
||||
// callback's firing became reliable.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getWindow().setDecorFitsSystemWindows(false);
|
||||
}
|
||||
|
||||
// **The keyboard's height arrives twice, over two different
|
||||
// paths, and the phone needs the second one** (Iris, 2026-09-07:
|
||||
// the emulator pushed the composer up and her Pixel did not).
|
||||
// `setOnApplyWindowInsetsListener` is the platform's *settled*
|
||||
// answer; `WindowInsetsAnimation.Callback` is the running one, and
|
||||
// an IME that animates in delivers every intermediate height
|
||||
// through the callback with the static dispatch arriving only at
|
||||
// the ends -- on some devices only at `onEnd`. Registering both
|
||||
// means neither device depends on the other's timing, and it is
|
||||
// also what makes the push-up *animate* with the keyboard rather
|
||||
// than jump when it lands.
|
||||
//
|
||||
// The two do not disagree, because they are the same call with the
|
||||
// same numbers read out of whichever `WindowInsets` is current.
|
||||
// `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming
|
||||
// nothing keeps the ordinary dispatch running underneath.
|
||||
// `onEnd` re-reads the root's insets rather than trusting the last
|
||||
// `onProgress`: an animation interrupted mid-flight never delivers
|
||||
// its final frame, which is exactly the fault the Compose app hit
|
||||
// (AGENTS.md, "the composer can get stuck floating above the
|
||||
// bottom of the screen").
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
|
||||
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||
@Override
|
||||
public WindowInsets onProgress(
|
||||
WindowInsets insets, List<WindowInsetsAnimation> running) {
|
||||
sendInsets(view, insets);
|
||||
return insets;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnd(WindowInsetsAnimation animation) {
|
||||
WindowInsets settled = view.getRootWindowInsets();
|
||||
if (settled != null) {
|
||||
sendInsets(view, settled);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
sendInsets((IrisView) v, insets);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||
int left = insets.getSystemWindowInsetLeft();
|
||||
int top = insets.getSystemWindowInsetTop();
|
||||
int right = insets.getSystemWindowInsetRight();
|
||||
int bottom = insets.getSystemWindowInsetBottom();
|
||||
// **Two separate answers, because they are separate questions**
|
||||
// (Iris's phone, 2026-09-06: "message box does not push up the
|
||||
// scroll area"). `isVisible(ime())` says whether the keyboard is
|
||||
// up; `getInsets(ime()).bottom` says how tall it is. An earlier
|
||||
// pass sent the boolean *as* the height (0 or 1) because under
|
||||
// plain `adjustResize` the window shrinks to make room and the ime
|
||||
// inset therefore measures a zero overlap by construction -- true
|
||||
// then, and no longer true now that this is an edge-to-edge window
|
||||
// (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call
|
||||
// above for the devices below that), which is exactly the case
|
||||
// where the system stops resizing and hands the app the real
|
||||
// overlap instead. Sending 1 for it left the Rust side padding the
|
||||
// composer by one physical pixel, so the keyboard covered the bar
|
||||
// and the transcript alike.
|
||||
//
|
||||
// The visibility is still sent in its own right rather than
|
||||
// inferred from `height > 0`: the two disagree during the
|
||||
// keyboard's slide-in and -out (visible, height still climbing),
|
||||
// and "is the IME up" drives the bench's own state machine
|
||||
// (`bench_client.rs`'s `ime_state`) where a half-open frame
|
||||
// reading as "closed" is a miscount.
|
||||
int imeBottom = 0;
|
||||
int imeVisible = 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
|
||||
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
|
||||
}
|
||||
view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package org.linebender.android.rustview;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.inputmethod.CompletionInfo;
|
||||
import android.view.inputmethod.CorrectionInfo;
|
||||
import android.view.inputmethod.ExtractedText;
|
||||
import android.view.inputmethod.ExtractedTextRequest;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
import android.view.inputmethod.InputContentInfo;
|
||||
|
||||
class RustInputConnection implements InputConnection {
|
||||
private final RustView mView;
|
||||
|
||||
RustInputConnection(RustView view) {
|
||||
mView = view;
|
||||
}
|
||||
|
||||
private long getViewPeer() {
|
||||
return mView.mViewPeer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getTextBeforeCursor(int n, int flags) {
|
||||
return mView.getTextBeforeCursorNative(getViewPeer(), n);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getTextAfterCursor(int n, int flags) {
|
||||
return mView.getTextAfterCursorNative(getViewPeer(), n);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CharSequence getSelectedText(int flags) {
|
||||
return mView.getSelectedTextNative(getViewPeer());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCursorCapsMode(int reqModes) {
|
||||
return mView.getCursorCapsModeNative(getViewPeer(), reqModes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
|
||||
return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
|
||||
return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setComposingText(CharSequence text, int newCursorPosition) {
|
||||
return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setComposingRegion(int start, int end) {
|
||||
return mView.setComposingRegionNative(getViewPeer(), start, end);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean finishComposingText() {
|
||||
return mView.finishComposingTextNative(getViewPeer());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||
return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commitCompletion(CompletionInfo text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commitCorrection(CorrectionInfo correctionInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setSelection(int start, int end) {
|
||||
return mView.setSelectionNative(getViewPeer(), start, end);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performEditorAction(int editorAction) {
|
||||
return mView.performEditorActionNative(getViewPeer(), editorAction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performContextMenuAction(int id) {
|
||||
return mView.performContextMenuActionNative(getViewPeer(), id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean beginBatchEdit() {
|
||||
return mView.beginBatchEditNative(getViewPeer());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean endBatchEdit() {
|
||||
return mView.endBatchEditNative(getViewPeer());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean sendKeyEvent(KeyEvent event) {
|
||||
return mView.inputConnectionSendKeyEventNative(getViewPeer(), event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clearMetaKeyStates(int states) {
|
||||
return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean reportFullscreenMode(boolean enabled) {
|
||||
return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performPrivateCommand(String action, Bundle data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestCursorUpdates(int cursorUpdateMode) {
|
||||
return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Handler getHandler() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeConnection() {
|
||||
mView.closeInputConnectionNative(getViewPeer());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package org.linebender.android.rustview;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Bundle;
|
||||
import android.view.Choreographer;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.accessibility.AccessibilityNodeInfo;
|
||||
import android.view.accessibility.AccessibilityNodeProvider;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
public abstract class RustView extends SurfaceView
|
||||
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
|
||||
// with one deliberate change: `protected` rather than package-private, so a
|
||||
// subclass in a different package (dev.iris.android.demo.IrisView) can pass
|
||||
// it to the window-insets native call android-view itself has no hook for --
|
||||
// see iris/src/android/insets.rs's doc comment for why that call exists at
|
||||
// all. No other line differs from upstream.
|
||||
protected final long mViewPeer;
|
||||
final InputMethodManager mInputMethodManager;
|
||||
|
||||
protected abstract long newViewPeer(Context context);
|
||||
|
||||
public RustView(Context context) {
|
||||
super(context);
|
||||
mViewPeer = newViewPeer(context);
|
||||
getHolder().addCallback(this);
|
||||
mInputMethodManager =
|
||||
(InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
}
|
||||
|
||||
private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec);
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthSpec, int heightSpec) {
|
||||
int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec);
|
||||
if (result != null) {
|
||||
setMeasuredDimension(result[0], result[1]);
|
||||
} else {
|
||||
super.onMeasure(widthSpec, heightSpec);
|
||||
}
|
||||
}
|
||||
|
||||
private native void onLayoutNative(
|
||||
long peer, boolean changed, int left, int top, int right, int bottom);
|
||||
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
||||
onLayoutNative(mViewPeer, changed, left, top, right, bottom);
|
||||
super.onLayout(changed, left, top, right, bottom);
|
||||
}
|
||||
|
||||
private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh);
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
onSizeChangedNative(mViewPeer, w, h, oldw, oldh);
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
}
|
||||
|
||||
private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event);
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event);
|
||||
|
||||
@Override
|
||||
public boolean onKeyUp(int keyCode, KeyEvent event) {
|
||||
return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event);
|
||||
}
|
||||
|
||||
private native boolean onTrackballEventNative(long peer, MotionEvent event);
|
||||
|
||||
@Override
|
||||
public boolean onTrackballEvent(MotionEvent event) {
|
||||
return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event);
|
||||
}
|
||||
|
||||
private native boolean onTouchEventNative(long peer, MotionEvent event);
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event);
|
||||
}
|
||||
|
||||
private native boolean onGenericMotionEventNative(long peer, MotionEvent event);
|
||||
|
||||
@Override
|
||||
public boolean onGenericMotionEvent(MotionEvent event) {
|
||||
return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
private native boolean onHoverEventNative(long peer, MotionEvent event);
|
||||
|
||||
@Override
|
||||
public boolean onHoverEvent(MotionEvent event) {
|
||||
return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event);
|
||||
}
|
||||
|
||||
private native void onFocusChangedNative(
|
||||
long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect);
|
||||
|
||||
@Override
|
||||
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
|
||||
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
|
||||
onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect);
|
||||
}
|
||||
|
||||
private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus);
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasWindowFocus) {
|
||||
super.onWindowFocusChanged(hasWindowFocus);
|
||||
onWindowFocusChangedNative(mViewPeer, hasWindowFocus);
|
||||
}
|
||||
|
||||
private native void onAttachedToWindowNative(long peer);
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
onAttachedToWindowNative(mViewPeer);
|
||||
}
|
||||
|
||||
private native void onDetachedFromWindowNative(long peer);
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
onDetachedFromWindowNative(mViewPeer);
|
||||
}
|
||||
|
||||
private native void onWindowVisibilityChangedNative(long peer, int visibility);
|
||||
|
||||
@Override
|
||||
protected void onWindowVisibilityChanged(int visibility) {
|
||||
super.onWindowVisibilityChanged(visibility);
|
||||
onWindowVisibilityChangedNative(mViewPeer, visibility);
|
||||
}
|
||||
|
||||
private native void surfaceCreatedNative(long peer, SurfaceHolder holder);
|
||||
|
||||
@Override
|
||||
public void surfaceCreated(SurfaceHolder holder) {
|
||||
surfaceCreatedNative(mViewPeer, holder);
|
||||
}
|
||||
|
||||
private native void surfaceChangedNative(
|
||||
long peer, SurfaceHolder holder, int format, int width, int height);
|
||||
|
||||
@Override
|
||||
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||
surfaceChangedNative(mViewPeer, holder, format, width, height);
|
||||
}
|
||||
|
||||
private native void surfaceDestroyedNative(long peer, SurfaceHolder holder);
|
||||
|
||||
@Override
|
||||
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||
surfaceDestroyedNative(mViewPeer, holder);
|
||||
}
|
||||
|
||||
void postFrameCallback() {
|
||||
Choreographer c = Choreographer.getInstance();
|
||||
c.removeFrameCallback(this);
|
||||
c.postFrameCallback(this);
|
||||
}
|
||||
|
||||
void removeFrameCallback() {
|
||||
Choreographer.getInstance().removeFrameCallback(this);
|
||||
}
|
||||
|
||||
private native void doFrameNative(long peer, long frameTimeNanos);
|
||||
|
||||
@Override
|
||||
public void doFrame(long frameTimeNanos) {
|
||||
doFrameNative(mViewPeer, frameTimeNanos);
|
||||
}
|
||||
|
||||
private native void delayedCallbackNative(long peer);
|
||||
|
||||
private final Runnable mDelayedCallback =
|
||||
new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
delayedCallbackNative(mViewPeer);
|
||||
}
|
||||
};
|
||||
|
||||
boolean postDelayed(long delayMillis) {
|
||||
return postDelayed(mDelayedCallback, delayMillis);
|
||||
}
|
||||
|
||||
boolean removeDelayedCallbacks() {
|
||||
return removeCallbacks(mDelayedCallback);
|
||||
}
|
||||
|
||||
private native boolean hasAccessibilityNodeProviderNative(long peer);
|
||||
|
||||
private native AccessibilityNodeInfo createAccessibilityNodeInfoNative(
|
||||
long peer, int virtualViewId);
|
||||
|
||||
private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId);
|
||||
|
||||
private native boolean performAccessibilityActionNative(
|
||||
long peer, int virtualViewId, int action, Bundle arguments);
|
||||
|
||||
@Override
|
||||
public AccessibilityNodeProvider getAccessibilityNodeProvider() {
|
||||
if (!hasAccessibilityNodeProviderNative(mViewPeer)) {
|
||||
return super.getAccessibilityNodeProvider();
|
||||
}
|
||||
return new AccessibilityNodeProvider() {
|
||||
@Override
|
||||
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
|
||||
return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccessibilityNodeInfo findFocus(int focusType) {
|
||||
return accessibilityFindFocusNative(mViewPeer, focusType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performAction(int virtualViewId, int action, Bundle arguments) {
|
||||
return performAccessibilityActionNative(
|
||||
mViewPeer, virtualViewId, action, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs);
|
||||
|
||||
@Override
|
||||
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
||||
if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) {
|
||||
return null;
|
||||
}
|
||||
return new RustInputConnection(this);
|
||||
}
|
||||
|
||||
native String getTextBeforeCursorNative(long peer, int n);
|
||||
|
||||
native String getTextAfterCursorNative(long peer, int n);
|
||||
|
||||
native String getSelectedTextNative(long peer);
|
||||
|
||||
native int getCursorCapsModeNative(long peer, int reqModes);
|
||||
|
||||
native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength);
|
||||
|
||||
native boolean deleteSurroundingTextInCodePointsNative(
|
||||
long peer, int beforeLength, int afterLength);
|
||||
|
||||
native boolean setComposingTextNative(long peer, String text, int newCursorPosition);
|
||||
|
||||
native boolean setComposingRegionNative(long peer, int start, int end);
|
||||
|
||||
native boolean finishComposingTextNative(long peer);
|
||||
|
||||
native boolean commitTextNative(long peer, String text, int newCursorPosition);
|
||||
|
||||
native boolean setSelectionNative(long peer, int start, int end);
|
||||
|
||||
native boolean performEditorActionNative(long peer, int editorAction);
|
||||
|
||||
native boolean performContextMenuActionNative(long peer, int id);
|
||||
|
||||
native boolean beginBatchEditNative(long peer);
|
||||
|
||||
native boolean endBatchEditNative(long peer);
|
||||
|
||||
native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event);
|
||||
|
||||
native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states);
|
||||
|
||||
native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled);
|
||||
|
||||
native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode);
|
||||
|
||||
native void closeInputConnectionNative(long peer);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/bin/sh
|
||||
# Builds iris-android-app end to end: the cdylib (cargo ndk, straight into
|
||||
# app/src/main/jniLibs/) then the APK (Gradle). Written to stop re-typing
|
||||
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
|
||||
# cargo ndk invocation, the keystore env for a release build, apksigner/
|
||||
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
|
||||
# build-apk.sh` (the Compose app's own build script) and `app/
|
||||
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
|
||||
#
|
||||
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
|
||||
# debug/release default to debug (matches this-machine-android's "the
|
||||
# emulator stays on debug" rule -- pass `release` explicitly for a phone
|
||||
# build). --abi defaults to arm64-v8a (a phone/real device); pass
|
||||
# x86_64 for this checkout's own AVD. --features defaults to
|
||||
# "transcript-screen bench" -- deliberately *without* `force-gles`, unlike
|
||||
# an earlier version of this default. `force-gles` (`iris/Cargo.toml`'s
|
||||
# own doc) exists only to force the emulator off its default software
|
||||
# Vulkan and onto GLES for one specific measurement (RUST.md's I5, "Where
|
||||
# iris's frame time goes") -- it was never meant to reach a real device,
|
||||
# but this script's old default put it in every arm64 build regardless,
|
||||
# so the P0 bench APK delivered to Iris's phone forced GLES there too.
|
||||
# That is the named hypothesis in RUST.md's P0 box ("iris bench crash on
|
||||
# the phone, 2026-09-06"): a real Vulkan driver is what a phone should
|
||||
# run, and GLES is the backend the same box's own SwiftShader finding
|
||||
# already flagged as the fragile one for this shader's storage buffers.
|
||||
# Pass `--features "transcript-screen force-gles bench"` explicitly for
|
||||
# an emulator backend-isolation run; never for a build meant for a phone.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUILD_TYPE="debug"
|
||||
ABI="arm64-v8a"
|
||||
FEATURES="transcript-screen bench"
|
||||
case "${1:-}" in
|
||||
debug|release) BUILD_TYPE="$1"; shift ;;
|
||||
esac
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--abi) ABI="$2"; shift 2 ;;
|
||||
--features) FEATURES="$2"; shift 2 ;;
|
||||
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SDK_ROOT="$HOME/Android/Sdk"
|
||||
export ANDROID_HOME="$SDK_ROOT"
|
||||
export ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
|
||||
if [ -z "$NDK_DIR" ]; then
|
||||
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
|
||||
exit 1
|
||||
fi
|
||||
export ANDROID_NDK_HOME="$NDK_DIR"
|
||||
|
||||
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
|
||||
# whatever earlier builds left here, and Gradle packages every directory it
|
||||
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
|
||||
# 339 MB on 2026-09-06.
|
||||
rm -rf app/src/main/jniLibs
|
||||
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --release --features "$FEATURES"
|
||||
else
|
||||
cargo ndk -t "$ABI" -P 29 -o app/src/main/jniLibs/ build --features "$FEATURES"
|
||||
fi
|
||||
|
||||
GRADLE_TASK="assembleDebug"
|
||||
APK_DIR="app/build/outputs/apk/debug"
|
||||
APK_NAME="app-debug.apk"
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
GRADLE_TASK="assembleRelease"
|
||||
APK_DIR="app/build/outputs/apk/release"
|
||||
APK_NAME="app-release.apk"
|
||||
# Same key `app/build-apk.sh` (the Compose app) generates once under
|
||||
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
|
||||
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
|
||||
if [ ! -f "$AI_APP_KEYSTORE" ]; then
|
||||
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
|
||||
exit 1
|
||||
fi
|
||||
export AI_APP_KEYSTORE_PASSWORD
|
||||
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
|
||||
fi
|
||||
|
||||
gradle ":app:$GRADLE_TASK" --console=plain
|
||||
|
||||
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
|
||||
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
|
||||
echo "--- aapt2 dump badging ---"
|
||||
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
echo "--- apksigner verify ---"
|
||||
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
|
||||
fi
|
||||
echo "$APK_PATH"
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.4.0" apply false
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Only does anything under the `transcript-screen` feature (RUST.md's I5
|
||||
// Android integration) -- the plain tabs build (I2/I4) needs none of this
|
||||
// and stays untouched, same reasoning as the feature gate in Cargo.toml.
|
||||
//
|
||||
// Bakes the sandbox server's host, port, token and pinned CA in at build
|
||||
// time, the same way `app/androidApp/build.gradle.kts`'s
|
||||
// `GeneratePinnedCert` task bakes the CA for the Compose app -- see that
|
||||
// file's comment for why reading the machine's own certificate at build
|
||||
// time is the right trust boundary. This build additionally bakes the
|
||||
// host/port/token, which the Compose app does not: that app enrolls at
|
||||
// runtime from a scanned QR/deep link, and a from-scratch enrollment UI
|
||||
// (Keystore-sealed token storage, a QR/link scanner) is real, separate
|
||||
// scope this integration does not need to build to answer RUST.md's
|
||||
// question -- there is nothing here yet resembling `ServerConfig.kt`. So
|
||||
// this is a **deliberate simplification for this rig only**: an APK built
|
||||
// this way is good for exactly the emulator/server pair that built it, and
|
||||
// must never be treated as a template for a real enrollment flow. Recorded
|
||||
// in RUST.md's I5 box rather than left to be rediscovered.
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
||||
return;
|
||||
}
|
||||
// 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_path = std::env::var_os("AI_APP_CA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var_os("HOME").expect("HOME must be set");
|
||||
PathBuf::from(home).join(".config")
|
||||
});
|
||||
base.join("ai-app").join("certs").join("ca.pem")
|
||||
});
|
||||
let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"no CA certificate at {} ({e}).\n\
|
||||
Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it \
|
||||
generates the CA this build pins. Set AI_APP_CA=/path/to/ca.pem to build \
|
||||
against a different one.",
|
||||
ca_path.display()
|
||||
)
|
||||
});
|
||||
let ca_pem = ca_pem.trim();
|
||||
if !ca_pem.starts_with("-----BEGIN CERTIFICATE-----") {
|
||||
panic!("{} is not a PEM certificate.", ca_path.display());
|
||||
}
|
||||
|
||||
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
let generated = format!(
|
||||
"// Generated by build.rs from {host}:{port} and {ca}. 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}")),
|
||||
ca = ca_path.display(),
|
||||
host_lit = host,
|
||||
token_lit = token,
|
||||
ca_lit = ca_pem,
|
||||
);
|
||||
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
|
||||
}
|
||||
|
||||
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}")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
# Installs and runs the iris `bench` build on this checkout's own emulator
|
||||
# (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it)
|
||||
# and prints the report -- the iris half of `app/transcript-bench.sh`'s
|
||||
# job. No coordinates: the button is found by its accessibility label
|
||||
# through `ui-trace`, per AGENTS.md's "Driving the UI".
|
||||
#
|
||||
# Usage: ./run-bench.sh [--apk PATH]
|
||||
# Defaults to this checkout's own release APK
|
||||
# (app/build/outputs/apk/release/app-release.apk) if it exists, else the
|
||||
# debug one -- build one first with ./build-apk.sh.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APK=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--apk) APK="$2"; shift 2 ;;
|
||||
*) echo "run-bench.sh: unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$APK" ]; then
|
||||
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
|
||||
APK=app/build/outputs/apk/release/app-release.apk
|
||||
else
|
||||
APK=app/build/outputs/apk/debug/app-debug.apk
|
||||
fi
|
||||
fi
|
||||
if [ ! -f "$APK" ]; then
|
||||
echo "run-bench.sh: no APK at $APK -- run ./build-apk.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERIAL=$(emu serial)
|
||||
PKG=$(aapt2 dump badging "$APK" 2>/dev/null | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||
if [ -z "$PKG" ]; then
|
||||
BUILD_TOOLS=$(ls -d "$HOME"/Android/Sdk/build-tools/*/ | sort -V | tail -1)
|
||||
PKG=$("${BUILD_TOOLS}aapt2" dump badging "$APK" | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p")
|
||||
fi
|
||||
|
||||
echo "run-bench.sh: installing $APK ($PKG) on $SERIAL"
|
||||
adb -s "$SERIAL" install -r "$APK" >/dev/null
|
||||
adb -s "$SERIAL" shell am force-stop "$PKG"
|
||||
adb -s "$SERIAL" logcat -c
|
||||
adb -s "$SERIAL" shell am start -n "$PKG/dev.iris.android.demo.MainActivity" >/dev/null
|
||||
|
||||
ui-trace record -s "$SERIAL" -d 3000 --do "tap 'Run benchmark'" -o /tmp/run-bench-tap.txt >/dev/null
|
||||
|
||||
# Poll for the report line rather than a fixed sleep -- the run itself is
|
||||
# a fixed script (RUST.md's "Benchmark v2": 16 flings, a 20s streaming
|
||||
# phase, ~61s of typing, 10s of keyboard toggles, roughly 2.5 minutes end
|
||||
# to end) but device speed varies. 260s cap rather than v1's 90s -- v2 is
|
||||
# a longer script than v1's swipe-loop-only run.
|
||||
# The report's own first line, not the bare "iris bench report:" prefix:
|
||||
# `copy_report` logs that prefix too ("nothing to copy -- run the benchmark
|
||||
# first", which the app emits at startup), so polling for the prefix
|
||||
# returned instantly and the script printed a report that was never run.
|
||||
REPORT_LINE="iris bench report: iris bench report"
|
||||
i=0
|
||||
while [ "$i" -lt 260 ]; do
|
||||
LINE=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null | grep "$REPORT_LINE" || true)
|
||||
if [ -n "$LINE" ]; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$LINE" ]; then
|
||||
echo "run-bench.sh: no report after 260s -- check logcat by hand" >&2
|
||||
exit 1
|
||||
fi
|
||||
# -A 60 rather than v1's -A 6 -- v2's report has a per-phase block (four
|
||||
# phases, four lines each) on top of the frames/bench sections v1 had.
|
||||
adb -s "$SERIAL" logcat -d -s iris-android-app:I | grep -A 60 "$REPORT_LINE"
|
||||
@@ -0,0 +1,15 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "iris-android-demo"
|
||||
include(":app")
|
||||
@@ -0,0 +1,999 @@
|
||||
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"):
|
||||
//! the same fixture, scroll loop and streaming phase the Compose `bench`
|
||||
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
|
||||
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||
//! that measures the renderer rather than the data or the network.
|
||||
//!
|
||||
//! **Reuses `transcript_client.rs`'s shape** (folded items, the same
|
||||
//! `TranscriptScreen::apply` incremental update on every event) with the
|
||||
//! network half replaced by the checked-in fixture. Reading that fixture
|
||||
//! and folding it into a screen is **`transcript-fixture`'s** job, not
|
||||
//! this file's -- the same crate the headless harness and the
|
||||
//! phone-shaped desktop window open, so all three measure one screen
|
||||
//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is
|
||||
//! replayed one at a time through `fold_event` -- the same fold path a
|
||||
//! live SSE reply arrives on -- by the "Run benchmark" control below.
|
||||
//! Streaming through `apply` rather than a full rebuild per event is what
|
||||
//! this file exists to measure -- see docs/RUST.md's P0 box for the
|
||||
//! before/after report.
|
||||
|
||||
use crate::bench_jni::PlatformHandle;
|
||||
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||
use client_core::transcript_fold::{TranscriptItem, fold_event};
|
||||
use event_model::SeqEvent;
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
use iris::prelude::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients
|
||||
/// implement the identical four phases -- see that box before changing any
|
||||
/// constant here, since a mismatch would make the two reports stop
|
||||
/// measuring the same thing while still looking like they do.
|
||||
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||
const STREAM_SECONDS: u64 = 20;
|
||||
|
||||
/// Kept only so this phase's own label text still reads "scroll: 6 cycles
|
||||
/// (24 swipes, legacy tween)" the way `BenchRun.kt`'s v2 report does --
|
||||
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s own report shows this
|
||||
/// exact line even though the swipe loop it names no longer runs there
|
||||
/// either (the fling phase replaced it); nothing here drives an actual
|
||||
/// swipe with these any more.
|
||||
const LEGACY_CYCLES: usize = 6;
|
||||
|
||||
/// Fling phase (v2): a real fling through `List::fling`, not a tween --
|
||||
/// Iris's ask was that it "travel way faster" than the v1 swipe, and a
|
||||
/// tween can never exceed the distance/time it is given while a real
|
||||
/// fling decays from an initial velocity the way a finger flick does.
|
||||
/// 12,000 px/s matches `BenchRun.kt`'s own constant exactly.
|
||||
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
|
||||
const FLING_COUNT: usize = 8;
|
||||
const FLING_SETTLE_CAP_MS: u64 = 3_000;
|
||||
const FLING_PAUSE_MS: u64 = 300;
|
||||
|
||||
/// Type phase (v2): long, multisyllabic words so the composer actually
|
||||
/// wraps and the transcript above it is pushed upward, typed and deleted
|
||||
/// one character per `TYPE_CHAR_MS`. Exactly `BenchRun.TYPE_TEXT` --
|
||||
/// verified 600 characters by `type_text_is_exactly_600_characters` below.
|
||||
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
|
||||
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
|
||||
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
|
||||
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
|
||||
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
|
||||
wrap across several lines while the transcript above is pushed upward by the growing \
|
||||
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
|
||||
happening now!!!";
|
||||
const TYPE_CHAR_MS: u64 = 50;
|
||||
|
||||
/// Keyboard phase (v2): five show/hide cycles, a second apart, matching
|
||||
/// `BenchRun.kt`'s `KEYBOARD_CYCLES`/`KEYBOARD_SHOW_WAIT_MS`/
|
||||
/// `KEYBOARD_HIDE_WAIT_MS`.
|
||||
const KEYBOARD_CYCLES: usize = 5;
|
||||
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||
|
||||
/// One animation step's target cadence -- close enough to 60Hz that a
|
||||
/// fling/scroll is many small moves rather than one jump, so frames are
|
||||
/// actually rendered along the way, and close enough that a `ctx.update`
|
||||
/// closure's effect (only applied once the next frame callback drains the
|
||||
/// task channel -- `IrisViewPeer::drain_tasks`) is visible again quickly
|
||||
/// when a later step in the same phase needs to read state back.
|
||||
const ANIM_STEP_MS: u64 = 16;
|
||||
|
||||
/// How much of the screen a *filled* benchmark report may take before it
|
||||
/// scrolls instead of growing -- roughly a third of a phone screen, the
|
||||
/// share the pane used to reserve unconditionally. An empty report takes
|
||||
/// nothing at all; see `new`'s comment at the tree it is used in.
|
||||
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
||||
|
||||
pub struct BenchClient {
|
||||
ui_state: AndroidUiState,
|
||||
content: WeakWidget<WidgetPtr>,
|
||||
report_display: WeakWidget<TextEdit>,
|
||||
/// The top button row, in a `WidgetPtr` slot rather than added
|
||||
/// directly (like `content`) so `on_insets_changed` can swap in a
|
||||
/// version padded for the status bar once insets are known -- RUST.md's
|
||||
/// P0 box, "the status-bar inset is not applied," found the row sitting
|
||||
/// directly under it because nothing here read `insets().top` at all.
|
||||
top_bar: WeakWidget<WidgetPtr>,
|
||||
screen: Option<transcript_ui::TranscriptScreen>,
|
||||
items: Vec<TranscriptItem>,
|
||||
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
||||
/// clone, kept here only as the source a second run would need (the
|
||||
/// button can be pressed more than once; `running` just stops overlap,
|
||||
/// not repeat).
|
||||
stream_tail: Vec<SeqEvent>,
|
||||
platform: Option<Arc<PlatformHandle>>,
|
||||
last_report: Option<String>,
|
||||
running: bool,
|
||||
/// The keyboard phase's own confirmation channel -- updated from
|
||||
/// `on_insets_changed` (the platform's own answer for whether the IME
|
||||
/// is actually visible, per `WindowInsets::ime_bottom`), read from the
|
||||
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
|
||||
/// `ctx.update`, since neither side needs the widget tree for this.
|
||||
ime_state: Arc<Mutex<ImeState>>,
|
||||
/// Edge-triggers the keyboard diagnostics capture below -- set on the
|
||||
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
|
||||
/// first where it is not, so opening the keyboard fires this once
|
||||
/// rather than on every insets update while it stays open (a rotation
|
||||
/// or a status-bar change with the keyboard already up would otherwise
|
||||
/// re-fire it).
|
||||
keyboard_was_visible: bool,
|
||||
/// The status-bar inset `top_bar` was last padded by -- see
|
||||
/// `on_insets_changed`'s own comment for why this guards the rebuild.
|
||||
last_top_pad: f32,
|
||||
}
|
||||
|
||||
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
|
||||
/// count real 0->visible / visible->0 transitions `on_insets_changed`
|
||||
/// observed, not merely "a show/hide was requested" -- UI_RULES.md: never
|
||||
/// present an inferred value as a measured one. `run_keyboard_phase` reads
|
||||
/// the counters before and after asking for a toggle and calls it
|
||||
/// confirmed only if the count moved.
|
||||
#[derive(Default)]
|
||||
struct ImeState {
|
||||
visible: bool,
|
||||
shown_events: u32,
|
||||
hidden_events: u32,
|
||||
}
|
||||
|
||||
impl HasAndroidUiState for BenchClient {
|
||||
fn android_state(&self) -> &AndroidUiState {
|
||||
&self.ui_state
|
||||
}
|
||||
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||
&mut self.ui_state
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||
wtext(message.to_string())
|
||||
.color(Color::WHITE)
|
||||
.wrap(true)
|
||||
.pad(16)
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
|
||||
/// the syscall itself fails, which UI_RULES.md's "never present an
|
||||
/// inferred value as a measured one" says to keep apart from a real (and
|
||||
/// here, impossible) zero.
|
||||
fn process_cpu_ms() -> Option<u64> {
|
||||
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
||||
// initialises on success; on failure it is never read.
|
||||
unsafe {
|
||||
let mut usage: libc::rusage = std::mem::zeroed();
|
||||
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
|
||||
return None;
|
||||
}
|
||||
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
|
||||
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
|
||||
Some(user_ms + sys_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
|
||||
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
|
||||
/// two reports' numbers mean the same thing.
|
||||
fn peak_rss_kb() -> Option<u64> {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.ok()?
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("VmHWM:"))
|
||||
.and_then(|rest| rest.trim().strip_suffix("kB"))
|
||||
.and_then(|n| n.trim().parse().ok())
|
||||
}
|
||||
|
||||
fn battery_line(samples: &[i32]) -> String {
|
||||
if samples.is_empty() {
|
||||
return " battery current: unavailable on this device".to_string();
|
||||
}
|
||||
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||
// `min`/`max` are guarded by the `is_empty` check above, three lines
|
||||
// up -- pairing the `Option` unwraps with the emptiness check right
|
||||
// here (rather than two statements apart, with `mean` in between
|
||||
// reading the same slice) is what keeps a future reorder from
|
||||
// separating the guard from what it protects (docs/
|
||||
// REVIEW-2026-09-06.md finding 7).
|
||||
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
|
||||
unreachable!("samples is non-empty, checked above");
|
||||
};
|
||||
format!(
|
||||
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||
samples.len()
|
||||
)
|
||||
}
|
||||
|
||||
impl AndroidAppState for BenchClient {
|
||||
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||
let content = WidgetPtr::new().add(rsc);
|
||||
let loading = placeholder(rsc, "Loading fixture...");
|
||||
content(rsc).set(loading);
|
||||
|
||||
let report_display = wtext("")
|
||||
.editable(EditMode::MultiLine)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.size(14)
|
||||
.color(Color::WHITE)
|
||||
.attr::<Selectable>(())
|
||||
.label("Benchmark report")
|
||||
.add(rsc);
|
||||
|
||||
let top_bar = WidgetPtr::new().add(rsc);
|
||||
let controls = bench_controls(rsc, 0.0);
|
||||
top_bar(rsc).set(controls);
|
||||
// The report pane is sized to whatever report it is holding, not
|
||||
// to a share of the window: `rest(1)` here reserved a third of
|
||||
// the screen for an *empty* `TextEdit` at every launch, which is
|
||||
// what Iris's 2026-09-06 11:39 phone report described as "the app
|
||||
// does not start with keyboard spacing correct" -- the composer
|
||||
// two thirds down with black below it, nothing to do with the IME
|
||||
// inset (measured: `iris insets:` reports bottom=63 ime_bottom=0
|
||||
// at launch, while the `Message` field's own box sat 789px above
|
||||
// the bottom of a 2282px surface -- exactly this pane's third).
|
||||
// Capped and scrollable so a long report cannot take the screen
|
||||
// back over, the same idiom `composer.rs` uses for the field.
|
||||
// Above the transcript, not below it: the report is what the
|
||||
// header's own "Run benchmark" button produces (UI_RULES.md --
|
||||
// results appear where the action was started), and a pane under
|
||||
// the composer would eat the navigation-bar clearance
|
||||
// `set_bottom_inset` gives it.
|
||||
let tree = (
|
||||
top_bar,
|
||||
report_display
|
||||
.pad(dp(8))
|
||||
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
|
||||
content.height(rest(1)),
|
||||
)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
ui_state.set_root(tree);
|
||||
|
||||
// Startup log line (RUST.md's P0 box, "log once at startup ... the
|
||||
// number of font families found, the default family resolved"):
|
||||
// what font discovery actually found on this device, before
|
||||
// anything is drawn.
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
log::info!(
|
||||
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||
bold={:?} italic={:?} mono={:?}",
|
||||
font.families_found,
|
||||
font.default_family,
|
||||
font.default_mono_family,
|
||||
font.regular_resolved,
|
||||
font.bold_resolved,
|
||||
font.italic_resolved,
|
||||
font.mono_resolved,
|
||||
);
|
||||
|
||||
let mut client = Self {
|
||||
ui_state,
|
||||
content,
|
||||
report_display,
|
||||
top_bar,
|
||||
screen: None,
|
||||
items: Vec::new(),
|
||||
stream_tail: Vec::new(),
|
||||
platform: None,
|
||||
last_report: None,
|
||||
running: false,
|
||||
ime_state: Arc::new(Mutex::new(ImeState::default())),
|
||||
keyboard_was_visible: false,
|
||||
last_top_pad: 0.0,
|
||||
};
|
||||
|
||||
match transcript_fixture::build_screen(rsc) {
|
||||
Ok((opened, tree)) => {
|
||||
client.items = opened.items;
|
||||
client.stream_tail = opened.stream_tail;
|
||||
(client.content)(rsc).set(tree);
|
||||
client.screen = Some(opened.screen);
|
||||
}
|
||||
Err(message) => {
|
||||
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||
}
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
|
||||
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
|
||||
}
|
||||
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
|
||||
/// field comment. Rebuilds the row rather than mutating a stored
|
||||
/// `Padding` in place, since nothing here holds a handle to one --
|
||||
/// but **only when `insets.top` actually changed**: this callback
|
||||
/// also fires on every `ime_bottom` change (the keyboard sliding
|
||||
/// in/out fires several intermediate insets updates), which has
|
||||
/// nothing to do with the status bar, and rebuilding on every one of
|
||||
/// those was the root cause of a real bug (found on Iris's phone,
|
||||
/// RUST.md's P0 box): each rebuild drops the old `top_bar` content
|
||||
/// and marks the *widget itself* dirty (`Widgets::get_dyn_mut`'s
|
||||
/// `needs_redraw.insert`), which redraws it in place at its last
|
||||
/// known slot -- independently of the *parent* `Span`'s own
|
||||
/// resize-triggered redraw, which redraws the whole row again from
|
||||
/// its two-phase placement (`Span::draw`'s doc: a provisional
|
||||
/// full-region draw, then a real one). A `.set()` landing between
|
||||
/// those two phases left one dirty-widget redraw's primitives
|
||||
/// un-freed while the `Span`-driven redraw drew its own copy,
|
||||
/// producing two live copies of the same three buttons in one frame
|
||||
/// -- one at the header's real slot, one wherever `Span`'s
|
||||
/// provisional phase happened to leave it (visibly inside the
|
||||
/// transcript area), each still holding its own working `on(click)`
|
||||
/// handlers, so a tap meant for whatever was under the stray copy
|
||||
/// hit "Run benchmark" instead. Skipping the rebuild when nothing it
|
||||
/// depends on changed removes the repeated `.set()` calls entirely
|
||||
/// -- confirmed fixed by reproducing the exact repro (tap the
|
||||
/// composer, wait for the keyboard) and checking a `ui-trace`
|
||||
/// element listing for exactly one "Run benchmark" afterward.
|
||||
///
|
||||
/// Also two things downstream of the same `ime_bottom` transition:
|
||||
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
|
||||
/// doc -- the platform's own answer for whether the IME actually
|
||||
/// opened or closed, rather than assumed from having called
|
||||
/// `show_ime`/`hide_ime`), and **the trigger for the keyboard
|
||||
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
|
||||
/// surface is exactly the case a previous commit found wiped text,
|
||||
/// and Iris needs a way to get a report off the phone even if that
|
||||
/// (or some other keyboard-triggered regression) is still happening
|
||||
/// on the build she is holding -- `capture_keyboard_diagnostics`
|
||||
/// below fires ~500ms after the keyboard becomes visible, once per
|
||||
/// keyboard opening, and shows its report in a plain overlay view
|
||||
/// that draws independently of whatever iris itself is doing.
|
||||
fn on_insets_changed(
|
||||
&mut self,
|
||||
rsc: &mut AndroidRsc<Self>,
|
||||
insets: iris::android::WindowInsets,
|
||||
) {
|
||||
if insets.top != self.last_top_pad {
|
||||
self.last_top_pad = insets.top;
|
||||
let controls = bench_controls(rsc, insets.top);
|
||||
(self.top_bar)(rsc).set(controls);
|
||||
}
|
||||
|
||||
// The composer bar sits directly on whichever of the IME or the
|
||||
// navigation bar is currently the bottom of usable space -- see
|
||||
// `transcript_ui::composer::Composer::set_bottom_inset`'s doc.
|
||||
// `ime_bottom` already exceeds the plain nav-bar inset whenever the
|
||||
// keyboard covers it, so the larger of the two is always the right
|
||||
// answer without needing to know which is currently showing.
|
||||
if let Some(screen) = &self.screen {
|
||||
screen
|
||||
.composer
|
||||
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
|
||||
}
|
||||
|
||||
// The platform's own answer, not `ime_bottom > 0.0` -- see
|
||||
// `iris::android::WindowInsets::ime_bottom`. The height is still
|
||||
// climbing while the keyboard slides in, so a frame or two of a
|
||||
// real opening reads as "closed" when the boolean is inferred from
|
||||
// it, and `shown_events`/`hidden_events` below count transitions.
|
||||
let ime_visible = insets.ime_visible;
|
||||
|
||||
let mut ime = self.ime_state.lock().unwrap();
|
||||
if ime_visible && !ime.visible {
|
||||
ime.shown_events += 1;
|
||||
}
|
||||
if !ime_visible && ime.visible {
|
||||
ime.hidden_events += 1;
|
||||
}
|
||||
ime.visible = ime_visible;
|
||||
drop(ime);
|
||||
|
||||
if ime_visible && !self.keyboard_was_visible {
|
||||
self.keyboard_was_visible = true;
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
state.capture_keyboard_diagnostics(rsc);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
} else if !ime_visible {
|
||||
self.keyboard_was_visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait after the keyboard becomes visible before capturing
|
||||
/// diagnostics -- long enough that the resize, the reported wipe (if it is
|
||||
/// still happening) and a couple of frames have all had time to land, per
|
||||
/// AGENTS.md's "so that operations that finish in milliseconds have states
|
||||
/// on the way that nothing can observe" reasoning applied the other way:
|
||||
/// this wants to observe the state *after* the transition settles, not
|
||||
/// mid-flight.
|
||||
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||
|
||||
type Rsc = AndroidRsc<BenchClient>;
|
||||
|
||||
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
|
||||
/// why it needs one at all. A dark neutral rather than pure black
|
||||
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
|
||||
/// instead of a hole in the background the buttons happen to float in.
|
||||
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
|
||||
|
||||
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
||||
/// `on_insets_changed` has run once) -- folded in here, rather than
|
||||
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
||||
/// naming that builder's type at each call site is more machinery than a
|
||||
/// top-of-screen padding number is worth.
|
||||
///
|
||||
/// **Backed by an opaque rect the full size of the row, not just the three
|
||||
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
|
||||
/// build a9232ac): "the header buttons have nothing behind them and
|
||||
/// overlap the transcript text" -- before this, only each button's own
|
||||
/// `rect(...)` painted anything, so the gaps between and around them (and
|
||||
/// the status-bar strip above them) showed whatever was one layer back
|
||||
/// (`CLEAR_COLOR`, black), and the row's true height was three
|
||||
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
|
||||
/// density-correct size the transcript below was already using post-P0 --
|
||||
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
|
||||
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
|
||||
/// function), and every size below moved from a bare number (physical
|
||||
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||
/// so the row's reserved height in the outer `Span::DOWN`
|
||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.start_benchmark(rsc);
|
||||
},
|
||||
)
|
||||
.label("Run benchmark");
|
||||
let run = (
|
||||
run_rect,
|
||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||
ctx.state.copy_report();
|
||||
},
|
||||
)
|
||||
.label("Copy report");
|
||||
let copy = (
|
||||
copy_rect,
|
||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let diag_rect = rect(Color::rgb(60, 45, 70))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||
ctx.state.show_diagnostics(rsc);
|
||||
},
|
||||
)
|
||||
.label("Diagnostics");
|
||||
let diagnostics = (
|
||||
diag_rect,
|
||||
wtext("Diagnostics").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
|
||||
|
||||
(rect(HEADER_SURFACE), buttons)
|
||||
.stack()
|
||||
.height(dp(56))
|
||||
.pad(Padding::top(top_pad))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
impl BenchClient {
|
||||
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
|
||||
let widget = placeholder(rsc, message);
|
||||
(self.content)(rsc).set(widget);
|
||||
self.screen = None;
|
||||
}
|
||||
|
||||
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||
let (screen, tree) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items));
|
||||
(self.content)(rsc).set(tree);
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
|
||||
/// RUST.md's P0 box: "a named `Diagnostics` control ... with 'copy this
|
||||
/// and send it to Iris'." Fills `report_display` (the same TextEdit the
|
||||
/// benchmark report uses) rather than a separate widget, so the
|
||||
/// existing "Copy report" button and clipboard path work on whichever
|
||||
/// text is currently shown -- `last_report` is what `copy_report` reads,
|
||||
/// so it's set here too rather than adding a second copy path.
|
||||
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let report = self.diagnostics_text(rsc);
|
||||
self.report_display.edit(rsc).set(&report);
|
||||
self.last_report = Some(report);
|
||||
}
|
||||
|
||||
/// The diagnostics report as text, with no side effect on what is on
|
||||
/// screen -- shared by the `Diagnostics` button (which shows it) and
|
||||
/// the keyboard-open capture (which only logs it), so the two can
|
||||
/// never drift into reporting different things.
|
||||
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
|
||||
let font = rsc.ui.text.font_diagnostics();
|
||||
let frame_report = match self.android_state().frame_report.report() {
|
||||
Some(stats) => format!("{stats}"),
|
||||
None => "no frames recorded yet".to_string(),
|
||||
};
|
||||
let renderer = match &self.android_state().renderer {
|
||||
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
||||
};
|
||||
// The insets line goes in the pane, not just the log: Iris has no
|
||||
// logcat on her phone, and "the keyboard does not push the
|
||||
// composer up" cannot be told from "the listener never fired"
|
||||
// without it (`AndroidUiState::insets_report`).
|
||||
format!("{renderer}\n{}", self.android_state().insets_report())
|
||||
}
|
||||
|
||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||
/// doc comment. **Logged only.** It used to also copy the report to
|
||||
/// the clipboard unprompted and put it in the shell's overlay view,
|
||||
/// from when the keyboard-inset callback was not firing at all and a
|
||||
/// report could not be got off the phone any other way. Both are gone
|
||||
/// as of 2026-09-06: the callback fires reliably now (edge-to-edge,
|
||||
/// `MainActivity.java`), and the overlay covered the whole screen on
|
||||
/// *every* keyboard open with its own Copy/Close buttons underneath
|
||||
/// the keyboard, so it could not be dismissed -- an interruption for
|
||||
/// something nobody asked for, over an app you are trying to type
|
||||
/// into (UI_RULES.md). The named `Diagnostics` button still shows the
|
||||
/// same text on demand, and `iris surface:`/`iris insets:` (view.rs)
|
||||
/// carry the lifecycle a `logcat` pull actually needs.
|
||||
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
let report = self.diagnostics_text(rsc);
|
||||
log::info!("iris keyboard diagnostics:\n{report}");
|
||||
}
|
||||
|
||||
fn copy_report(&mut self) {
|
||||
let Some(report) = &self.last_report else {
|
||||
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
||||
return;
|
||||
};
|
||||
let Some(platform) = &self.platform else {
|
||||
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||
return;
|
||||
};
|
||||
if platform.copy_to_clipboard("iris bench report", report) {
|
||||
log::info!("iris bench report: copied to clipboard");
|
||||
} else {
|
||||
log::info!("iris bench report: clipboard copy failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// RUST.md's "Benchmark v2": fling, then stream (unchanged from v1),
|
||||
/// then type, then keyboard, then the report -- run in-process for the
|
||||
/// same reason `BenchRun.kt`'s own doc gives (no usable system tracing
|
||||
/// on a real phone, no agent that can drive one).
|
||||
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||
if self.running {
|
||||
log::info!("iris bench report: already running");
|
||||
return;
|
||||
}
|
||||
self.running = true;
|
||||
self.android_state_mut().frame_report.reset();
|
||||
self.report_display.edit(rsc).set("Running benchmark...");
|
||||
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
let platform = self.platform.clone();
|
||||
let stream_tail = self.stream_tail.clone();
|
||||
let ime_state = self.ime_state.clone();
|
||||
let refresh_hz = platform
|
||||
.as_ref()
|
||||
.and_then(|p| p.refresh_rate_hz())
|
||||
.unwrap_or(60.0);
|
||||
let cpu_start = process_cpu_ms();
|
||||
let run_started_at = Instant::now();
|
||||
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
// The battery sampler runs for the whole run, once a second,
|
||||
// the same cadence `BatterySampler` uses on the Compose side
|
||||
// -- via its own JNI-attached thread, not `ctx.update`, since
|
||||
// a sample needs no widget-tree access.
|
||||
let sampler_done = Arc::new(AtomicBool::new(false));
|
||||
let samples = Arc::new(Mutex::new(Vec::<i32>::new()));
|
||||
let sampler = platform.clone().map(|platform| {
|
||||
let done = sampler_done.clone();
|
||||
let samples = samples.clone();
|
||||
tokio::spawn(async move {
|
||||
while !done.load(Ordering::Relaxed) {
|
||||
if let Some(value) = platform.battery_current_ua() {
|
||||
samples.lock().unwrap().push(value);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let travel = run_fling_phase(&mut ctx, &redraw).await;
|
||||
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
|
||||
run_type_phase(&mut ctx, &redraw, &platform).await;
|
||||
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).await;
|
||||
|
||||
sampler_done.store(true, Ordering::Relaxed);
|
||||
if let Some(sampler) = sampler {
|
||||
let _ = sampler.await;
|
||||
}
|
||||
let battery = battery_line(&samples.lock().unwrap());
|
||||
let cpu_line = match (cpu_start, process_cpu_ms()) {
|
||||
(Some(start), Some(end)) => {
|
||||
format!(
|
||||
" process CPU time over this run: {}ms",
|
||||
end.saturating_sub(start)
|
||||
)
|
||||
}
|
||||
_ => " process CPU time over this run: unavailable".to_string(),
|
||||
};
|
||||
let rss_line = match peak_rss_kb() {
|
||||
Some(kb) => format!(" peak RSS: {kb}kB"),
|
||||
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
|
||||
};
|
||||
let total_seconds = run_started_at.elapsed().as_secs_f64();
|
||||
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
state.running = false;
|
||||
let now = Instant::now();
|
||||
let phase_lines: String = state
|
||||
.android_state()
|
||||
.frame_report
|
||||
.phase_stats(now, refresh_hz)
|
||||
.iter()
|
||||
.map(|p| format!("{p}\n"))
|
||||
.collect();
|
||||
let per_phase = if phase_lines.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("per phase:\n{phase_lines}\n")
|
||||
};
|
||||
let frames_block = match state.android_state().frame_report.report() {
|
||||
Some(stats) => {
|
||||
let (late, late_pct) =
|
||||
state.android_state().frame_report.late_at_hz(refresh_hz);
|
||||
format!(
|
||||
"frames:\n {} frames over {:.1}s at {:.0}Hz ({:.1}ms budget)\n \
|
||||
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
|
||||
p99 {:.1}ms\n worst {:.1}ms\n cpu_p50 {:.1}ms gpu_wait_p50 {:.1}ms",
|
||||
stats.total_frames,
|
||||
total_seconds,
|
||||
refresh_hz,
|
||||
1000.0 / refresh_hz as f64,
|
||||
stats.p50.as_secs_f64() * 1000.0,
|
||||
stats.p90.as_secs_f64() * 1000.0,
|
||||
stats.p99.as_secs_f64() * 1000.0,
|
||||
stats.worst.as_secs_f64() * 1000.0,
|
||||
stats.cpu_p50.as_secs_f64() * 1000.0,
|
||||
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
|
||||
)
|
||||
}
|
||||
None => "frames:\n no frames recorded".to_string(),
|
||||
};
|
||||
let scroll_line = format!(
|
||||
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
|
||||
{sent}/{total} fixture events",
|
||||
LEGACY_CYCLES * 4
|
||||
);
|
||||
let fling_line = format!(
|
||||
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
|
||||
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
|
||||
);
|
||||
let type_line = format!(
|
||||
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
|
||||
TYPE_TEXT.chars().count()
|
||||
);
|
||||
let report = format!(
|
||||
"iris bench report\n{per_phase}{frames_block}\n\nbench:\n{fling_line}\n\
|
||||
{scroll_line}\n{type_line}\n{keyboard}\n{cpu_line}\n{rss_line}\n{battery}"
|
||||
);
|
||||
log::info!("iris bench report: {report}");
|
||||
state.report_display.edit(rsc).set(&report);
|
||||
state.last_report = Some(report);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
|
||||
/// same `ctx.update` every other mutation here goes through) and returns
|
||||
/// its result to the caller's async task -- `ctx.update` alone has no way
|
||||
/// to hand a value back, since the closure only actually runs once the
|
||||
/// next frame callback drains `IrisViewPeer`'s task channel
|
||||
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
|
||||
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
|
||||
/// nothing drains it until something schedules the frame callback that
|
||||
/// calls `drain_tasks`, and a caller relying on some *earlier*,
|
||||
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
|
||||
/// deadlocks the moment that earlier callback has already fired and
|
||||
/// drained everything queued before this call existed. Cost a real hang
|
||||
/// in this file's first version of the fling phase: every loop iteration
|
||||
/// after the first sat forever with nothing scheduled to drain it.
|
||||
/// Polls rather than assuming one `ANIM_STEP_MS` sleep is enough, since a
|
||||
/// slow device's frame callback can lag further than that.
|
||||
async fn read_from_state<T, F>(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
f: F,
|
||||
) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let _ = tx.send(f(state, rsc));
|
||||
});
|
||||
redraw.request_redraw();
|
||||
loop {
|
||||
if let Ok(value) = rx.try_recv() {
|
||||
return value;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1: starting pinned at the newest end, `FLING_COUNT` flings away
|
||||
/// from it (toward older messages) through `List::fling`, then
|
||||
/// `FLING_COUNT` back. Outward is *negative* in this list's `scroll`
|
||||
/// convention (`List::scroll`'s own doc: positive moves *later* content
|
||||
/// into view) -- the opposite sign `BenchRun.kt`'s `runFlingPhase` uses,
|
||||
/// since `TranscriptList`'s `LazyColumn` and this list define "positive"
|
||||
/// the other way around; the two apps' *travel* is still directly
|
||||
/// comparable because both report it as a row index + pixel offset, not a
|
||||
/// signed distance.
|
||||
async fn run_fling_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) -> String {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("fling");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
|
||||
// `anchor = None` into a real slot before `start` is read.
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS * 2)).await;
|
||||
let start = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
wait_for_fling_settle(ctx, redraw).await;
|
||||
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||
}
|
||||
let outward = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
for _ in 0..FLING_COUNT {
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
wait_for_fling_settle(ctx, redraw).await;
|
||||
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
|
||||
}
|
||||
let end = read_anchor_position(ctx, redraw).await;
|
||||
|
||||
format!("start={start} outward={outward} end={end}")
|
||||
}
|
||||
|
||||
async fn read_anchor_position(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) -> String {
|
||||
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||
Some(screen) => (screen.list)(rsc).anchor_position_display(),
|
||||
None => "idx=none".to_string(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ticks the fling forward in ~60Hz steps (the same shape
|
||||
/// `run_stream_phase`'s per-event loop and the old `animate_scroll` used)
|
||||
/// until it settles or `FLING_SETTLE_CAP_MS` passes -- belt-and-suspenders
|
||||
/// the same way `BenchRun.kt`'s own `waitForSettle` is, since a fling's
|
||||
/// own spline-decided `duration()` already caps how long it can run.
|
||||
async fn wait_for_fling_settle(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
) {
|
||||
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < cap {
|
||||
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
|
||||
Some(screen) => (screen.list)(rsc).tick_fling(Instant::now()),
|
||||
None => false,
|
||||
})
|
||||
.await;
|
||||
if !still_scrolling {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 2, unchanged from v1: pinned to the newest end before streaming
|
||||
/// starts (matching `stream-bench.sh`'s "Jump to latest" tap), then
|
||||
/// `STREAM_EVENTS_PER_SEC * STREAM_SECONDS` fixture events replayed
|
||||
/// through the real `fold_event`/`TranscriptScreen::apply` path. Returns
|
||||
/// `(sent, total)`.
|
||||
async fn run_stream_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
stream_tail: Vec<SeqEvent>,
|
||||
) -> (usize, usize) {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("stream");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
|
||||
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||
let mut sent = 0usize;
|
||||
for event in stream_tail.into_iter().take(total) {
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
let old_items = state.items.clone();
|
||||
state.items = fold_event(&state.items, &event);
|
||||
match &state.screen {
|
||||
Some(screen) => screen.apply(rsc, &old_items, &state.items),
|
||||
None => state.rebuild_transcript(rsc),
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
sent += 1;
|
||||
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||
}
|
||||
// Lets the last few deltas land and draw before the next phase starts
|
||||
// -- `BenchRun.kt`'s own closing delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
(sent, total)
|
||||
}
|
||||
|
||||
/// Phase 3: focuses the real composer, shows the keyboard, then types
|
||||
/// `TYPE_TEXT` one character at a time through the composer `TextEdit`'s
|
||||
/// real edit path (`set`, the same call a real keystroke's `onValueChange`
|
||||
/// makes -- `Composer::build_composer`'s `field`), and deletes it the same
|
||||
/// way.
|
||||
async fn run_type_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
redraw: &Arc<dyn RequestRedraw>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
) {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state.android_state_mut().frame_report.mark_phase("type");
|
||||
});
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
(screen.list)(rsc).jump_to_end();
|
||||
state.set_focus(Some(screen.composer.field));
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
// Lets focus and the keyboard's opening animation land before typing
|
||||
// starts, so the frames this phase records are the wrap/reflow it is
|
||||
// measuring, not the keyboard opening -- `BenchRun.kt`'s own delay.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let mut typed = String::new();
|
||||
for ch in TYPE_TEXT.chars() {
|
||||
typed.push(ch);
|
||||
let text = typed.clone();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
while !typed.is_empty() {
|
||||
typed.pop();
|
||||
let text = typed.clone();
|
||||
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||
if let Some(screen) = &state.screen {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 4: `KEYBOARD_CYCLES` show/hide cycles through the shell's own
|
||||
/// `InputMethodManager` (`bench_jni.rs`'s `show_ime`/`hide_ime`), each
|
||||
/// confirmed by `on_insets_changed`'s real `ime_bottom` transition rather
|
||||
/// than assumed from the JNI call having returned -- `ImeState`'s doc.
|
||||
/// "keyboard: could not be shown" if the platform never confirms it even
|
||||
/// once, per UI_RULES.md ("design the unknown/failed state before the
|
||||
/// answer's").
|
||||
async fn run_keyboard_phase(
|
||||
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||
platform: &Option<Arc<PlatformHandle>>,
|
||||
ime_state: &Arc<Mutex<ImeState>>,
|
||||
) -> String {
|
||||
ctx.update(|state: &mut BenchClient, _rsc| {
|
||||
state
|
||||
.android_state_mut()
|
||||
.frame_report
|
||||
.mark_phase("keyboard");
|
||||
});
|
||||
let mut shown = 0;
|
||||
let mut hidden = 0;
|
||||
for _ in 0..KEYBOARD_CYCLES {
|
||||
let before_shown = ime_state.lock().unwrap().shown_events;
|
||||
if let Some(p) = platform {
|
||||
p.show_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||
if ime_state.lock().unwrap().shown_events > before_shown {
|
||||
shown += 1;
|
||||
}
|
||||
|
||||
let before_hidden = ime_state.lock().unwrap().hidden_events;
|
||||
if let Some(p) = platform {
|
||||
p.hide_ime();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
|
||||
if ime_state.lock().unwrap().hidden_events > before_hidden {
|
||||
hidden += 1;
|
||||
}
|
||||
}
|
||||
if shown == 0 {
|
||||
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
|
||||
} else {
|
||||
format!(
|
||||
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
|
||||
(confirmed via on_insets_changed)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TYPE_TEXT;
|
||||
|
||||
/// `BenchRun.kt`'s own `TYPE_TEXT` is verified `.length == 600`; this
|
||||
/// is the same string, so it has to match exactly or the two apps'
|
||||
/// type phases stop typing the same content -- RUST.md's "Benchmark
|
||||
/// v2" spec is one shared string for both.
|
||||
#[test]
|
||||
fn type_text_is_exactly_600_characters() {
|
||||
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! JNI calls the `bench` feature needs that go through the shell's own
|
||||
//! Java side rather than anything `iris`/`android-view` already wraps:
|
||||
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
|
||||
//! per-second battery sample, `ClipboardManager.setPrimaryClip` for the
|
||||
//! "Copy report" control (P0's iris half, docs/RUST.md), and -- added for
|
||||
//! RUST.md's "Benchmark v2" -- `Display.getRefreshRate()` for the phase
|
||||
//! report's real late-frame budget and `InputMethodManager.
|
||||
//! showSoftInput`/`hideSoftInputFromWindow` for the keyboard phase. None
|
||||
//! of these are part of `android_view::context`'s own `Context`/
|
||||
//! `Resources` wrappers (that file's own `// TODO: more methods?`), so
|
||||
//! this calls them directly rather than growing that crate's wrapper for
|
||||
//! calls this crate alone needs.
|
||||
//!
|
||||
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
|
||||
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
|
||||
//! whichever thread calls it -- the battery sampler runs on a background
|
||||
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
|
||||
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
|
||||
//! already attached (the `jni` crate detects it and does not double
|
||||
//! attach), so no caller here needs to know or care which thread it is.
|
||||
|
||||
use android_view::jni::{
|
||||
JNIEnv, JavaVM,
|
||||
objects::{GlobalRef, JObject, JValue},
|
||||
};
|
||||
|
||||
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
|
||||
/// as a constant anywhere reachable without the Android SDK jar, so named
|
||||
/// here with its source rather than left as a bare `2`.
|
||||
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
|
||||
|
||||
pub struct PlatformHandle {
|
||||
vm: JavaVM,
|
||||
view: GlobalRef,
|
||||
}
|
||||
|
||||
impl PlatformHandle {
|
||||
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
|
||||
Self { vm, view }
|
||||
}
|
||||
|
||||
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
|
||||
env.call_method(
|
||||
self.view.as_obj(),
|
||||
"getContext",
|
||||
"()Landroid/content/Context;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn system_service<'e>(
|
||||
&self,
|
||||
env: &mut JNIEnv<'e>,
|
||||
context: &JObject<'e>,
|
||||
name: &str,
|
||||
) -> Option<JObject<'e>> {
|
||||
let jname = env.new_string(name).ok()?;
|
||||
env.call_method(
|
||||
context,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[JValue::Object(jname.as_ref())],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
|
||||
/// on any JNI failure, on a device with no `BatteryManager` service,
|
||||
/// or when the platform itself answers "not supported" -- `0` or
|
||||
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
|
||||
/// both would read as a real (and wrong) measurement if folded into an
|
||||
/// average rather than named apart. UI_RULES.md: never present an
|
||||
/// inferred value as a measured one.
|
||||
pub fn battery_current_ua(&self) -> Option<i32> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let context = self.context(env)?;
|
||||
let battery_manager = self.system_service(env, &context, "batterymanager")?;
|
||||
let value = env
|
||||
.call_method(
|
||||
&battery_manager,
|
||||
"getIntProperty",
|
||||
"(I)I",
|
||||
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
|
||||
)
|
||||
.ok()?
|
||||
.i()
|
||||
.ok()?;
|
||||
if value == 0 || value == i32::MIN {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts `text` on the system clipboard through `ClipboardManager` --
|
||||
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
|
||||
/// `setPrimaryClip`) succeeded.
|
||||
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
|
||||
self.try_copy_to_clipboard(label, text).is_some()
|
||||
}
|
||||
|
||||
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let context = self.context(env)?;
|
||||
let clipboard = self.system_service(env, &context, "clipboard")?;
|
||||
let jlabel = env.new_string(label).ok()?;
|
||||
let jtext = env.new_string(text).ok()?;
|
||||
let clip = env
|
||||
.call_static_method(
|
||||
"android/content/ClipData",
|
||||
"newPlainText",
|
||||
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
|
||||
&[
|
||||
JValue::Object(jlabel.as_ref()),
|
||||
JValue::Object(jtext.as_ref()),
|
||||
],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
env.call_method(
|
||||
&clipboard,
|
||||
"setPrimaryClip",
|
||||
"(Landroid/content/ClipData;)V",
|
||||
&[JValue::Object(&clip)],
|
||||
)
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The display's own refresh rate in Hz (`View::getDisplay()` ->
|
||||
/// `Display::getRefreshRate()`), for RUST.md's "Benchmark v2": late
|
||||
/// frames are judged against *this* device's real budget, not an
|
||||
/// assumed 60Hz -- a 90Hz or 120Hz phone would otherwise call frames
|
||||
/// "late" that met their own faster deadline. `None` if the view is
|
||||
/// not yet attached to a window (`getDisplay` returns `null`) or the
|
||||
/// platform reports a non-positive rate, which is not a real answer
|
||||
/// either.
|
||||
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let display = env
|
||||
.call_method(
|
||||
self.view.as_obj(),
|
||||
"getDisplay",
|
||||
"()Landroid/view/Display;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
if display.is_null() {
|
||||
return None;
|
||||
}
|
||||
let rate = env
|
||||
.call_method(&display, "getRefreshRate", "()F", &[])
|
||||
.ok()?
|
||||
.f()
|
||||
.ok()?;
|
||||
if rate > 0.0 { Some(rate) } else { None }
|
||||
}
|
||||
|
||||
/// `InputMethodManager.showSoftInput(view, 0)` -- the keyboard phase's
|
||||
/// own show, called directly rather than through the focus-driven
|
||||
/// `pending_show_keyboard` path `android/view.rs` uses for a real tap,
|
||||
/// since RUST.md's "Benchmark v2" spec asks for this "through the
|
||||
/// shell's InputMethodManager" independent of focus state. `true` only
|
||||
/// if the platform itself reports the request succeeded -- whether the
|
||||
/// IME actually became visible is confirmed separately, from
|
||||
/// `on_insets_changed`, per UI_RULES.md ("never present an inferred
|
||||
/// value as a measured one").
|
||||
pub fn show_ime(&self) -> bool {
|
||||
self.try_toggle_ime(true).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `InputMethodManager.hideSoftInputFromWindow(windowToken, 0)`.
|
||||
pub fn hide_ime(&self) -> bool {
|
||||
self.try_toggle_ime(false).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let context = self.context(env)?;
|
||||
let imm = self.system_service(env, &context, "input_method")?;
|
||||
if show {
|
||||
env.call_method(
|
||||
&imm,
|
||||
"showSoftInput",
|
||||
"(Landroid/view/View;I)Z",
|
||||
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
|
||||
)
|
||||
.ok()?
|
||||
.z()
|
||||
.ok()
|
||||
} else {
|
||||
let token = env
|
||||
.call_method(
|
||||
self.view.as_obj(),
|
||||
"getWindowToken",
|
||||
"()Landroid/os/IBinder;",
|
||||
&[],
|
||||
)
|
||||
.ok()?
|
||||
.l()
|
||||
.ok()?;
|
||||
env.call_method(
|
||||
&imm,
|
||||
"hideSoftInputFromWindow",
|
||||
"(Landroid/os/IBinder;I)Z",
|
||||
&[JValue::Object(&token), JValue::Int(0)],
|
||||
)
|
||||
.ok()?
|
||||
.z()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `report` in the shell's plain-view diagnostics overlay
|
||||
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
|
||||
/// and Close controls, added over whatever iris itself is drawing
|
||||
/// rather than replacing it (unlike `android::view::show_renderer_error`,
|
||||
/// which exists for the case the renderer can never recover from and
|
||||
/// intentionally never returns). Called from a background task after
|
||||
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
|
||||
/// so the Java side hops onto the UI thread itself before touching the
|
||||
/// view tree -- see that method's own comment.
|
||||
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
|
||||
self.try_show_diagnostics_overlay(report).is_some()
|
||||
}
|
||||
|
||||
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let jreport = env.new_string(report).ok()?;
|
||||
env.call_method(
|
||||
self.view.as_obj(),
|
||||
"showDiagnosticsOverlay",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(jreport.as_ref())],
|
||||
)
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The android-view demo app: by default, iris's `tabs` widget tree
|
||||
//! (`tabs_ui::build`, shared with the winit example) running through
|
||||
//! `iris::android`'s `ViewPeer`. This is RUST.md's I2 pass condition made
|
||||
//! concrete -- there is no UI here beyond what `tabs-ui` already draws.
|
||||
//!
|
||||
//! `JNI_OnLoad` and `new_view_peer` mirror android-view's own demo
|
||||
//! (`~/src/android-view/demo/src/lib.rs`): the only android-view-specific
|
||||
//! plumbing a real app needs is registering its `View` subclass and
|
||||
//! wrapping `iris::android::new_peer`'s generic function in a concrete
|
||||
//! `extern "system" fn`, since `register_view_class` wants a plain
|
||||
//! function pointer.
|
||||
//!
|
||||
//! **`transcript-screen` feature (RUST.md's I5 Android integration):** with
|
||||
//! `--features transcript-screen`, `new_view_peer` instantiates
|
||||
//! `transcript_client::TranscriptClient` instead of the tabs `Client`
|
||||
//! below, against a real `ai-server` (see that module's doc). Chosen over a
|
||||
//! third shell crate: this one already has the Gradle project, the
|
||||
//! `IrisView`/`MainActivity` Java, and the JNI registration I2 built and
|
||||
//! measured against, and the only thing a transcript screen needs on top
|
||||
//! is a different `AndroidAppState` -- the same axis `tabs_ui::build` vs.
|
||||
//! `transcript_ui::build` already varies along on the winit side (compare
|
||||
//! `iris/examples/tabs.rs` and `iris/transcript-ui/examples/transcript.rs`).
|
||||
//! A build picks one screen or the other, never both, so `Client` and
|
||||
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
|
||||
//! there is no in-app navigation to switch *to* on either side yet.
|
||||
//!
|
||||
//! **`bench` feature (P0's iris half, docs/RUST.md):** a third
|
||||
//! `AndroidAppState`, `bench_client::BenchClient`, on the same axis --
|
||||
//! `transcript_ui::build_tree` again, this time against the checked-in
|
||||
//! fixture (`app/bench-fixture/assets/transcript.jsonl`) instead of a real
|
||||
//! server, with a "Run benchmark" control that drives the same scroll loop
|
||||
//! and streaming phase the Compose `bench` build type's `BenchRun.kt`
|
||||
//! does. `bench` depends on `transcript-screen` (Cargo.toml) for
|
||||
//! `transcript-ui`/`client-core`/`event-model`, so both features end up
|
||||
//! enabled together -- `ActiveClient` below gives `bench` priority in that
|
||||
//! case, the same way `transcript-screen` already takes priority over the
|
||||
//! default `tabs-screen`.
|
||||
|
||||
use android_view::{
|
||||
Context, View,
|
||||
jni::{
|
||||
JNIEnv, JavaVM,
|
||||
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
||||
},
|
||||
register_view_class,
|
||||
};
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
use iris::prelude::*;
|
||||
use log::LevelFilter;
|
||||
use std::ffi::c_void;
|
||||
|
||||
#[cfg(feature = "bench")]
|
||||
mod bench_client;
|
||||
#[cfg(feature = "bench")]
|
||||
mod bench_jni;
|
||||
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||
mod transcript_client;
|
||||
|
||||
/// The app's `View` subclass, matching the Java side's package --
|
||||
/// `app/src/main/java/dev/iris/android/demo/IrisView.java`.
|
||||
const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView";
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
pub struct Client {
|
||||
ui_state: AndroidUiState,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
impl HasAndroidUiState for Client {
|
||||
fn android_state(&self) -> &AndroidUiState {
|
||||
&self.ui_state
|
||||
}
|
||||
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||
&mut self.ui_state
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
impl AndroidAppState for Client {
|
||||
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||
// `widgets.info` is the winit example's frame-debug readout, kept
|
||||
// current from `DefaultAppState::window_event` -- android-view has
|
||||
// no per-frame hook to drive the equivalent from here yet, so it
|
||||
// is left at its built "" text rather than wired to nothing.
|
||||
let _ = tabs_ui::build(rsc, &mut ui_state);
|
||||
Self { ui_state }
|
||||
}
|
||||
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||
// Nothing in the tabs example has a back stack of its own to pop --
|
||||
// declining lets the activity finish, which is the same "no
|
||||
// handler" behaviour the default impl gives. Present as an
|
||||
// explicit override (rather than relying on the default) so a
|
||||
// reader checking "does the back gesture reach this app" finds an
|
||||
// answer here rather than nothing.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
type ActiveClient = Client;
|
||||
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||
type ActiveClient = transcript_client::TranscriptClient;
|
||||
#[cfg(feature = "bench")]
|
||||
type ActiveClient = bench_client::BenchClient;
|
||||
|
||||
extern "system" fn new_view_peer<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
view: View<'local>,
|
||||
context: Context<'local>,
|
||||
) -> jlong {
|
||||
iris::android::new_peer::<ActiveClient>(env, view, context)
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// Interacting with JNI at load time is always unsafe at some level --
|
||||
/// mirrors android-view's own demo, which carries the same comment.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(LevelFilter::Debug)
|
||||
.with_tag("iris-android-app"),
|
||||
);
|
||||
let vm = unsafe { JavaVM::from_raw(vm) }.unwrap();
|
||||
let mut env = vm.get_env().unwrap();
|
||||
register_view_class(&mut env, VIEW_CLASS, new_view_peer);
|
||||
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
||||
JNI_VERSION_1_6
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! RUST.md's I5 Android integration: `transcript-ui`'s screen filling the
|
||||
//! whole window on android-view, against a real `ai-server` through
|
||||
//! `client-core` -- the missing half `iris-android-app` (I2) only had for
|
||||
//! `tabs-ui` until now. Behind the `transcript-screen` Cargo feature so the
|
||||
//! plain build (`cargo ndk build`, no `--features`) stays exactly the tabs
|
||||
//! demo I2/I4 already measured against.
|
||||
//!
|
||||
//! **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.
|
||||
//!
|
||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||
//! `client_core::transcript_fold`, a `generation` counter guarding against
|
||||
//! a stale background response. What differs is only the redraw
|
||||
//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses
|
||||
//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to
|
||||
//! request a frame after each `TaskCtx::update` instead of relying on
|
||||
//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own
|
||||
//! doc for why.
|
||||
//!
|
||||
//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate
|
||||
//! showed why it mattered -- 20 events/second means 20 rebuilds/second of
|
||||
//! a ~3,200-row transcript otherwise): `apply_event` calls
|
||||
//! `transcript_ui::TranscriptScreen::apply` with the item list before and
|
||||
//! after `fold_event`, which updates only the row(s) that actually
|
||||
//! changed (almost always just the one open assistant message) instead of
|
||||
//! refolding and rebuilding every row. `rebuild_transcript` still runs
|
||||
//! the whole widget tree once, for the opening page and for `apply`'s own
|
||||
//! rare regroup fallback.
|
||||
|
||||
use client_core::api::{ApiClient, UreqTransport};
|
||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
||||
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
||||
use event_model::SeqEvent;
|
||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||
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
|
||||
/// [`frame_report_controls`] bar, which is built once (`new`, below)
|
||||
/// and never touched by `show_message`/`rebuild_transcript`'s own
|
||||
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
|
||||
/// by rebuilding the session list beside it.
|
||||
content: WeakWidget<WidgetPtr>,
|
||||
screen: Option<transcript_ui::TranscriptScreen>,
|
||||
/// The folded transcript as of the last rebuild -- kept here (not
|
||||
/// re-derived) for the same reason `desktop-app`'s `Client::items`
|
||||
/// exists: a live `StreamEvent` only carries one new wire event, and
|
||||
/// `fold_event` needs everything folded so far to fold it in.
|
||||
items: Vec<TranscriptItem>,
|
||||
/// The session currently open -- `None` only before the first fetch
|
||||
/// resolves. Read back by `apply_event`'s rebuild, which has no session
|
||||
/// id of its own (a live `SeqEvent` doesn't carry one).
|
||||
session_id: Option<String>,
|
||||
/// Bumped every time a new session load starts; a background response
|
||||
/// checks it before touching state, so a slow reply for a session this
|
||||
/// screen has moved on from can't overwrite what replaced it. There is
|
||||
/// only ever one session here (no list to switch away to), but the
|
||||
/// guard still matters for the *first* fetch racing a `stop`/`start`.
|
||||
generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl HasAndroidUiState for TranscriptClient {
|
||||
fn android_state(&self) -> &AndroidUiState {
|
||||
&self.ui_state
|
||||
}
|
||||
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||
&mut self.ui_state
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
fn build_transport() -> Result<UreqTransport, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||
wtext(message.to_string())
|
||||
.color(Color::WHITE)
|
||||
.wrap(true)
|
||||
.pad(16)
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
/// The two named controls RUST.md's I5 box ("Measurements taken" (b))
|
||||
/// drives by name over `ui-trace`, e.g. `ui-trace record --do "tap 'Frame
|
||||
/// report'"`. `dumpsys gfxinfo` cannot see this screen's own GPU-drawn
|
||||
/// frames at all -- this is the screen's own equivalent of the Compose
|
||||
/// app's "Copy render timings" control, logged rather than clipboarded
|
||||
/// (no clipboard wiring exists here) under this crate's own fixed
|
||||
/// `android_logger` tag (`iris-android-app`, `lib.rs`'s `JNI_OnLoad`),
|
||||
/// grep-able on the fixed string `"iris frame report"` the way
|
||||
/// `transcript-bench.sh` greps `"ai-app render report"`.
|
||||
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
||||
type Rsc = AndroidRsc<TranscriptClient>;
|
||||
let report_rect = rect(Color::rgb(50, 50, 60))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
|
||||
.state
|
||||
.android_state()
|
||||
.frame_report
|
||||
.report()
|
||||
{
|
||||
Some(stats) => log::info!("iris frame report: {stats}"),
|
||||
None => log::info!(
|
||||
"iris frame report: no frames recorded -- scroll first, then press this"
|
||||
),
|
||||
},
|
||||
)
|
||||
.label("Frame report");
|
||||
let report = (
|
||||
report_rect,
|
||||
wtext("Frame report").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.add(rsc);
|
||||
|
||||
let reset_rect = rect(Color::rgb(70, 40, 40))
|
||||
.on(
|
||||
CursorSense::click(),
|
||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||
ctx.state.android_state_mut().frame_report.reset();
|
||||
log::info!("iris frame report: reset");
|
||||
},
|
||||
)
|
||||
.label("Reset frame report");
|
||||
let reset = (
|
||||
reset_rect,
|
||||
wtext("Reset").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.add(rsc);
|
||||
|
||||
(report, reset).span(Dir::RIGHT).height(56).add(rsc)
|
||||
}
|
||||
|
||||
impl AndroidAppState for TranscriptClient {
|
||||
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||
let content = WidgetPtr::new().add(rsc);
|
||||
let loading = placeholder(rsc, "Loading sessions...");
|
||||
content(rsc).set(loading);
|
||||
|
||||
let tree = (frame_report_controls(rsc), content.height(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
ui_state.set_root(tree);
|
||||
|
||||
let mut client = Self {
|
||||
ui_state,
|
||||
content,
|
||||
screen: None,
|
||||
items: Vec::new(),
|
||||
session_id: None,
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
client.spawn_fetch_sessions(rsc);
|
||||
client
|
||||
}
|
||||
|
||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||
// No screen stack of its own -- same "let the activity finish"
|
||||
// answer `iris-android-app`'s tabs `Client` already gives.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptClient {
|
||||
fn show_message(&mut self, rsc: &mut AndroidRsc<Self>, message: &str) {
|
||||
let widget = placeholder(rsc, message);
|
||||
(self.content)(rsc).set(widget);
|
||||
self.screen = None;
|
||||
}
|
||||
|
||||
fn spawn_fetch_sessions(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
let my_generation = self.generation.load(Ordering::SeqCst);
|
||||
let generation = self.generation.clone();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
let outcome = match build_transport() {
|
||||
Ok(transport) => ApiClient::new(transport)
|
||||
.fetch_sessions()
|
||||
.map_err(|e| e.to_string()),
|
||||
Err(e) => Err(format!("couldn't set up TLS: {e}")),
|
||||
};
|
||||
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||
if generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
match outcome {
|
||||
Ok(sessions) => match sessions.into_iter().next() {
|
||||
Some(session) => state.select_session(rsc, session.id),
|
||||
None => state.show_message(rsc, "No sessions on the sandbox server."),
|
||||
},
|
||||
Err(message) => {
|
||||
state.show_message(rsc, &format!("Couldn't list sessions: {message}"))
|
||||
}
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/// Loads the opening page, then follows the live SSE stream for the
|
||||
/// rest of this session's life -- `desktop-app`'s `select_session`
|
||||
/// almost verbatim, with `Proxy::send_event` replaced by `ctx.update` +
|
||||
/// `redraw.request_redraw()` (see this module's doc).
|
||||
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
|
||||
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.items.clear();
|
||||
self.session_id = Some(session_id.clone());
|
||||
self.show_message(rsc, "Loading transcript...");
|
||||
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
let live_generation = self.generation.clone();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
let transports =
|
||||
build_transport().and_then(|rest| build_transport().map(|stream| (rest, stream)));
|
||||
let (rest, stream_transport) = match transports {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
let message = format!("couldn't set up TLS: {e}");
|
||||
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||
if live_generation.load(Ordering::SeqCst) == my_generation {
|
||||
state.show_message(rsc, &message);
|
||||
}
|
||||
});
|
||||
redraw.request_redraw();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let api = ApiClient::new(rest);
|
||||
|
||||
// The most recent 200 events, coalesced -- the same page size
|
||||
// `desktop-app` uses; RUST.md's I3/history-paging work is what
|
||||
// a real scrollback would reuse (out of scope here, same as
|
||||
// E4).
|
||||
let page: Result<Vec<serde_json::Value>, String> = api
|
||||
.fetch_transcript_page(&session_id, None, 200, true)
|
||||
.map_err(|e| e.to_string());
|
||||
// The wire `seq` of the last line, not a folded item's `seq()`
|
||||
// -- see `client_core::transcript_fold::raw_seq`'s doc for why
|
||||
// resuming from the latter re-delivers deltas already folded
|
||||
// into an in-progress reply.
|
||||
let after = page
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|values| values.last())
|
||||
.and_then(client_core::transcript_fold::raw_seq)
|
||||
.unwrap_or(0);
|
||||
let result = page.and_then(|values| fold_page(&values));
|
||||
|
||||
{
|
||||
let live_generation = live_generation.clone();
|
||||
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(items) => {
|
||||
state.items = items;
|
||||
state.rebuild_transcript(rsc);
|
||||
}
|
||||
Err(message) => {
|
||||
state.show_message(rsc, &format!("Couldn't load transcript: {message}"))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
redraw.request_redraw();
|
||||
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
// The outer closure here is an `FnMut` -- `follow_session_events`
|
||||
// calls it once per line -- so it captures `live_generation` by
|
||||
// move and re-clones it for each inner `ctx.update` closure
|
||||
// rather than moving a shared `stop`-style helper into itself:
|
||||
// a value moved out of an `FnMut`'s captures on one call leaves
|
||||
// nothing there for the next.
|
||||
let _ =
|
||||
follow_session_events(
|
||||
&stream_transport,
|
||||
&session_id,
|
||||
after,
|
||||
move |item| match item {
|
||||
StreamItem::Open | StreamItem::Reset => {
|
||||
live_generation.load(Ordering::SeqCst) == my_generation
|
||||
}
|
||||
StreamItem::Event { event, .. } => {
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return false;
|
||||
}
|
||||
let live_generation = live_generation.clone();
|
||||
ctx.update(move |state: &mut TranscriptClient, rsc| {
|
||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||
return;
|
||||
}
|
||||
state.apply_event(rsc, &event);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuilds the whole widget tree from `self.items` -- same tradeoff as
|
||||
/// `desktop-app`'s `rebuild_transcript` (this module's doc comment).
|
||||
/// Reads `self.session_id` rather than taking one, since every caller
|
||||
/// (the opening page, and every live event) already has it set there.
|
||||
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||
let in_progress = self
|
||||
.screen
|
||||
.as_ref()
|
||||
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
|
||||
.filter(|t| !t.is_empty());
|
||||
|
||||
let rows = group_tool_runs(&self.items);
|
||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||
|
||||
if let Some(text) = in_progress {
|
||||
screen.composer.field.edit(rsc).set(&text);
|
||||
}
|
||||
if let Some(session_id) = self.session_id.clone() {
|
||||
let field = screen.composer.field;
|
||||
rsc.register_event(field, Submit, move |ctx, rsc| {
|
||||
let text = field.edit(rsc).take();
|
||||
let text = text.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
ctx.state.send_message(session_id.clone(), text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
(self.content)(rsc).set(tree);
|
||||
self.screen = Some(screen);
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
||||
let old_items = self.items.clone();
|
||||
self.items = fold_event(&self.items, event);
|
||||
match &self.screen {
|
||||
// The common path: update only the row(s) that actually
|
||||
// changed instead of refolding and rebuilding all ~3,200 of
|
||||
// them per event (RUST.md's P0 streaming-phase fix).
|
||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||
// No screen yet (the opening page hasn't landed) -- build one
|
||||
// the ordinary way once it has.
|
||||
None => self.rebuild_transcript(rsc),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(&mut self, session_id: String, text: String) {
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(transport) = build_transport() {
|
||||
let api = ApiClient::new(transport);
|
||||
let _ = api.send_message(&session_id, &text, &[]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||