Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8602c1626 | ||
|
|
f49b284b46 | ||
|
|
599d33287c | ||
|
|
b53a17c436 | ||
|
|
de92fccba5 | ||
|
|
25370731d0 | ||
|
|
5428cd75c9 | ||
|
|
8c6e2ed9cf | ||
|
|
4bc23172fd | ||
|
|
e5fee03da8 | ||
|
|
5ece49b8d9 | ||
|
|
e212ed8d02 | ||
|
|
f5183af306 | ||
|
|
fffed42f9e | ||
|
|
0aa03cf621 | ||
|
|
ae0af8f5e3 | ||
|
|
18c5f9aaac | ||
|
|
a428cba41a | ||
|
|
3c7d3db370 | ||
|
|
77cee6a8fa | ||
|
|
43a3a345e4 | ||
|
|
9bf714fa2e | ||
|
|
42d54eec95 | ||
|
|
4ccfda6b8e | ||
|
|
09778346a0 | ||
|
|
6d5a231f5c | ||
|
|
e9a6562dc6 | ||
|
|
afbc2ad132 | ||
|
|
1318e149f5 | ||
|
|
4fdabc39d0 | ||
|
|
bf8658c404 | ||
|
|
00e0a63887 | ||
|
|
b7474f61b0 | ||
|
|
8e5928cc6a | ||
|
|
76fcbdccb9 | ||
|
|
a00376994e | ||
|
|
ba57086361 | ||
|
|
1a9655414e | ||
|
|
5e34dba2fd | ||
|
|
cc8148cbec | ||
|
|
a2e5e5881c | ||
|
|
c121bc0725 | ||
|
|
fe7dc9c728 | ||
|
|
02b277e7ad | ||
|
|
fc82d9d7e8 | ||
|
|
9e301f30c6 | ||
|
|
341b7a5922 | ||
|
|
c8785b6091 | ||
|
|
9c560e3492 | ||
|
|
e5a90c6135 | ||
|
|
af1b0c5ab2 | ||
|
|
cce4b28324 | ||
|
|
94d8373289 | ||
|
|
8310431497 | ||
|
|
b863f9f3df | ||
|
|
cdeb7b0857 | ||
|
|
476609e1d3 | ||
|
|
2756087e1c | ||
|
|
af7d5f3782 | ||
|
|
db0a41a7cd | ||
|
|
b9924e7617 | ||
|
|
f014e8d9cf | ||
|
|
0ccc444246 | ||
|
|
c6da735134 | ||
|
|
4f6ec3a900 | ||
|
|
38bf6309cb | ||
|
|
3eb0e033d5 | ||
|
|
b8ea723718 | ||
|
|
7485d78d50 | ||
|
|
b87f5a597e | ||
|
|
80a75c128e | ||
|
|
50e69995b6 | ||
|
|
85869d02f8 | ||
|
|
f99ae4c366 | ||
|
|
203f53470c | ||
|
|
b38e797db3 | ||
|
|
92985ba8e3 | ||
|
|
181ba64606 | ||
|
|
a6a100edc6 | ||
|
|
ff1d6ea932 | ||
|
|
2b20bb2c91 | ||
|
|
3c80d9d696 | ||
|
|
06b8a1f4b0 | ||
|
|
e10582a2cd | ||
|
|
551c01398f | ||
|
|
7e79ec11e0 | ||
|
|
2ec0fee84c | ||
|
|
992c472975 | ||
|
|
729098756d | ||
|
|
d8562d96a3 | ||
|
|
22210a42f5 | ||
|
|
ade572973a | ||
|
|
9b27e858b5 | ||
|
|
7e4e26a335 | ||
|
|
84a13e806b | ||
|
|
452c44249f | ||
|
|
238057ad5e | ||
|
|
896c93a59a | ||
|
|
690161e5e9 | ||
|
|
e922b73d7a | ||
|
|
d507ae4c96 | ||
|
|
9ed01e2812 | ||
|
|
5be9f1baac | ||
|
|
977bdb9ee0 | ||
|
|
9cd1263080 | ||
|
|
42af780639 | ||
|
|
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 |
No files matched your search
+7
-3
@@ -1,6 +1,10 @@
|
|||||||
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
|
||||||
# every crate in this repo into one workspace -- they are deliberately
|
# every crate in this repo into one workspace -- they are deliberately
|
||||||
# independent (see run-tests.sh, which cds into each). `cargo xtask apk`
|
# independent (see scripts/run-tests.sh, which cds into each).
|
||||||
# from the repo root runs xtask/src/main.rs directly.
|
#
|
||||||
|
# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs.
|
||||||
|
# The manifest path is relative to the working directory cargo is run from,
|
||||||
|
# so the root is where it works; this file is found from any directory
|
||||||
|
# inside the checkout, but the path inside it is not.
|
||||||
[alias]
|
[alias]
|
||||||
xtask = "run --quiet --manifest-path xtask/Cargo.toml --"
|
xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --"
|
||||||
+5
-3
@@ -64,9 +64,11 @@ components: [
|
|||||||
// defaults to this checkout's root, which both the `cargo xtask`
|
// defaults to this checkout's root, which both the `cargo xtask`
|
||||||
// alias (`.cargo/config.toml`, resolved relative to the working
|
// alias (`.cargo/config.toml`, resolved relative to the working
|
||||||
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
// directory cargo is run from) and `cargo xtask apk`'s own publishing
|
||||||
// step (`xtask/build/outputs/apk/<mode>/*.apk`, matching discover.rs's
|
// step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
|
||||||
// `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc)
|
// discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
|
||||||
// both need.
|
// module doc) both need. The publish directory is `scripts/build`
|
||||||
|
// rather than `scripts/xtask/build` for exactly that reason: the
|
||||||
|
// pattern is one directory deep, and the tool moved two on 2026-09-09.
|
||||||
Apk(
|
Apk(
|
||||||
name: "shell",
|
name: "shell",
|
||||||
modes: ["release", "debug"],
|
modes: ["release", "debug"],
|
||||||
|
|||||||
+6
-9
@@ -9,8 +9,7 @@ local.properties
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
server/target/
|
server/target/
|
||||||
event-model/target/
|
event-model/target/
|
||||||
client-core/target/
|
app-rust/target/
|
||||||
android-shell/target/
|
|
||||||
|
|
||||||
# E3's native library, built by cargo-ndk straight into the Gradle module
|
# E3's native library, built by cargo-ndk straight into the Gradle module
|
||||||
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
# (RUST.md) -- an artifact, like server/target/ above, not source.
|
||||||
@@ -32,11 +31,9 @@ sessions/
|
|||||||
|
|
||||||
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
|
||||||
iris/target/
|
iris/target/
|
||||||
iris/android-app/target/
|
|
||||||
|
|
||||||
# E5's packaging xtask (RUST.md). `build/` above already covers
|
# The packaging xtask and the GPU rigs, both under scripts/. `build/`
|
||||||
# xtask/build/outputs/apk (the published APK, see apk.rs's module doc).
|
# above already covers scripts/build/outputs/apk, where `cargo xtask apk`
|
||||||
# The repo root has no Cargo workspace, so this is xtask's own
|
# publishes for Dev Updater to find.
|
||||||
# intermediate working files (target/xtask/apk/...), not a shared one.
|
scripts/xtask/target/
|
||||||
xtask/target/
|
scripts/rigs/gpu-probe/target/
|
||||||
/target/
|
|
||||||
@@ -19,6 +19,28 @@ 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,
|
is a new driver — never a session-type branch in shared code (routes,
|
||||||
transcript, app screens).
|
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
|
||||||
|
`app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android`
|
||||||
|
and `src/desktop` 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 `ui` the first time it is written, and a platform
|
||||||
|
module 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.
|
||||||
|
|
||||||
|
The third, from Iris on 2026-09-08: **`iris/` is the UI framework and
|
||||||
|
nothing else.** Nothing in it may know about a session, a transcript, a
|
||||||
|
setup or a server; anything that does belongs in `app-rust/`, and the
|
||||||
|
dependency runs one way only. The port's project code is **one crate**
|
||||||
|
(`ai-app`) rather than the six it was scattered across — see docs/RUST.md's
|
||||||
|
"One app crate" for what forced each of the splits that were removed and
|
||||||
|
the two that remain.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
|
||||||
@@ -29,11 +51,46 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
|||||||
|
|
||||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||||
comment is the HTTP table and the surface's source of truth.
|
comment is the HTTP table and the surface's source of truth.
|
||||||
|
- `event-model/` — the wire shape `server/` and `app-rust/` both depend on,
|
||||||
|
which is the whole reason it is a crate of its own rather than part of
|
||||||
|
either: it is the contract between them, so the two agree by construction.
|
||||||
|
- `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/
|
||||||
|
client` is everything with no UI in it (the REST and SSE clients, the
|
||||||
|
transcript cache and fold, the highlighter, the ANSI parser, config and
|
||||||
|
the enrolment link); `src/ui` is the screens as iris widget trees;
|
||||||
|
`src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android`
|
||||||
|
is the `android-view` entry point and `android-project/` its Gradle app;
|
||||||
|
`src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls.
|
||||||
|
Features pick which face a build is: `screens` (default) for anything
|
||||||
|
that draws, `shell` for the Compose app's bridge, `bench` for P0's
|
||||||
|
fixture build. See its `Cargo.toml` header.
|
||||||
|
- `iris/` — the UI framework, and **only** the UI framework: `core`,
|
||||||
|
`macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree)
|
||||||
|
and `rig-input`. It must not mention anything this product is about.
|
||||||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||||
the Keystore-sealed token.
|
the Keystore-sealed token.
|
||||||
|
- `scripts/` — everything at the root that was neither a program nor a
|
||||||
|
document: the three repo-wide shell scripts (`run-tests.sh`,
|
||||||
|
`test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and
|
||||||
|
`virtgpu-probe` device probes, and `ui-profile`'s two layer-1
|
||||||
|
profiling rigs), and `xtask/`. **A project's own scripts
|
||||||
|
stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and
|
||||||
|
`server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant
|
||||||
|
top level sh files").
|
||||||
|
`scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask)
|
||||||
|
convention: an ordinary Rust binary that does build work a shell script
|
||||||
|
would otherwise do, run as `cargo xtask apk` **from the repo root**
|
||||||
|
(`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to
|
||||||
|
the working directory). It packages `app/shellApp` without Gradle
|
||||||
|
driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
|
||||||
|
`apksigner` — and publishes to `scripts/build/outputs/apk/<mode>/`,
|
||||||
|
which is where Dev Updater looks. There is deliberately **no `target/`
|
||||||
|
at the repo root** any more: there is no workspace there, and what used
|
||||||
|
to be one was only xtask's own scratch space, now in
|
||||||
|
`scripts/xtask/target/`.
|
||||||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||||||
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
|
||||||
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
binding and the certificate's SANs (`netif`), owner-only files (`private`),
|
||||||
@@ -51,18 +108,53 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
|
|||||||
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
|
||||||
the opening and stream effects in `SessionScreen.kt`.
|
the opening and stream effects in `SessionScreen.kt`.
|
||||||
- `docs/TODO.md` — the working list.
|
- `docs/TODO.md` — the working list.
|
||||||
|
- `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the
|
||||||
|
phone's view, and the choices behind the shape.
|
||||||
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
|
- `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
|
branch of the `ai-app-2` clone): what has to be reproduced, the
|
||||||
framework decision, and the ordered experiments with their pass
|
framework decision, and the ordered experiments with their pass
|
||||||
conditions. Read it before touching anything under that branch.
|
conditions. Read it before touching anything under that branch.
|
||||||
- `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`,
|
- `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`,
|
||||||
`docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's
|
`docs/CLIENT_CORE.md` — iris's open working list, its layout/render
|
||||||
own public API log, working list, decisions log, layout/render design,
|
design, its texture-atlas design, and the design of `app-rust`'s
|
||||||
and texture-atlas design, and the client-core crate's design,
|
`client` module, respectively.
|
||||||
respectively.
|
|
||||||
|
**These documents are pruned as the work lands, not appended to
|
||||||
|
forever** (Iris, 2026-09-08: *"remove everything that's already done and
|
||||||
|
decided… many with checkboxes already ticked off that just fill up
|
||||||
|
context"*). A ticked box, a finished experiment and a completed review
|
||||||
|
are deleted once carried out; `IRIS_TODO.md` holds only open items, and
|
||||||
|
a finished document is removed rather than archived in place. What
|
||||||
|
survives is what cannot be cheaply re-derived — measurements, dead ends
|
||||||
|
and failed hypotheses, invariants and their reasons, and the design of
|
||||||
|
what exists now rather than the route to it.
|
||||||
|
|
||||||
|
**There is no decisions log and no design log, and one should not be
|
||||||
|
started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on
|
||||||
|
2026-09-09 at Iris's instruction: *"I've decided to instead make
|
||||||
|
decisions when planning with agents rather than after they do things,
|
||||||
|
and they're both too long for me to wanna read, + don't cover all the
|
||||||
|
decisions I'll wanna make about the code anyways. I'll just naturally
|
||||||
|
run into things for now."* So raise a choice **while planning it with
|
||||||
|
her**, when the direction is still cheap to change; otherwise decide it,
|
||||||
|
put the reasoning at the code it governs, and carry on. TODO lists are
|
||||||
|
still wanted — a list of open work is useful, a list of finished work
|
||||||
|
is not.
|
||||||
|
- `docs/SCROLL.md` — how anything in iris scrolls: one
|
||||||
|
`ScrollController` holds the position, the gesture, the fling and the
|
||||||
|
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
|
||||||
|
one each through the `Scrollable` trait. Read it before touching
|
||||||
|
`scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that
|
||||||
|
pans, flings or lays out a long list.
|
||||||
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
||||||
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
||||||
rather than a script kept here) and the APK, in parallel. It points at
|
rather than a script kept here), the Compose app and the shell APK, in
|
||||||
|
parallel. **It does not publish either benchmark APK.** Phone benchmark
|
||||||
|
builds live in the separate `~/repos/ai-app-bench` repository: build here,
|
||||||
|
copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped
|
||||||
|
path, then commit and push that repo. Dev Updater pulls the committed APK
|
||||||
|
from there; pushing `ai-app-2` alone cannot update its benchmark card. This
|
||||||
|
file points at
|
||||||
`resources.ron`, which is *ours* rather than Dev Updater's — it names
|
`resources.ron`, which is *ours* rather than Dev Updater's — it names
|
||||||
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
|
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
|
||||||
offer them. Note what deleting the config directory takes with it: the CA
|
offer them. Note what deleting the config directory takes with it: the CA
|
||||||
@@ -85,12 +177,33 @@ the **Mono** face, where every glyph is one em square, which is what makes
|
|||||||
two icon buttons the same width without either being given one — and why
|
two icon buttons the same width without either being given one — and why
|
||||||
`GLYPH_SIZE` is smaller than it looks like it should be.
|
`GLYPH_SIZE` is smaller than it looks like it should be.
|
||||||
|
|
||||||
|
**The Rust app does the same, from its own subset**:
|
||||||
|
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
|
||||||
|
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
|
||||||
|
with `Family::Icons`. Same rule about the two lists agreeing (there is a
|
||||||
|
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
|
||||||
|
Design family so an icon means the same thing in both apps. Its subset is
|
||||||
|
separate rather than shared because subsetting only what one app draws is
|
||||||
|
the point. This is the **only** font iris bundles — body and monospace
|
||||||
|
text come from the platform (decided 2026-09-07), and an icon
|
||||||
|
is the opposite case: a small closed set of codepoints no system font is
|
||||||
|
guaranteed to have.
|
||||||
|
|
||||||
## Checking your work
|
## Checking your work
|
||||||
|
|
||||||
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
- **Commit completed work.** Once a coherent piece of work has passed its
|
||||||
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build
|
relevant checks and has no known major issue or unresolved design decision,
|
||||||
stays warning-clean and rustfmt-clean at the defaults — there is no
|
commit it rather than leaving it in the worktree. Keep independently
|
||||||
`rustfmt.toml` and there should not be one.
|
completed slices in separate commits.
|
||||||
|
|
||||||
|
- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`,
|
||||||
|
`server/` and `app-rust/`; `cd iris && cargo test` runs the framework's
|
||||||
|
own suite, which is slower and not about this product. Each workspace
|
||||||
|
also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays
|
||||||
|
warning-clean and rustfmt-clean at the defaults — there is no
|
||||||
|
`rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the
|
||||||
|
UI profiling rig use the rolling nightly channel through per-directory
|
||||||
|
`rust-toolchain.toml` files; `server/` and `event-model/` are stable.
|
||||||
- **App**: from `app/`,
|
- **App**: from `app/`,
|
||||||
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||||
@@ -151,7 +264,7 @@ two icon buttons the same width without either being given one — and why
|
|||||||
whether it was continued or reset (`stream backlog:`). That is the only
|
whether it was continued or reset (`stream backlog:`). That is the only
|
||||||
place "how far had this phone fallen behind" is answerable — the app sees a
|
place "how far had this phone fallen behind" is answerable — the app sees a
|
||||||
window arrive and cannot tell.
|
window arrive and cannot tell.
|
||||||
- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
|
- **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
|
||||||
network namespaces inside one machine and drives the server through it — a
|
network namespaces inside one machine and drives the server through it — a
|
||||||
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||||
involved. That is how to verify the wg0-only posture.
|
involved. That is how to verify the wg0-only posture.
|
||||||
@@ -161,7 +274,7 @@ two icon buttons the same width without either being given one — and why
|
|||||||
Each exists because something was invisible without it.
|
Each exists because something was invisible without it.
|
||||||
|
|
||||||
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
|
- **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
|
and the 2026-09-05 decisions), the phone benchmark gate Iris
|
||||||
asked for before porting continues: a deterministic, checked-in synthetic
|
asked for before porting continues: a deterministic, checked-in synthetic
|
||||||
transcript (`app/bench-fixture/generate.py`, never a real one) that both
|
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
|
this app and iris open with no server, so a frame-time comparison
|
||||||
@@ -261,13 +374,113 @@ Each exists because something was invisible without it.
|
|||||||
framework, from `atrace` text output with no trace processor needed. It is
|
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
|
how the cost of a layout node per link was attributed to the framework
|
||||||
rather than guessed at.
|
rather than guessed at.
|
||||||
- **`iris/android-app/build-apk.sh [debug|release] [--abi ...] [--features
|
- **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features
|
||||||
...]`** builds iris-android-app's cdylib (`cargo ndk`) and its APK
|
...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`,
|
||||||
(Gradle) in one step and verifies the result (`aapt2`/`apksigner`), and
|
straight into `android-project/app/src/main/jniLibs/`) and its APK
|
||||||
**`iris/android-app/run-bench.sh [--apk PATH]`** installs it on this
|
(Gradle, from `android-project/`) in one step and verifies the result
|
||||||
checkout's own emulator, taps "Run benchmark" by label, and prints the
|
(`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`**
|
||||||
report -- written so the P0 build/install/tap/read-report cycle stops
|
installs it on this checkout's own emulator, taps "Run benchmark" by
|
||||||
being retyped by hand each time (docs/RUST.md's P0 box).
|
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). It passes `--no-default-features`, so
|
||||||
|
`--features` alone decides what is in the `.so`; that is what keeps the
|
||||||
|
1.9 MB bench fixture out of a build that did not ask for `bench`. A phone
|
||||||
|
build is published only by replacing
|
||||||
|
`~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`
|
||||||
|
and pushing the **bench repository**, not this checkout.
|
||||||
|
- **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. `cd app-rust && cargo test` 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
|
||||||
|
`app-rust/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
|
||||||
|
--dir ../app-rust --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 (`--dir` names the workspace to build in,
|
||||||
|
since the rig lives in iris and the app's examples do not). 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.
|
||||||
|
- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in
|
||||||
|
a crate of their own so a rig's dependencies stay out of the app's
|
||||||
|
(Iris, 2026-09-09: *"Rigs should probably all be in their own crate so
|
||||||
|
dependencies and such don't get mixed"*). Run either from that
|
||||||
|
directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh`
|
||||||
|
neither runs them nor can fail on them, and both need **release or the
|
||||||
|
numbers mean nothing**.
|
||||||
|
- **`tests/frame_profile.rs`** is what a frame costs on the CPU,
|
||||||
|
at layer 1 -- `cargo test --release --test frame_profile -- --ignored
|
||||||
|
--nocapture`. Two runs: a fling over the bench
|
||||||
|
fixture eight times out and back, and a reply streaming into it one
|
||||||
|
event at a time. Text shaping dominates, which is why the profile is
|
||||||
|
meaningless unoptimised. It cannot answer anything about the GPU, the
|
||||||
|
swapchain or the phone's own clock.
|
||||||
|
|
||||||
|
What it established on 2026-09-09, worth not re-deriving. A **fling**
|
||||||
|
is not CPU-bound: only about one frame in six lays anything out (the
|
||||||
|
rest are moved on the GPU through `move_offsets`), and the
|
||||||
|
multi-millisecond spikes are all in the *first* pass over a stretch of
|
||||||
|
transcript -- every later pass over the same rows is p99 0.26ms. A
|
||||||
|
**streamed event** is, and it is not where it looks: folding the event
|
||||||
|
is 0.35ms and applying the diff to the widget tree is 0.41ms, while the
|
||||||
|
*frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the
|
||||||
|
hypothesis, from `foldEvent`'s Compose lesson under "Things that have
|
||||||
|
bitten"; measuring it is what ruled it out.) That frame is one
|
||||||
|
`TextBuffer::shape` of the block a delta landed in, and **the fixture's
|
||||||
|
is 14,888 characters in a single block** -- against a largest-ever
|
||||||
|
1,580 across 7,706 blocks of real replies. So the stream phase's number
|
||||||
|
is a property of the fixture, not of streaming; docs/RUST.md's
|
||||||
|
"Incremental text" has the measurements and why parley cannot help.
|
||||||
|
|
||||||
|
The last two runs (`where_a_streamed_deltas_cost_is`,
|
||||||
|
`what_the_fixture_streams`) exist to keep that answerable: what a delta
|
||||||
|
costs to re-split and re-compare, and what the fixture actually
|
||||||
|
streams.
|
||||||
|
- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half
|
||||||
|
of a frame layer 1 builds and never performs, and so the half
|
||||||
|
`frame_profile.rs` cannot see at all. It prints three numbers per GPU
|
||||||
|
array per frame, and the point of the rig is that no two of them alone
|
||||||
|
are honest: **floor** (entries whose bytes actually differ, found by
|
||||||
|
diffing), **uploaded** (what iris really writes, read from the same
|
||||||
|
`Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the
|
||||||
|
old code wrote whenever anything changed). A gap between the first two
|
||||||
|
is over-marking; one was 122x and invisible until both were printed
|
||||||
|
side by side.
|
||||||
|
|
||||||
|
What it established on 2026-09-09, and what the three optimisations it
|
||||||
|
drove were. Uploading the whole arena on any change cost **758 MB over
|
||||||
|
a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed
|
||||||
|
frame. Three things were wrong and each is now guarded by this rig:
|
||||||
|
`ArrBuf` reallocated on every length change, so adding one glyph made
|
||||||
|
the buffer's contents undefined and forced a full rewrite; a redraw
|
||||||
|
freed its primitives and pushed new ones, which -- since freed slots
|
||||||
|
are only reusable next frame and provisional layout nested -- grew
|
||||||
|
the arena to **127,443 slots for 11,569 live primitives**; and nothing
|
||||||
|
tracked *which* entries changed. Now: the stream arena is 11,569 slots
|
||||||
|
for 11,569 live, and every array uploads within a hair of its floor.
|
||||||
|
The CPU half improved with it, since the freeing and renumbering
|
||||||
|
went away: a streamed frame is p50 1.39ms, from 2.20ms.
|
||||||
|
|
||||||
|
The remaining layout cost was then removed at the framework boundary:
|
||||||
|
`Painter::set_child_offset` gives a container one retained coordinate slot
|
||||||
|
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
|
||||||
|
Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from
|
||||||
|
71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496.
|
||||||
|
`Primitives` also cancels dirty marks for provisional writes restored before
|
||||||
|
upload, so CPU-only layout states never become GPU work.
|
||||||
|
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
|
||||||
|
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
|
||||||
|
in software -- while its GLES *is* the host's real GPU through virgl at
|
||||||
|
ES 3.1, so an ordinary build's runtime fallback lands there by itself
|
||||||
|
and nothing should pass `force-gles` to arrange it. The Vulkan path is
|
||||||
|
verified on the desktop build and on Iris's phone. Do not boot the
|
||||||
|
emulator with SwiftShader Vulkan to "test the Vulkan path": that
|
||||||
|
measures a software rasteriser and steers iris away from the one
|
||||||
|
hardware-accelerated backend it has there. Every run says which adapter
|
||||||
|
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
|
||||||
|
that line before reading a number.
|
||||||
|
|
||||||
### Driving the UI
|
### Driving the UI
|
||||||
|
|
||||||
@@ -333,13 +546,11 @@ thing to suspect first if a remote spawn ever mangles an argument.
|
|||||||
|
|
||||||
## Where things run (host vs this VM)
|
## Where things run (host vs this VM)
|
||||||
|
|
||||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
This checkout runs in a VM while production runs on its host:
|
||||||
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
|
|
||||||
means here:
|
|
||||||
|
|
||||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||||
address the phone can reach is, and where WireGuard terminates.
|
address the phone can reach is, and where WireGuard terminates.
|
||||||
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
`scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||||
there with `sudo WG_ENDPOINT=<ddns name>`.
|
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
- **The tunnel and the real phone can never terminate in the VM**, because
|
||||||
nothing outside can open a connection into it. Phone bring-up is host work.
|
nothing outside can open a connection into it. Phone bring-up is host work.
|
||||||
@@ -427,8 +638,7 @@ where it was instead of half-deleted.
|
|||||||
|
|
||||||
## Things that have bitten
|
## Things that have bitten
|
||||||
|
|
||||||
Project-specific only — a lesson that would bite any project on this machine
|
Project-specific only; keep cross-project machine notes out of this file.
|
||||||
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead.
|
|
||||||
|
|
||||||
- **tracing caches callsite interest process-wide.** A test that hits a
|
- **tracing caches callsite interest process-wide.** A test that hits a
|
||||||
`tracing::warn!` with no subscriber installed can poison the interest cache
|
`tracing::warn!` with no subscriber installed can poison the interest cache
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# Decisions awaiting review
|
|
||||||
|
|
||||||
Choices made while working autonomously, for Bryan to keep or change. Each
|
|
||||||
says what was picked and why; the detail is in the design doc it names.
|
|
||||||
Delete an entry once it has been looked at.
|
|
||||||
|
|
||||||
## Subagent views (2026-09-05, `SUBAGENTS.md`)
|
|
||||||
|
|
||||||
Made on my own judgement, limited blast radius:
|
|
||||||
|
|
||||||
1. **A subagent is a transcript, not a session.** It has no process,
|
|
||||||
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
|
|
||||||
and stored under the session's directory, so deleting the session takes
|
|
||||||
it. Alternative rejected: registering it as a session of its own, which
|
|
||||||
would give it a card in the main list and a driver that can do nothing.
|
|
||||||
2. **Read-only view is the session screen minus its controls**, rather than
|
|
||||||
a second, simpler transcript screen. Keeps paging, caching, selection
|
|
||||||
and rendering in one place. Cost: a `readOnly` mode threaded through
|
|
||||||
`SessionScreen`.
|
|
||||||
3. **The list only carries a count.** Each session row says how many
|
|
||||||
subagents it has; their titles and statuses are fetched when the card is
|
|
||||||
expanded. Keeps `GET /sessions` from reading every subagent transcript.
|
|
||||||
Consequence: an expanded card's statuses refresh with the list, not live.
|
|
||||||
4. **Expanded/collapsed is remembered per session on the phone**, not on
|
|
||||||
the server. Collapsed by default, per the transcript convention that new
|
|
||||||
things arrive collapsed.
|
|
||||||
5. **Subagents of imported sessions are not shown.** The import path still
|
|
||||||
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
|
|
||||||
are not read. Only subagents run while this backend was watching exist.
|
|
||||||
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
|
|
||||||
paid turn to exercise.
|
|
||||||
|
|
||||||
Deferred, because they reach further than this feature:
|
|
||||||
|
|
||||||
- **Live status on the list.** Whether the session list should follow a
|
|
||||||
stream at all (it refreshes on demand today) decides whether subagent
|
|
||||||
status can ever be live there. Not changed.
|
|
||||||
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
|
|
||||||
in its transcript and are not given transcripts of their own. Supporting
|
|
||||||
that is the same mechanism one level down, but the UI would need nested
|
|
||||||
expanders.
|
|
||||||
|
|
||||||
- **The subagent status row says "context unknown".** Nothing measures a
|
|
||||||
subagent's context; the row could leave it out rather than admit it.
|
|
||||||
Generated
-1081
File diff suppressed because it is too large.
Load diff
@@ -1,36 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//! 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,10 @@
|
|||||||
|
android-project/.gradle/
|
||||||
|
android-project/build/
|
||||||
|
android-project/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.
|
||||||
|
android-project/app/src/main/jniLibs/
|
||||||
|
target/
|
||||||
|
Cargo.lock.orig
|
||||||
+186
-206
@@ -166,6 +166,28 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ai-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"android-view",
|
||||||
|
"android_logger",
|
||||||
|
"base64",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"pulldown-cmark",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tabs-ui",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"ureq",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aligned"
|
name = "aligned"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -566,18 +588,18 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-vec"
|
name = "bit-vec"
|
||||||
version = "0.8.0"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit_field"
|
name = "bit_field"
|
||||||
@@ -606,12 +628,6 @@ dependencies = [
|
|||||||
"no_std_io2",
|
"no_std_io2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "block"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -621,6 +637,15 @@ dependencies = [
|
|||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block2"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blocking"
|
name = "blocking"
|
||||||
version = "1.7.0"
|
version = "1.7.0"
|
||||||
@@ -740,16 +765,6 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "client-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"event-model",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"ureq",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -761,9 +776,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.12.0"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
|
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
@@ -834,16 +849,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-foundation"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -857,8 +862,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"core-graphics-types 0.1.3",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -870,18 +875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-graphics-types"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"core-foundation 0.10.1",
|
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -896,9 +890,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.7"
|
version = "0.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-epoch",
|
"crossbeam-epoch",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
@@ -906,18 +900,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.20"
|
version = "0.9.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.22"
|
version = "0.8.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crunchy"
|
name = "crunchy"
|
||||||
@@ -1182,9 +1176,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "font-types"
|
name = "font-types"
|
||||||
version = "0.12.4"
|
version = "0.12.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
|
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
@@ -1387,9 +1381,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glow"
|
name = "glow"
|
||||||
version = "0.16.0"
|
version = "0.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
|
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"slotmap",
|
"slotmap",
|
||||||
@@ -1420,26 +1414,6 @@ dependencies = [
|
|||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gpu-descriptor"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"gpu-descriptor-types",
|
|
||||||
"hashbrown 0.15.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gpu-descriptor-types"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -1505,12 +1479,6 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hexf-parse"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1759,23 +1727,6 @@ dependencies = [
|
|||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iris-android-app"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"android-view",
|
|
||||||
"android_logger",
|
|
||||||
"client-core",
|
|
||||||
"event-model",
|
|
||||||
"iris",
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"serde_json",
|
|
||||||
"tabs-ui",
|
|
||||||
"tokio",
|
|
||||||
"transcript-ui",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iris-core"
|
name = "iris-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1796,7 +1747,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1983,7 +1934,7 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"libc",
|
"libc",
|
||||||
"plain",
|
"plain",
|
||||||
"redox_syscall 0.9.3",
|
"redox_syscall 0.9.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2040,15 +1991,6 @@ dependencies = [
|
|||||||
"imgref",
|
"imgref",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "malloc_buf"
|
|
||||||
version = "0.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2083,21 +2025,6 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "metal"
|
|
||||||
version = "0.33.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"block",
|
|
||||||
"core-graphics-types 0.2.0",
|
|
||||||
"foreign-types",
|
|
||||||
"log",
|
|
||||||
"objc",
|
|
||||||
"paste",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2130,9 +2057,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "naga"
|
name = "naga"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135"
|
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
@@ -2141,11 +2068,11 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"codespan-reporting",
|
"codespan-reporting",
|
||||||
"half",
|
"half",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"hexf-parse",
|
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"libm",
|
"libm",
|
||||||
"log",
|
"log",
|
||||||
|
"naga-types",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
@@ -2154,6 +2081,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "naga-types"
|
||||||
|
version = "30.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"indexmap",
|
||||||
|
"rustc-hash",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2303,15 +2242,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
|
||||||
dependencies = [
|
|
||||||
"malloc_buf",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -2344,13 +2274,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
"objc2-core-image",
|
"objc2-core-image",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-quartz-core",
|
"objc2-quartz-core 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2372,7 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2384,7 +2314,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2396,7 +2326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2431,10 +2361,10 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2443,7 +2373,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-contacts",
|
"objc2-contacts",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2472,7 +2402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -2506,7 +2436,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2519,11 +2449,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-metal"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2531,10 +2473,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-metal",
|
"objc2-metal 0.2.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-quartz-core"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-metal 0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2554,7 +2510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
@@ -2562,7 +2518,7 @@ dependencies = [
|
|||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-link-presentation",
|
"objc2-link-presentation",
|
||||||
"objc2-quartz-core",
|
"objc2-quartz-core 0.2.2",
|
||||||
"objc2-symbols",
|
"objc2-symbols",
|
||||||
"objc2-uniform-type-identifiers",
|
"objc2-uniform-type-identifiers",
|
||||||
"objc2-user-notifications",
|
"objc2-user-notifications",
|
||||||
@@ -2574,7 +2530,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -2586,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
@@ -2858,9 +2814,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pollster"
|
name = "pollster"
|
||||||
version = "0.4.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
@@ -3139,6 +3095,18 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "raw-window-metal"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-quartz-core 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -3196,9 +3164,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5"
|
checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
]
|
]
|
||||||
@@ -3310,9 +3278,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -3569,9 +3537,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spirv"
|
name = "spirv"
|
||||||
version = "0.3.0+sdk-1.3.268.0"
|
version = "0.4.0+sdk-1.4.341.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
|
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
]
|
]
|
||||||
@@ -3863,17 +3831,6 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "transcript-ui"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"client-core",
|
|
||||||
"event-model",
|
|
||||||
"iris",
|
|
||||||
"log",
|
|
||||||
"pulldown-cmark",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tree_magic_mini"
|
name = "tree_magic_mini"
|
||||||
version = "3.2.2"
|
version = "3.2.2"
|
||||||
@@ -3944,9 +3901,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq"
|
name = "ureq"
|
||||||
version = "3.4.0"
|
version = "3.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
@@ -3964,9 +3921,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq-proto"
|
name = "ureq-proto"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"http",
|
"http",
|
||||||
@@ -4252,9 +4209,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu"
|
name = "wgpu"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f"
|
checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
@@ -4262,7 +4219,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
@@ -4282,9 +4239,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core"
|
name = "wgpu-core"
|
||||||
version = "28.0.1"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d23f4642f53f666adcfd2d3218ab174d1e6681101aef18696b90cbe64d1c10f9"
|
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
@@ -4293,10 +4250,11 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"document-features",
|
"document-features",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
|
"naga-types",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
@@ -4309,66 +4267,70 @@ dependencies = [
|
|||||||
"wgpu-core-deps-emscripten",
|
"wgpu-core-deps-emscripten",
|
||||||
"wgpu-core-deps-windows-linux-android",
|
"wgpu-core-deps-windows-linux-android",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
|
"wgpu-naga-bridge",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-apple"
|
name = "wgpu-core-deps-apple"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da"
|
checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-emscripten"
|
name = "wgpu-core-deps-emscripten"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79"
|
checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-core-deps-windows-linux-android"
|
name = "wgpu-core-deps-windows-linux-android"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae"
|
checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-hal"
|
name = "wgpu-hal"
|
||||||
version = "28.0.1"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d6cb474beb218824dcc9e1ce679d973f719262789bfb27407da560cac20eeb"
|
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"ash",
|
"ash",
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block",
|
"block2 0.6.2",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics-types 0.2.0",
|
|
||||||
"glow",
|
"glow",
|
||||||
"glutin_wgl_sys",
|
"glutin_wgl_sys",
|
||||||
"gpu-allocator",
|
"gpu-allocator",
|
||||||
"gpu-descriptor",
|
"hashbrown 0.17.1",
|
||||||
"hashbrown 0.16.1",
|
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"khronos-egl",
|
"khronos-egl",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
"metal",
|
|
||||||
"naga",
|
"naga",
|
||||||
|
"naga-types",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"objc",
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-metal 0.3.2",
|
||||||
|
"objc2-quartz-core 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4377,26 +4339,44 @@ dependencies = [
|
|||||||
"profiling",
|
"profiling",
|
||||||
"range-alloc",
|
"range-alloc",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
|
"raw-window-metal",
|
||||||
"renderdoc-sys",
|
"renderdoc-sys",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"static_assertions",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
"wayland-sys",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"wgpu-naga-bridge",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core",
|
"windows-core",
|
||||||
|
"windows-result",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wgpu-naga-bridge"
|
||||||
|
version = "30.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
|
||||||
|
dependencies = [
|
||||||
|
"naga",
|
||||||
|
"wgpu-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-types"
|
name = "wgpu-types"
|
||||||
version = "28.0.0"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c"
|
checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"naga-types",
|
||||||
|
"raw-window-handle",
|
||||||
|
"static_assertions",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4760,12 +4740,12 @@ dependencies = [
|
|||||||
"android-activity",
|
"android-activity",
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"calloop",
|
"calloop",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -5064,18 +5044,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Product code lives here; reusable UI belongs in `iris/`.
|
||||||
|
[package]
|
||||||
|
name = "ai-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
# Android loads the cdylib; desktop, examples, and tests link the rlib.
|
||||||
|
[lib]
|
||||||
|
name = "ai_app"
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ai-app-desktop"
|
||||||
|
path = "src/bin_desktop.rs"
|
||||||
|
required-features = ["screens"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "transcript"
|
||||||
|
required-features = ["screens"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "phone"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
event-model = { path = "../event-model" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
# Transcript lines must retain exact float values and raw JSON bytes.
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
||||||
|
ureq = { version = "3", features = ["json"] }
|
||||||
|
pulldown-cmark = "0.13.4"
|
||||||
|
base64 = "0.23"
|
||||||
|
log = { version = "0.4.34", features = ["std"] }
|
||||||
|
|
||||||
|
# Optional so the Compose shell does not link the renderer.
|
||||||
|
iris = { path = "../iris", optional = true }
|
||||||
|
tabs-ui = { path = "../iris/tabs-ui", optional = true }
|
||||||
|
jni = { version = "0.22", optional = true }
|
||||||
|
libc = { version = "0.2.189", optional = true }
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
winit = "0.30.13"
|
||||||
|
|
||||||
|
# Keep this pin synchronized with `iris/Cargo.toml`.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
|
android_logger = "0.15.1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["screens", "fixture"]
|
||||||
|
screens = ["dep:iris"]
|
||||||
|
# Default-on for tests; APK builds opt in so ordinary APKs omit the 1.9 MB fixture.
|
||||||
|
fixture = ["screens"]
|
||||||
|
transcript-screen = ["screens"]
|
||||||
|
tabs-screen = ["screens", "dep:tabs-ui"]
|
||||||
|
bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"]
|
||||||
|
shell = ["dep:jni"]
|
||||||
|
force-gles = ["screens", "iris/force-gles"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"] }
|
||||||
|
|
||||||
|
# APK builds select these profiles explicitly.
|
||||||
|
[profile.android-release]
|
||||||
|
inherits = "release"
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
# A warm-fling profile measured p90/p99 0.09/0.26 ms at 3 versus
|
||||||
|
# 0.15/0.42 ms at "s"; the 1.9 MB saving is not worth that frame cost.
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
[profile.android-dev]
|
||||||
|
inherits = "dev"
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# Full DWARF in each renderer-linked test binary writes tens of gigabytes.
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[profile.test]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "catch_a_fling"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "fence_fling"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "gesture_cancel"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "input_log_roundtrip"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "phone_screen"
|
||||||
|
required-features = ["fixture"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "top_edge"
|
||||||
|
required-features = ["fixture"]
|
||||||
@@ -13,8 +13,37 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.iris.android.demo"
|
applicationId = "dev.iris.android.demo"
|
||||||
minSdk = 26
|
// 29, not 26: `iris::android::view`'s touch handler dates each
|
||||||
targetSdk = 34
|
// 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
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?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>
|
||||||
|
|
||||||
|
<!-- The enrollment link Dev Updater's Enroll button opens
|
||||||
|
(what `ai-server` mints), the same one the Compose app
|
||||||
|
in `app/` registers: which app answers it is the phone
|
||||||
|
owner's choice at the moment of the tap, and both being
|
||||||
|
offered is the intended behaviour rather than a clash.
|
||||||
|
BROWSABLE so a link tapped in another app reaches here,
|
||||||
|
and `android:host` so this app is not offered for every
|
||||||
|
aiapp:// URI a future route invents. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="aiapp" android:host="enroll" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data android:name="android.app.lib_name" android:value="ai_app" />
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- This app's own recent log, for Dev Updater to read on the
|
||||||
|
phone. Iris runs these builds with no adb, and Android
|
||||||
|
forbids one app reading another's logcat, so this is the
|
||||||
|
only way a log::info! here reaches her. The shape is Dev
|
||||||
|
Updater's contract (its README.md, "An app's own log"), not
|
||||||
|
something invented for this app.
|
||||||
|
|
||||||
|
The authority carries ${applicationId}, so the bench package
|
||||||
|
and the ordinary one each get their own and neither can read
|
||||||
|
the other's log. Exported, because the whole point is
|
||||||
|
another app reading it, and guarded by a permission Dev
|
||||||
|
Updater declares at protectionLevel="normal" (a signature
|
||||||
|
permission is not available: the two apps are signed with
|
||||||
|
different locally generated keys). Read-only: insert,
|
||||||
|
update and delete throw. -->
|
||||||
|
<provider
|
||||||
|
android:name=".DevLogProvider"
|
||||||
|
android:authorities="${applicationId}.devlog"
|
||||||
|
android:exported="true"
|
||||||
|
android:readPermission="dev.updater.permission.READ_DEVLOG" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.content.ContentProvider;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.content.UriMatcher;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.MatrixCursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
/** Read-only Dev Updater log provider; its URI and column schema are an external contract. */
|
||||||
|
public final class DevLogProvider extends ContentProvider {
|
||||||
|
static {
|
||||||
|
// A provider can start the process without creating MainActivity.
|
||||||
|
System.loadLibrary("ai_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int FIELDS_PER_LINE = 5;
|
||||||
|
|
||||||
|
private static final String[] LINE_COLUMNS = {"seq", "t_ms", "level", "target", "message"};
|
||||||
|
private static final String[] STATUS_COLUMNS = {"held", "dropped", "newest_seq"};
|
||||||
|
|
||||||
|
private static final int LINES = 1;
|
||||||
|
private static final int STATUS = 2;
|
||||||
|
|
||||||
|
private UriMatcher matcher;
|
||||||
|
|
||||||
|
private static native String[] nativeLinesSince(long since);
|
||||||
|
|
||||||
|
private static native String[] nativeStatus();
|
||||||
|
|
||||||
|
// The provider may be the process's only component, so it must supply
|
||||||
|
// the files directory normally initialized by MainActivity.
|
||||||
|
private static native void nativeReady(String authority, String filesDir);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreate() {
|
||||||
|
String authority = getContext().getPackageName() + ".devlog";
|
||||||
|
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||||
|
matcher.addURI(authority, "lines", LINES);
|
||||||
|
matcher.addURI(authority, "status", STATUS);
|
||||||
|
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cursor query(
|
||||||
|
Uri uri,
|
||||||
|
String[] projection,
|
||||||
|
String selection,
|
||||||
|
String[] selectionArgs,
|
||||||
|
String sortOrder) {
|
||||||
|
switch (matcher.match(uri)) {
|
||||||
|
case LINES:
|
||||||
|
return lines(sinceOf(uri));
|
||||||
|
case STATUS:
|
||||||
|
return status();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long sinceOf(Uri uri) {
|
||||||
|
String since = uri.getQueryParameter("since");
|
||||||
|
if (since == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(since);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Cursor lines(long since) {
|
||||||
|
String[] fields = nativeLinesSince(since);
|
||||||
|
if (fields == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(LINE_COLUMNS, fields.length / FIELDS_PER_LINE);
|
||||||
|
for (int at = 0; at + FIELDS_PER_LINE <= fields.length; at += FIELDS_PER_LINE) {
|
||||||
|
cursor.addRow(
|
||||||
|
new Object[] {
|
||||||
|
Long.parseLong(fields[at]),
|
||||||
|
Long.parseLong(fields[at + 1]),
|
||||||
|
fields[at + 2],
|
||||||
|
fields[at + 3],
|
||||||
|
fields[at + 4],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Cursor status() {
|
||||||
|
String[] fields = nativeStatus();
|
||||||
|
if (fields == null || fields.length != STATUS_COLUMNS.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(STATUS_COLUMNS, 1);
|
||||||
|
cursor.addRow(
|
||||||
|
new Object[] {
|
||||||
|
Long.parseLong(fields[0]), Long.parseLong(fields[1]), Long.parseLong(fields[2]),
|
||||||
|
});
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType(Uri uri) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Uri insert(Uri uri, ContentValues values) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("this app's log is read-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.Gravity;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This path must not depend on the renderer that failed to initialize.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.iris.android.demo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsAnimation;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class MainActivity extends Activity {
|
||||||
|
static {
|
||||||
|
System.loadLibrary("ai_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native void nativeSetFilesDir(String path);
|
||||||
|
|
||||||
|
private static native void nativeEnroll(String uri);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
nativeSetFilesDir(getFilesDir().getAbsolutePath());
|
||||||
|
handleEnrollmentIntent(getIntent());
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Edge-to-edge makes IME-only changes produce fresh inset dispatches.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
getWindow().setDecorFitsSystemWindows(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static dispatch supplies settled insets; the animation callback
|
||||||
|
// supplies intermediate IME heights. An interrupted animation may
|
||||||
|
// omit its final progress frame, so onEnd re-reads the root insets.
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
// Keep getIntent() consistent with the enrollment being handled.
|
||||||
|
setIntent(intent);
|
||||||
|
handleEnrollmentIntent(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleEnrollmentIntent(Intent intent) {
|
||||||
|
if (intent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Uri data = intent.getData();
|
||||||
|
if (data != null) {
|
||||||
|
nativeEnroll(data.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendInsets(IrisView view, WindowInsets insets) {
|
||||||
|
int left = insets.getSystemWindowInsetLeft();
|
||||||
|
int top = insets.getSystemWindowInsetTop();
|
||||||
|
int right = insets.getSystemWindowInsetRight();
|
||||||
|
int bottom = insets.getSystemWindowInsetBottom();
|
||||||
|
// Visibility and height disagree during IME animation, so neither
|
||||||
|
// can be inferred from the other.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
+2
-6
@@ -16,12 +16,8 @@ import android.view.inputmethod.InputMethodManager;
|
|||||||
|
|
||||||
public abstract class RustView extends SurfaceView
|
public abstract class RustView extends SurfaceView
|
||||||
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
|
||||||
// Vendored from android-view (bec6c62, https://github.com/rust-mobile/android-view)
|
// Vendored from android-view bec6c62. The only local change is `protected`,
|
||||||
// with one deliberate change: `protected` rather than package-private, so a
|
// allowing IrisView to forward insets through this native peer.
|
||||||
// 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;
|
protected final long mViewPeer;
|
||||||
final InputMethodManager mInputMethodManager;
|
final InputMethodManager mInputMethodManager;
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Builds the Android app end to end: the cdylib (cargo ndk from this
|
||||||
|
# directory, straight into android-project/app/src/main/jniLibs/) then the
|
||||||
|
# APK (Gradle, from android-project/). 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`, and
|
||||||
|
# nothing should add it back for the emulator's sake.
|
||||||
|
#
|
||||||
|
# **The emulator does not need a GLES build, because it has no hardware
|
||||||
|
# Vulkan to be steered away from** (docs/RUST.md, "What the emulator
|
||||||
|
# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader
|
||||||
|
# in software, its GLES is the host's real GPU through virgl, and iris's
|
||||||
|
# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on
|
||||||
|
# `Backends::GL` -- takes an ordinary build there by itself. So the
|
||||||
|
# emulator and the phone run the *same binary* and differ only in what
|
||||||
|
# that binary finds, which is the whole point: a build flag that changed
|
||||||
|
# the backend would mean the thing measured here is not the thing
|
||||||
|
# shipped.
|
||||||
|
#
|
||||||
|
# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile
|
||||||
|
# time for a backend-isolation measurement (RUST.md's I5, "Where iris's
|
||||||
|
# frame time goes"), and the desktop is the better place to run it now
|
||||||
|
# (`run-headless.sh ... --features iris/force-gles`). 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 -- the named hypothesis in RUST.md's P0 box
|
||||||
|
# ("iris bench crash on the phone, 2026-09-06"). Never pass it 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 android-project/app/src/main/jniLibs
|
||||||
|
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
|
||||||
|
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
|
||||||
|
# build that switches ABI packages the previous ABI: an `--abi x86_64`
|
||||||
|
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
|
||||||
|
# aborted at startup with `Could not get adapter!: NotFound {
|
||||||
|
# active_backends: VULKAN }` under libndk_translation -- which reads
|
||||||
|
# exactly like the phone's own Vulkan problem and is nothing of the kind.
|
||||||
|
# Scoped to the merge task's directory rather than all of `app/build`, so
|
||||||
|
# an ABI change costs the native merge and not the whole Gradle build.
|
||||||
|
rm -rf android-project/app/build/intermediates/merged_native_libs \
|
||||||
|
android-project/app/build/intermediates/stripped_native_libs \
|
||||||
|
android-project/app/build/intermediates/merged_jni_libs
|
||||||
|
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 android-project/app/src/main/jniLibs/ build --lib \
|
||||||
|
--profile android-release --no-default-features --features "$FEATURES"
|
||||||
|
else
|
||||||
|
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
|
||||||
|
--profile android-dev --no-default-features --features "$FEATURES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
GRADLE_TASK="assembleDebug"
|
||||||
|
APK_DIR="android-project/app/build/outputs/apk/debug"
|
||||||
|
APK_NAME="app-debug.apk"
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
GRADLE_TASK="assembleRelease"
|
||||||
|
APK_DIR="android-project/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
|
||||||
|
|
||||||
|
(cd android-project && 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,102 @@
|
|||||||
|
use iris::prelude::*;
|
||||||
|
use winit::{dpi::PhysicalSize, window::WindowAttributes};
|
||||||
|
|
||||||
|
fn ime_argv() -> Option<f32> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--ime" {
|
||||||
|
return args.next()?.parse().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_argv() -> Option<String> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--message" {
|
||||||
|
return Some(args.next()?.replace("\\n", "\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn typed_argv() -> Option<String> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--typed" {
|
||||||
|
return Some(args.next()?.replace("\\n", "\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
DesktopApp::<Client>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DesktopUiState)]
|
||||||
|
pub struct Client {
|
||||||
|
ui_state: DesktopUiState,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
screen: Option<ai_app::ui::TranscriptScreen>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopAppState for Client {
|
||||||
|
fn window_attributes() -> WindowAttributes {
|
||||||
|
WindowAttributes::default()
|
||||||
|
.with_title("iris transcript (bench fixture)")
|
||||||
|
.with_inner_size(PhysicalSize::new(
|
||||||
|
ai_app::ui::fixture::PHONE_WIDTH,
|
||||||
|
ai_app::ui::fixture::PHONE_HEIGHT,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DesktopUiState,
|
||||||
|
rsc: &mut DesktopRsc<Self>,
|
||||||
|
_: Proxy<Self::Event>,
|
||||||
|
) -> Self {
|
||||||
|
let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) {
|
||||||
|
Ok(opened) => {
|
||||||
|
if let Some(message) = message_argv() {
|
||||||
|
opened.screen.composer.field.edit(rsc).set(&message);
|
||||||
|
}
|
||||||
|
if let Some(text) = typed_argv() {
|
||||||
|
let field = opened.screen.composer.field;
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
for ch in text.chars() {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
ctx.update(move |state: &mut Client, rsc| {
|
||||||
|
state.set_focus(Some(field));
|
||||||
|
let end = rsc[field].text().len();
|
||||||
|
let mut edit = field.edit(rsc);
|
||||||
|
if edit.text.caret().is_none() {
|
||||||
|
edit.set_cursor_byte(end);
|
||||||
|
}
|
||||||
|
edit.insert(&ch.to_string());
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(inset) = ime_argv() {
|
||||||
|
opened.screen.composer.set_bottom_inset(rsc, inset);
|
||||||
|
}
|
||||||
|
Some(opened.screen)
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
|
||||||
|
.color(PaintId::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(dp(16))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
ui_state.set_root(rsc, text);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self { ui_state, screen }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
use ai_app::client::QuestionOption;
|
||||||
|
use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
DesktopApp::<Client>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DesktopUiState)]
|
||||||
|
pub struct Client {
|
||||||
|
ui_state: DesktopUiState,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
screen: ai_app::ui::TranscriptScreen,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
|
||||||
|
FoldedRow::Single(if from_user {
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
|
||||||
|
tool_call_in("run1", id, tool, input, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call_in(
|
||||||
|
run: &str,
|
||||||
|
id: &str,
|
||||||
|
tool: &str,
|
||||||
|
input: &str,
|
||||||
|
result: Option<(&str, bool)>,
|
||||||
|
) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq: 3,
|
||||||
|
id: id.into(),
|
||||||
|
run_id: run.into(),
|
||||||
|
tool: tool.into(),
|
||||||
|
input: input.into(),
|
||||||
|
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
|
||||||
|
done: result.is_some(),
|
||||||
|
failed: result.is_some_and(|(_, failed)| failed),
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
|
||||||
|
let mut call = tool_call_in("run2", id, tool, input, None);
|
||||||
|
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
|
||||||
|
asks.push(QuestionCard {
|
||||||
|
seq: 9,
|
||||||
|
id: format!("{id}-q"),
|
||||||
|
prompt: "Allow this command?".into(),
|
||||||
|
header: None,
|
||||||
|
options: vec![
|
||||||
|
QuestionOption {
|
||||||
|
label: "Allow".into(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
QuestionOption {
|
||||||
|
label: "Deny".into(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multi_select: false,
|
||||||
|
answers: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
call
|
||||||
|
}
|
||||||
|
|
||||||
|
fn long_output() -> String {
|
||||||
|
(0..200)
|
||||||
|
.map(|i| format!("test ai_app::ui::case_{i} ... ok"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic_rows() -> Vec<FoldedRow> {
|
||||||
|
vec and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
|
||||||
|
),
|
||||||
|
FoldedRow::Tools(vec![
|
||||||
|
tool_call(
|
||||||
|
"t1",
|
||||||
|
"Read",
|
||||||
|
r#"{"file_path": "src/main.rs"}"#,
|
||||||
|
Some(("fn main() {}\n", false)),
|
||||||
|
),
|
||||||
|
tool_call(
|
||||||
|
"t2",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
|
||||||
|
Some((
|
||||||
|
"error: could not compile `iris`\nCaused by: linker not found",
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
|
||||||
|
]),
|
||||||
|
FoldedRow::Single(tool_call(
|
||||||
|
"t5",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
|
||||||
|
Some((&long_output(), false)),
|
||||||
|
)),
|
||||||
|
msg(6, true, "Looks good, thanks!"),
|
||||||
|
msg(7, false, BLOCK_SAMPLER),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK_SAMPLER: &str = "\
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Iris **fold** render measure session window anchor context transcript \
|
||||||
|
iris measure iris scroll call transcript layout *cursor* context, and a \
|
||||||
|
[bench](https://example.com/bench) link.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||||
|
let mut out = items;
|
||||||
|
out.push(Item::new(seq));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| column | value |
|
||||||
|
|---|---|
|
||||||
|
| a | measure place draw tool call token context window anchor |
|
||||||
|
|
||||||
|
- one bullet
|
||||||
|
- another, with `inline code`
|
||||||
|
- nested one level
|
||||||
|
1. first numbered
|
||||||
|
2. second numbered
|
||||||
|
|
||||||
|
> A quoted line, to show the bar and the indent.
|
||||||
|
";
|
||||||
|
|
||||||
|
impl DesktopAppState for Client {
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DesktopUiState,
|
||||||
|
rsc: &mut DesktopRsc<Self>,
|
||||||
|
_: Proxy<Self::Event>,
|
||||||
|
) -> Self {
|
||||||
|
let mut screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
|
||||||
|
screen.push_row(
|
||||||
|
rsc,
|
||||||
|
&FoldedRow::Single(TranscriptItem::CommandRow {
|
||||||
|
seq: 8,
|
||||||
|
text: "clear".into(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
screen.push_row(
|
||||||
|
rsc,
|
||||||
|
&FoldedRow::Tools(vec![
|
||||||
|
tool_call_in(
|
||||||
|
"run2",
|
||||||
|
"t6",
|
||||||
|
"Read",
|
||||||
|
r#"{"file_path": "docs/RUST.md"}"#,
|
||||||
|
Some(("# Moving the app to Rust\n", false)),
|
||||||
|
),
|
||||||
|
tool_call_in(
|
||||||
|
"run2",
|
||||||
|
"t7",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
|
||||||
|
Some(("error: unused variable `x`", true)),
|
||||||
|
),
|
||||||
|
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
|
||||||
|
asking(
|
||||||
|
"t9",
|
||||||
|
"Bash",
|
||||||
|
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
screen.set_session_working(rsc, true);
|
||||||
|
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(rsc, true),
|
||||||
|
"the newest row must be the tool run this flag is about"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Self { ui_state, screen }
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/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
|
||||||
|
# (android-project/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 android-project/app/build/outputs/apk/release/app-release.apk ]; then
|
||||||
|
APK=android-project/app/build/outputs/apk/release/app-release.apk
|
||||||
|
else
|
||||||
|
APK=android-project/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
|
||||||
|
|
||||||
|
# Which adapter drew, before any number is printed. The emulator is a GLES
|
||||||
|
# machine -- its guest has no hardware Vulkan (docs/RUST.md, "What the
|
||||||
|
# emulator gives a GPU app") -- so iris's runtime fallback lands on `Gl`,
|
||||||
|
# and `Gl (... virgl ...)` is the host's real GPU while `Gl (...
|
||||||
|
# SwiftShader ...)` is the CPU. Those two produce frame times an order of
|
||||||
|
# magnitude apart and are otherwise indistinguishable in this report, so
|
||||||
|
# the line is printed rather than left in logcat for somebody to think of.
|
||||||
|
ADAPTER=$(adb -s "$SERIAL" logcat -d -s iris-android-app:I 2>/dev/null \
|
||||||
|
| sed -n 's/.*\(iris renderer: .*\)/\1/p' | tail -1)
|
||||||
|
if [ -n "$ADAPTER" ]; then
|
||||||
|
echo "run-bench.sh: $ADAPTER"
|
||||||
|
else
|
||||||
|
echo "run-bench.sh: no 'iris renderer:' line in logcat -- cannot say what drew this run" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
|
components = ["clippy", "rustfmt"]
|
||||||
|
targets = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! The platform half of this app's logging: what
|
||||||
|
//! `crate::client::log_ring` needs that only Android can supply, which is
|
||||||
|
//! `android_logger` as the logger to forward to and nothing else.
|
||||||
|
|
||||||
|
use crate::client::log_ring::{self, LogRing};
|
||||||
|
use std::{
|
||||||
|
fs, panic,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::OnceLock,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Installs the in-process ring in front of Android's logger.
|
||||||
|
pub fn install(max_level: log::LevelFilter) {
|
||||||
|
let inner = android_logger::AndroidLogger::new(
|
||||||
|
android_logger::Config::default()
|
||||||
|
.with_max_level(max_level)
|
||||||
|
.with_tag("iris-android-app"),
|
||||||
|
);
|
||||||
|
if log_ring::install_process_logger(
|
||||||
|
Box::new(inner),
|
||||||
|
max_level,
|
||||||
|
iris::diagnostics::trace_enabled,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
log::warn!("iris app log: a logger was already installed, so there is no ring");
|
||||||
|
}
|
||||||
|
install_panic_hook();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ring() -> &'static LogRing {
|
||||||
|
log_ring::process_ring()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub fn diagnostics_line() -> String {
|
||||||
|
let where_to_read = match crate::android::devlog::authority() {
|
||||||
|
Some(authority) => format!("devlog provider: content://{authority}"),
|
||||||
|
None => "devlog provider: declared, not created yet".to_string(),
|
||||||
|
};
|
||||||
|
format!("{}\n{where_to_read}", ring().summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the panic hook leaves its report, under the app's private
|
||||||
|
/// directory. Read back and dropped by [`set_crash_dir`] on the next
|
||||||
|
/// start.
|
||||||
|
const CRASH_FILE: &str = "last-panic.txt";
|
||||||
|
|
||||||
|
/// Enough preceding log lines to explain a crash without evicting the next run.
|
||||||
|
const CRASH_CONTEXT_LINES: usize = 80;
|
||||||
|
|
||||||
|
const PREVIOUS_RUN_TARGET: &str = "previous_run";
|
||||||
|
|
||||||
|
static CRASH_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Copies aborting panics into the device-readable log ring.
|
||||||
|
fn install_panic_hook() {
|
||||||
|
let previous = panic::take_hook();
|
||||||
|
panic::set_hook(Box::new(move |info| {
|
||||||
|
let where_at = match info.location() {
|
||||||
|
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
|
||||||
|
None => "an unknown location".to_string(),
|
||||||
|
};
|
||||||
|
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
|
||||||
|
let line = format!("iris panic at {where_at}: {message}");
|
||||||
|
log::error!("{line}");
|
||||||
|
if let Some(path) = CRASH_PATH.get() {
|
||||||
|
let context = ring()
|
||||||
|
.try_tail_text(CRASH_CONTEXT_LINES)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
"(the log ring was locked as this run died; no context)".to_string()
|
||||||
|
});
|
||||||
|
let _ = fs::write(path, format!("{line}\n{context}"));
|
||||||
|
}
|
||||||
|
previous(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures crash persistence and replays a report left by the previous run.
|
||||||
|
pub fn set_crash_dir(dir: &Path) {
|
||||||
|
let path = dir.join(CRASH_FILE);
|
||||||
|
if let Ok(previous) = fs::read_to_string(&path) {
|
||||||
|
// Delete first so a panic during replay cannot create a replay loop.
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
replay_crash(&previous);
|
||||||
|
}
|
||||||
|
let _ = CRASH_PATH.set(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a previous run's report back in the ring: its context lines in
|
||||||
|
/// the order they happened, then the panic itself.
|
||||||
|
fn replay_crash(report: &str) {
|
||||||
|
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
|
||||||
|
for line in context.lines().filter(|line| !line.is_empty()) {
|
||||||
|
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
|
||||||
|
}
|
||||||
|
log::error!(
|
||||||
|
"iris app log: the previous run died -- {}",
|
||||||
|
panic_line.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,888 @@
|
|||||||
|
use crate::android::bench_jni::PlatformHandle;
|
||||||
|
use crate::client::transcript_fold::{TranscriptItem, fold_event};
|
||||||
|
use crate::ui::{self, TranscriptScreen};
|
||||||
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::{
|
||||||
|
fs, mem,
|
||||||
|
sync::{
|
||||||
|
Arc, Mutex,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
mpsc,
|
||||||
|
},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||||
|
const STREAM_SECONDS: u64 = 20;
|
||||||
|
|
||||||
|
const LEGACY_CYCLES: usize = 6;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const KEYBOARD_CYCLES: usize = 5;
|
||||||
|
const KEYBOARD_WAIT_MS: u64 = 1_000;
|
||||||
|
|
||||||
|
const POLL_MS: u64 = 16;
|
||||||
|
|
||||||
|
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
|
||||||
|
|
||||||
|
pub struct BenchClient {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
content: WeakWidget<WidgetPtr>,
|
||||||
|
report_display: WeakWidget<TextEdit>,
|
||||||
|
top_bar: WeakWidget<WidgetPtr>,
|
||||||
|
screen: Option<TranscriptScreen>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
stream_tail: Vec<SeqEvent>,
|
||||||
|
platform: Option<Arc<PlatformHandle>>,
|
||||||
|
last_report: Option<String>,
|
||||||
|
running: bool,
|
||||||
|
ime_state: Arc<Mutex<ImeState>>,
|
||||||
|
keyboard_was_visible: bool,
|
||||||
|
last_top_pad: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(PaintId::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peak_rss_kb() -> Option<u64> {
|
||||||
|
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;
|
||||||
|
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(PaintId::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);
|
||||||
|
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(rsc, tree);
|
||||||
|
|
||||||
|
let font = rsc.ui.text.font_diagnostics();
|
||||||
|
log::info!(
|
||||||
|
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
|
||||||
|
bold={:?} italic={:?} mono={:?}, icons={:?}",
|
||||||
|
font.families_found,
|
||||||
|
font.default_family,
|
||||||
|
font.default_mono_family,
|
||||||
|
font.regular_resolved,
|
||||||
|
font.bold_resolved,
|
||||||
|
font.italic_resolved,
|
||||||
|
font.mono_resolved,
|
||||||
|
font.icon_family,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 ui::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>) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||||
|
|
||||||
|
type Rsc = AndroidRsc<BenchClient>;
|
||||||
|
|
||||||
|
/// What a report says about the `iris::input`/`iris::frame` trace, from
|
||||||
|
/// the flag read at the start of what is being reported and again at the
|
||||||
|
/// end.
|
||||||
|
///
|
||||||
|
/// Three answers rather than two. Those lines are default-off and the
|
||||||
|
/// switch that turns them on is on screen while a benchmark runs, so
|
||||||
|
/// "somebody moved it half way through" is a state that actually happens
|
||||||
|
/// -- and reported as either "on" or "off" it is a confident sentence
|
||||||
|
/// about a log that only covers part of the run. The "on" wording also
|
||||||
|
/// says what it costs, because a traced run fills the ring in seconds and
|
||||||
|
/// a reader looking at a log with nothing else in it should know why.
|
||||||
|
fn trace_line(at_start: bool, at_end: bool) -> String {
|
||||||
|
match (at_start, at_end) {
|
||||||
|
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
|
||||||
|
the app log, and a traced run fills the ring in seconds)"
|
||||||
|
.to_string(),
|
||||||
|
(false, false) => "input/frame trace: off".to_string(),
|
||||||
|
_ => "input/frame trace: switched during this run, so those lines cover only part \
|
||||||
|
of it"
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: Srgba8 = Srgba8::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.
|
||||||
|
const HEADER_TEXT: f32 = 18.0;
|
||||||
|
|
||||||
|
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
|
||||||
|
|
||||||
|
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||||
|
let run_rect = rect(Srgba8::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(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.copy_report(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Copy report");
|
||||||
|
let copy = (
|
||||||
|
copy_rect,
|
||||||
|
wtext("Copy report")
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let diag_rect = rect(Srgba8::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(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
// A switch rather than a button, so its own appearance says which
|
||||||
|
// state it is in: the two `iris::input`/`iris::frame` targets are
|
||||||
|
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
|
||||||
|
// session fills the 2000-line ring in seconds, so "is it on right
|
||||||
|
// now" is the question somebody has while looking at a log that is
|
||||||
|
// either full of trace or has none.
|
||||||
|
let tracing = iris::diagnostics::trace_enabled();
|
||||||
|
let trace_rect = rect(if tracing {
|
||||||
|
Srgba8::rgb(90, 70, 30)
|
||||||
|
} else {
|
||||||
|
Srgba8::rgb(50, 50, 60)
|
||||||
|
})
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.toggle_trace(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Trace input and frames");
|
||||||
|
let trace = (
|
||||||
|
trace_rect,
|
||||||
|
wtext(if tracing { "Trace on" } else { "Trace off" })
|
||||||
|
.size(HEADER_TEXT)
|
||||||
|
.text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(dp(8))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
|
||||||
|
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
|
||||||
|
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
|
||||||
|
|
||||||
|
(rect(HEADER_SURFACE), buttons)
|
||||||
|
.stack()
|
||||||
|
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
|
||||||
|
.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) = ui::build_tree(rsc, ui::fixture::rows(&self.items));
|
||||||
|
(self.content)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
|
||||||
|
/// switch that says so, and shows the pane that now reports it.
|
||||||
|
fn toggle_trace(&mut self, rsc: &mut Rsc) {
|
||||||
|
let on = !iris::diagnostics::trace_enabled();
|
||||||
|
iris::diagnostics::set_trace(on);
|
||||||
|
log::info!(
|
||||||
|
"iris diagnostics: input/frame trace {}",
|
||||||
|
if on { "on" } else { "off" }
|
||||||
|
);
|
||||||
|
let controls = bench_controls(rsc, self.last_top_pad);
|
||||||
|
(self.top_bar)(rsc).set(controls);
|
||||||
|
self.show_diagnostics(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
};
|
||||||
|
// Insets must be visible without adb so a missing callback can be
|
||||||
|
// distinguished from a callback reporting zero IME height.
|
||||||
|
format!(
|
||||||
|
"{renderer}\n{}\n{}\n{}\n{}",
|
||||||
|
trace_line(
|
||||||
|
iris::diagnostics::trace_enabled(),
|
||||||
|
iris::diagnostics::trace_enabled()
|
||||||
|
),
|
||||||
|
self.android_state().insets_report(),
|
||||||
|
// Which server this build talks to, and what to do when the
|
||||||
|
// answer is "none" -- the bench itself opens a checked-in
|
||||||
|
// fixture and needs no server, so this pane is the only place
|
||||||
|
// an enrolment can be seen to have taken.
|
||||||
|
crate::android::enrollment::status_line(),
|
||||||
|
crate::android::app_log::diagnostics_line()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, rsc: &mut Rsc) {
|
||||||
|
let Some(platform) = &self.platform else {
|
||||||
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let report = match self.last_report.clone() {
|
||||||
|
Some(report) => report,
|
||||||
|
None => format!(
|
||||||
|
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
|
||||||
|
self.diagnostics_text(rsc)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
|
||||||
|
let cpu_start = process_cpu_ms();
|
||||||
|
// Read at the start as well as the end, because the switch is on
|
||||||
|
// screen while a run is going: a report that only asked afterwards
|
||||||
|
// would say "on" about a run whose first half has no trace in it
|
||||||
|
// -- the inferred answer presented as the measured one.
|
||||||
|
let trace_at_start = iris::diagnostics::trace_enabled();
|
||||||
|
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 drawn_hz = state.android_state().frame_report.sustained_frame_hz();
|
||||||
|
let refresh_hz = match (drawn_hz, platform_hz) {
|
||||||
|
(Some(d), Some(p)) => d.max(p),
|
||||||
|
(Some(d), None) => d,
|
||||||
|
(None, Some(p)) => p,
|
||||||
|
(None, None) => 60.0,
|
||||||
|
};
|
||||||
|
let hz_line = match (drawn_hz, platform_hz) {
|
||||||
|
(Some(d), Some(p)) if d > p + 5.0 => format!(
|
||||||
|
" (sustained {d:.0}fps, so at least that; the display reported {p:.0}Hz)"
|
||||||
|
),
|
||||||
|
(Some(d), Some(_)) => format!(" (as the display reports it; drew {d:.0}fps)"),
|
||||||
|
(Some(d), None) => {
|
||||||
|
format!(" (sustained {d:.0}fps; the display would not say)")
|
||||||
|
}
|
||||||
|
(None, Some(_)) => {
|
||||||
|
" (as the display reports it; too few frames to measure)".to_string()
|
||||||
|
}
|
||||||
|
(None, None) => " (assumed -- neither measured nor reported)".to_string(),
|
||||||
|
};
|
||||||
|
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{hz_line} ({:.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 build_p50 {:.1}ms acquire_p50 \
|
||||||
|
{:.1}ms submit_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.acquire_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 traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
|
||||||
|
let report = format!(
|
||||||
|
"iris bench report\n{traced}\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 `POLL_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) = 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(POLL_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(POLL_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);
|
||||||
|
animate_scroll(screen.list, rsc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
animate_scroll(screen.list, rsc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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} ticked=frame-loop")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
|
||||||
|
let id = scroll.id();
|
||||||
|
rsc.ui_mut().animate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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).is_scrolling(),
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if !still_scrolling {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.as_mut() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
(sent, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn type_text_is_exactly_600_characters() {
|
||||||
|
assert_eq!(TYPE_TEXT.chars().count(), 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,3 @@
|
|||||||
//! 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, and `ClipboardManager.setPrimaryClip` for
|
|
||||||
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is
|
|
||||||
//! 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 two one-off
|
|
||||||
//! 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::{
|
use android_view::jni::{
|
||||||
JNIEnv, JavaVM,
|
JNIEnv, JavaVM,
|
||||||
objects::{GlobalRef, JObject, JValue},
|
objects::{GlobalRef, JObject, JValue},
|
||||||
@@ -66,13 +48,6 @@ impl PlatformHandle {
|
|||||||
.ok()
|
.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> {
|
pub fn battery_current_ua(&self) -> Option<i32> {
|
||||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
let env: &mut JNIEnv = &mut guard;
|
let env: &mut JNIEnv = &mut guard;
|
||||||
@@ -132,30 +107,73 @@ impl PlatformHandle {
|
|||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows `report` in the shell's plain-view diagnostics overlay
|
pub fn refresh_rate_hz(&self) -> Option<f32> {
|
||||||
/// (`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 mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
let env: &mut JNIEnv = &mut guard;
|
let env: &mut JNIEnv = &mut guard;
|
||||||
let jreport = env.new_string(report).ok()?;
|
let display = env
|
||||||
env.call_method(
|
.call_method(
|
||||||
self.view.as_obj(),
|
self.view.as_obj(),
|
||||||
"showDiagnosticsOverlay",
|
"getDisplay",
|
||||||
"(Ljava/lang/String;)V",
|
"()Landroid/view/Display;",
|
||||||
&[JValue::Object(jreport.as_ref())],
|
&[],
|
||||||
)
|
)
|
||||||
.ok()?;
|
.ok()?
|
||||||
Some(())
|
.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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_ime(&self) -> bool {
|
||||||
|
self.try_toggle_ime(true).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! The JNI half of `DevLogProvider`: reading this process's own log ring
|
||||||
|
//! for a `ContentProvider` that Dev Updater queries.
|
||||||
|
|
||||||
|
use android_view::jni::JNIEnv;
|
||||||
|
use android_view::jni::objects::{JClass, JObject, JString};
|
||||||
|
use android_view::jni::sys::{jlong, jobjectArray};
|
||||||
|
use std::{path::Path, ptr, sync::OnceLock};
|
||||||
|
|
||||||
|
/// Gated with its one reader: the tabs demo links no `client-core` and so
|
||||||
|
/// has no ring to lay out, and an ungated constant is a warning in that
|
||||||
|
/// build (`iris-android-app` without `transcript-screen`).
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
const FIELDS_PER_LINE: usize = 5;
|
||||||
|
|
||||||
|
/// The authority the provider registered itself under, once it has been
|
||||||
|
/// created. `None` until then, which is a state worth being able to say:
|
||||||
|
/// a provider Android never instantiated and one that is answering look
|
||||||
|
/// the same from inside this process otherwise.
|
||||||
|
static AUTHORITY: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub fn authority() -> Option<&'static str> {
|
||||||
|
AUTHORITY.get().map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory is taken here as well as in
|
||||||
|
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
|
||||||
|
/// only thing running**: once the app has died, Dev Updater's query
|
||||||
|
/// starts the process for the provider alone, so no activity ever runs
|
||||||
|
/// and the panic hook's file would never be replayed into the ring. That
|
||||||
|
/// is precisely the run whose log is being asked for. Whichever of the
|
||||||
|
/// two arrives first does the replay; `set_crash_dir` deletes the file,
|
||||||
|
/// so the second finds nothing and says nothing.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
authority: JString,
|
||||||
|
files_dir: JString,
|
||||||
|
) {
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
if let Some(dir) = string_arg(&mut env, &files_dir) {
|
||||||
|
crate::android::app_log::set_crash_dir(Path::new(&dir));
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
let _ = &files_dir;
|
||||||
|
let Some(authority) = string_arg(&mut env, &authority) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
log::info!("iris devlog: serving this app's log at content://{authority}");
|
||||||
|
let _ = AUTHORITY.set(authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
|
||||||
|
if value.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
env.get_string(value).ok().map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
|
||||||
|
/// what tells a reader holding a cursor that this process **restarted**:
|
||||||
|
/// the ring is in memory, so a new process starts again at zero and a
|
||||||
|
/// stale cursor would otherwise skip everything silently.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
) -> jobjectArray {
|
||||||
|
string_array(&mut env, &status_fields())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inclusive of `since` because [`crate::client::log_ring::LogRing::since`]
|
||||||
|
/// is, and one definition of the cursor is what keeps the app's own
|
||||||
|
/// uploaded report and this provider describing the same lines.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
since: jlong,
|
||||||
|
) -> jobjectArray {
|
||||||
|
// A negative cursor is a caller asking for everything, not an error to
|
||||||
|
// take the app down over: the provider is a diagnostic.
|
||||||
|
string_array(&mut env, &line_fields(since.max(0) as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
fn status_fields() -> Vec<String> {
|
||||||
|
let ring = crate::client::log_ring::process_ring();
|
||||||
|
vec![
|
||||||
|
ring.len().to_string(),
|
||||||
|
ring.dropped().to_string(),
|
||||||
|
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tabs demo links no `client-core` and keeps no ring, so it holds
|
||||||
|
/// nothing and has never dropped anything -- which is the truth, not a
|
||||||
|
/// stand-in. The natives are still exported there, because a `native`
|
||||||
|
/// method Java declares and the library does not is an
|
||||||
|
/// `UnsatisfiedLinkError` the moment the class loads.
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
fn status_fields() -> Vec<String> {
|
||||||
|
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
fn line_fields(since: u64) -> Vec<String> {
|
||||||
|
let (lines, _next) = crate::client::log_ring::process_ring().since(since);
|
||||||
|
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
|
||||||
|
for line in lines {
|
||||||
|
fields.push(line.seq.to_string());
|
||||||
|
fields.push(line.at_ms.to_string());
|
||||||
|
fields.push(line.level.to_string());
|
||||||
|
fields.push(line.target);
|
||||||
|
fields.push(line.message);
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
fn line_fields(_since: u64) -> Vec<String> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
|
||||||
|
/// reads it as "the provider could not answer" and returns no cursor,
|
||||||
|
/// which Dev Updater already draws as a distinct state. Taking the app
|
||||||
|
/// down to report that its diagnostic is unavailable would be worse than
|
||||||
|
/// the diagnostic being unavailable.
|
||||||
|
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
|
||||||
|
let null = ptr::null_mut();
|
||||||
|
let Ok(class) = env.find_class("java/lang/String") else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
for (index, field) in fields.iter().enumerate() {
|
||||||
|
let Ok(value) = env.new_string(field) else {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
if env
|
||||||
|
.set_object_array_element(&array, index as i32, value)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
array.into_raw()
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#[cfg(not(feature = "bench"))]
|
||||||
|
use crate::client::api::UreqTransport;
|
||||||
|
use crate::client::config::{EnrolledServer, EnrollmentStore};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn set_files_dir(dir: PathBuf) {
|
||||||
|
if let Err(existing) = FILES_DIR.set(dir.clone()) {
|
||||||
|
assert_eq!(
|
||||||
|
existing, dir,
|
||||||
|
"the app's files directory was set twice with different paths"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store() -> Option<EnrollmentStore> {
|
||||||
|
FILES_DIR.get().map(EnrollmentStore::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Status {
|
||||||
|
Enrolled(EnrolledServer),
|
||||||
|
NotEnrolled,
|
||||||
|
/// The question could not be answered -- the activity never handed a
|
||||||
|
/// files directory over, or the file is there and unreadable. Kept
|
||||||
|
/// apart from `NotEnrolled` because the two want different actions
|
||||||
|
/// from whoever is looking.
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status() -> Status {
|
||||||
|
let Some(store) = store() else {
|
||||||
|
return Status::Unknown("the activity never handed over a files directory".to_string());
|
||||||
|
};
|
||||||
|
match store.load() {
|
||||||
|
Ok(Some(server)) => Status::Enrolled(server),
|
||||||
|
Ok(None) => Status::NotEnrolled,
|
||||||
|
Err(error) => Status::Unknown(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for the diagnostics pane. The three states read differently on
|
||||||
|
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
|
||||||
|
/// must not be mistaken for it.
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
pub fn status_line() -> String {
|
||||||
|
match status() {
|
||||||
|
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
|
||||||
|
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
|
||||||
|
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
|
||||||
|
let server = EnrolledServer::parse_link(uri)?;
|
||||||
|
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
|
||||||
|
store
|
||||||
|
.save(&server)
|
||||||
|
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gated to the same builds as `transcript_client`, its only caller: the
|
||||||
|
/// bench build opens a checked-in fixture and reaches no server, so
|
||||||
|
/// compiling this into it would be a warning about dead code that is
|
||||||
|
/// dead on purpose.
|
||||||
|
///
|
||||||
|
/// Every failure here is a sentence a screen can show, because there is
|
||||||
|
/// nowhere else for it to go: this app has no `logcat` on the phone it is
|
||||||
|
/// built for.
|
||||||
|
#[cfg(not(feature = "bench"))]
|
||||||
|
pub fn transport() -> Result<UreqTransport, String> {
|
||||||
|
let server = match status() {
|
||||||
|
Status::Enrolled(server) => server,
|
||||||
|
Status::NotEnrolled => {
|
||||||
|
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
|
||||||
|
}
|
||||||
|
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
|
||||||
|
};
|
||||||
|
let ca_pem = server.ca_pem.as_ref().ok_or(
|
||||||
|
"The enrollment link carried no CA, so there is nothing to pin. \
|
||||||
|
Enrol again with a link minted by this server.",
|
||||||
|
)?;
|
||||||
|
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
|
||||||
|
.map_err(|error| error.message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
use android_view::{
|
||||||
|
Context, View,
|
||||||
|
jni::{
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
objects::{JClass, JString},
|
||||||
|
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};
|
||||||
|
use log::LevelFilter;
|
||||||
|
use std::{
|
||||||
|
ffi::c_void,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The app's own log ring and its upload -- only where `client-core` is
|
||||||
|
/// linked, which is every build that has a server to send to. The plain
|
||||||
|
/// tabs demo keeps `android_logger` alone, as it always had.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
mod app_log;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_client;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_jni;
|
||||||
|
mod devlog;
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
mod enrollment;
|
||||||
|
#[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 `DesktopAppState::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>) -> bool {
|
||||||
|
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 {
|
||||||
|
// The ring in front of `android_logger` where there is one (see
|
||||||
|
// `app_log`), and `android_logger` alone otherwise. Both install the
|
||||||
|
// same tag and level, so `logcat` cannot tell the two builds apart --
|
||||||
|
// the ring only adds a second reader.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
app_log::install(LevelFilter::Debug);
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
|
||||||
|
/// over before the view exists because that is where the enrollment is
|
||||||
|
/// read from and written to (`enrollment`'s module doc).
|
||||||
|
///
|
||||||
|
/// Exported by name rather than registered through `RegisterNatives`: the
|
||||||
|
/// view's methods are registered because `android-view` owns that class
|
||||||
|
/// and hands out one function pointer, whereas these two are this app's
|
||||||
|
/// own activity and the mangled name is the whole of what is needed.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
dir: JString,
|
||||||
|
) {
|
||||||
|
let Some(dir) = jstring(&mut env, dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
{
|
||||||
|
app_log::set_crash_dir(Path::new(&dir));
|
||||||
|
enrollment::set_files_dir(PathBuf::from(&dir));
|
||||||
|
}
|
||||||
|
log::debug!("iris app: files directory is {dir}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
uri: JString,
|
||||||
|
) {
|
||||||
|
let Some(uri) = jstring(&mut env, uri) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
match enrollment::apply_link(&uri) {
|
||||||
|
// Never the token: `wg-app-link`'s enroll module forbids logging
|
||||||
|
// it, and this line would otherwise be the one place it leaked.
|
||||||
|
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
|
||||||
|
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
|
||||||
|
if value.is_null() {
|
||||||
|
log::warn!("iris app: the activity passed a null string across JNI");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match env.get_string(&value) {
|
||||||
|
Ok(value) => Some(value.into()),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("iris app: couldn't read a string from the activity -- {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-117
@@ -1,54 +1,19 @@
|
|||||||
//! RUST.md's I5 Android integration: `transcript-ui`'s screen filling the
|
use crate::client::api::{ApiClient, UreqTransport};
|
||||||
//! whole window on android-view, against a real `ai-server` through
|
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||||
//! `client-core` -- the missing half `iris-android-app` (I2) only had for
|
use crate::client::transcript_fold::{
|
||||||
//! `tabs-ui` until now. Behind the `transcript-screen` Cargo feature so the
|
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
|
||||||
//! plain build (`cargo ndk build`, no `--features`) stays exactly the tabs
|
};
|
||||||
//! demo I2/I4 already measured against.
|
use crate::ui::{self, TranscriptScreen};
|
||||||
//!
|
|
||||||
//! **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 event_model::SeqEvent;
|
||||||
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
sync::{
|
||||||
|
Arc,
|
||||||
mod pinned {
|
atomic::{AtomicU64, Ordering},
|
||||||
include!(concat!(env!("OUT_DIR"), "/pinned_config.rs"));
|
},
|
||||||
}
|
thread,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct TranscriptClient {
|
pub struct TranscriptClient {
|
||||||
ui_state: AndroidUiState,
|
ui_state: AndroidUiState,
|
||||||
@@ -58,21 +23,9 @@ pub struct TranscriptClient {
|
|||||||
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
|
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
|
||||||
/// by rebuilding the session list beside it.
|
/// by rebuilding the session list beside it.
|
||||||
content: WeakWidget<WidgetPtr>,
|
content: WeakWidget<WidgetPtr>,
|
||||||
screen: Option<transcript_ui::TranscriptScreen>,
|
screen: Option<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>,
|
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>,
|
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>,
|
generation: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,41 +38,22 @@ impl HasAndroidUiState for TranscriptClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds one `UreqTransport` from the config `build.rs` baked in. Called
|
|
||||||
/// twice per session load, same as `desktop-app`'s `build_transport`
|
|
||||||
/// closure -- `ApiClient` and the live-stream follow each need their own,
|
|
||||||
/// since `UreqTransport` holds its own `ureq::Agent`.
|
|
||||||
fn build_transport() -> Result<UreqTransport, String> {
|
fn build_transport() -> Result<UreqTransport, String> {
|
||||||
let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT);
|
crate::android::enrollment::transport()
|
||||||
UreqTransport::new(
|
|
||||||
base_url,
|
|
||||||
pinned::TOKEN.to_string(),
|
|
||||||
pinned::CA_PEM.as_bytes(),
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
wtext(message.to_string())
|
wtext(message.to_string())
|
||||||
.color(Color::WHITE)
|
.color(PaintId::WHITE)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.pad(16)
|
.pad(16)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any()
|
.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 {
|
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
||||||
type Rsc = AndroidRsc<TranscriptClient>;
|
type Rsc = AndroidRsc<TranscriptClient>;
|
||||||
let report_rect = rect(Color::rgb(50, 50, 60))
|
let report_rect = rect(Srgba8::rgb(50, 50, 60))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
|
||||||
@@ -143,7 +77,7 @@ fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
|
|||||||
.pad(8)
|
.pad(8)
|
||||||
.add(rsc);
|
.add(rsc);
|
||||||
|
|
||||||
let reset_rect = rect(Color::rgb(70, 40, 40))
|
let reset_rect = rect(Srgba8::rgb(70, 40, 40))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||||
@@ -173,7 +107,7 @@ impl AndroidAppState for TranscriptClient {
|
|||||||
.span(Dir::DOWN)
|
.span(Dir::DOWN)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
ui_state.set_root(tree);
|
ui_state.set_root(rsc, tree);
|
||||||
|
|
||||||
let mut client = Self {
|
let mut client = Self {
|
||||||
ui_state,
|
ui_state,
|
||||||
@@ -187,7 +121,7 @@ impl AndroidAppState for TranscriptClient {
|
|||||||
client
|
client
|
||||||
}
|
}
|
||||||
|
|
||||||
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>) -> bool {
|
||||||
// No screen stack of its own -- same "let the activity finish"
|
// No screen stack of its own -- same "let the activity finish"
|
||||||
// answer `iris-android-app`'s tabs `Client` already gives.
|
// answer `iris-android-app`'s tabs `Client` already gives.
|
||||||
false
|
false
|
||||||
@@ -230,10 +164,6 @@ impl TranscriptClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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) {
|
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
|
||||||
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
@@ -260,22 +190,14 @@ impl TranscriptClient {
|
|||||||
};
|
};
|
||||||
let api = ApiClient::new(rest);
|
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
|
let page: Result<Vec<serde_json::Value>, String> = api
|
||||||
.fetch_transcript_page(&session_id, None, 200, true)
|
.fetch_transcript_page(&session_id, None, 200, true)
|
||||||
.map_err(|e| e.to_string());
|
.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
|
let after = page
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|values| values.last())
|
.and_then(|values| values.last())
|
||||||
.and_then(client_core::transcript_fold::raw_seq)
|
.and_then(raw_seq)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let result = page.and_then(|values| fold_page(&values));
|
let result = page.and_then(|values| fold_page(&values));
|
||||||
|
|
||||||
@@ -301,12 +223,6 @@ impl TranscriptClient {
|
|||||||
if live_generation.load(Ordering::SeqCst) != my_generation {
|
if live_generation.load(Ordering::SeqCst) != my_generation {
|
||||||
return;
|
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 _ =
|
let _ =
|
||||||
follow_session_events(
|
follow_session_events(
|
||||||
&stream_transport,
|
&stream_transport,
|
||||||
@@ -335,10 +251,6 @@ impl TranscriptClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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>) {
|
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
|
||||||
let in_progress = self
|
let in_progress = self
|
||||||
.screen
|
.screen
|
||||||
@@ -347,7 +259,7 @@ impl TranscriptClient {
|
|||||||
.filter(|t| !t.is_empty());
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
let rows = group_tool_runs(&self.items);
|
let rows = group_tool_runs(&self.items);
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
let (screen, tree) = ui::build_tree(rsc, rows);
|
||||||
|
|
||||||
if let Some(text) = in_progress {
|
if let Some(text) = in_progress {
|
||||||
screen.composer.field.edit(rsc).set(&text);
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
@@ -370,19 +282,14 @@ impl TranscriptClient {
|
|||||||
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
|
||||||
let old_items = self.items.clone();
|
let old_items = self.items.clone();
|
||||||
self.items = fold_event(&self.items, event);
|
self.items = fold_event(&self.items, event);
|
||||||
match &self.screen {
|
match self.screen.as_mut() {
|
||||||
// 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),
|
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),
|
None => self.rebuild_transcript(rsc),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_message(&mut self, session_id: String, text: String) {
|
fn send_message(&mut self, session_id: String, text: String) {
|
||||||
std::thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
if let Ok(transport) = build_transport() {
|
if let Ok(transport) = build_transport() {
|
||||||
let api = ApiClient::new(transport);
|
let api = ApiClient::new(transport);
|
||||||
let _ = api.send_message(&session_id, &text, &[]);
|
let _ = api.send_message(&session_id, &text, &[]);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use ai_app::desktop::{app, startup};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(e) = startup::load_startup_config() {
|
||||||
|
eprintln!("desktop-app: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
app::run();
|
||||||
|
}
|
||||||
@@ -1,25 +1,3 @@
|
|||||||
//! 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;
|
use std::ops::Range;
|
||||||
|
|
||||||
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||||
@@ -38,18 +16,10 @@ impl Rgb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AnsiPalette {
|
pub struct AnsiPalette {
|
||||||
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
|
||||||
pub colours: [Rgb; 16],
|
pub colours: [Rgb; 16],
|
||||||
/// What uncoloured text is, needed only where a style has to state a colour.
|
|
||||||
pub foreground: Rgb,
|
pub foreground: Rgb,
|
||||||
/// What the text sits on, needed for reverse video.
|
|
||||||
pub background: Rgb,
|
pub background: Rgb,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +37,6 @@ pub struct Style {
|
|||||||
pub strikethrough: 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)]
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
pub struct StyledText {
|
pub struct StyledText {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -87,11 +55,7 @@ impl StyledText {
|
|||||||
const ESC: char = '\u{1B}';
|
const ESC: char = '\u{1B}';
|
||||||
const BELL: char = '\u{7}';
|
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 {
|
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') {
|
if !text.contains(ESC) && !text.contains('\r') {
|
||||||
return StyledText::plain(text.to_string());
|
return StyledText::plain(text.to_string());
|
||||||
}
|
}
|
||||||
@@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
} 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);
|
flush(&mut plain, sgr, &mut runs);
|
||||||
drop_line(&mut runs);
|
drop_line(&mut runs);
|
||||||
at += 1;
|
at += 1;
|
||||||
} else if c == '\r' {
|
} else if c == '\r' {
|
||||||
at += 1;
|
at += 1;
|
||||||
} else if c >= ' ' || c == '\n' || c == '\t' {
|
} 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);
|
plain.push(c);
|
||||||
at += 1;
|
at += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
|||||||
StyledText { text: out, spans }
|
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>)>) {
|
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||||
while let Some((text, style)) = runs.pop() {
|
while let Some((text, style)) = runs.pop() {
|
||||||
if let Some(break_at) = text.rfind('\n') {
|
if let Some(break_at) = text.rfind('\n') {
|
||||||
@@ -162,7 +117,6 @@ fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bytes that end a CSI sequence.
|
|
||||||
fn is_csi_final(c: char) -> bool {
|
fn is_csi_final(c: char) -> bool {
|
||||||
('@'..='~').contains(&c)
|
('@'..='~').contains(&c)
|
||||||
}
|
}
|
||||||
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
end += 1;
|
end += 1;
|
||||||
}
|
}
|
||||||
if end >= chars.len() {
|
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()
|
chars.len()
|
||||||
} else {
|
} else {
|
||||||
let params: String = chars[at + 2..end].iter().collect();
|
let params: String = chars[at + 2..end].iter().collect();
|
||||||
@@ -196,8 +146,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
']' | 'P' | 'X' | '^' | '_' => {
|
']' | 'P' | 'X' | '^' | '_' => {
|
||||||
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
|
||||||
// allows after an OSC.
|
|
||||||
let mut end = at + 2;
|
let mut end = at + 2;
|
||||||
while end < chars.len() {
|
while end < chars.len() {
|
||||||
if chars[end] == BELL {
|
if chars[end] == BELL {
|
||||||
@@ -214,7 +162,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
struct Sgr {
|
struct Sgr {
|
||||||
fg: Option<Rgb>,
|
fg: Option<Rgb>,
|
||||||
@@ -227,7 +174,6 @@ struct Sgr {
|
|||||||
reverse: bool,
|
reverse: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
|
||||||
const DIM_ALPHA: f32 = 0.65;
|
const DIM_ALPHA: f32 = 0.65;
|
||||||
|
|
||||||
impl Sgr {
|
impl Sgr {
|
||||||
@@ -242,7 +188,6 @@ impl Sgr {
|
|||||||
reverse: false,
|
reverse: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
|
||||||
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||||
if *self == Sgr::PLAIN {
|
if *self == Sgr::PLAIN {
|
||||||
return None;
|
return None;
|
||||||
@@ -275,15 +220,7 @@ impl Sgr {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
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
|
let codes: Vec<i64> = params
|
||||||
.split(';')
|
.split(';')
|
||||||
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
||||||
@@ -377,12 +314,6 @@ impl Sgr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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) {
|
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
||||||
match codes.get(at + 1) {
|
match codes.get(at + 1) {
|
||||||
Some(&5) => match codes.get(at + 2) {
|
Some(&5) => match codes.get(at + 2) {
|
||||||
@@ -409,11 +340,8 @@ fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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];
|
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 {
|
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||||
if n < 0 {
|
if n < 0 {
|
||||||
palette.foreground
|
palette.foreground
|
||||||
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
|
||||||
/// white foreground, black background.
|
|
||||||
fn palette() -> AnsiPalette {
|
fn palette() -> AnsiPalette {
|
||||||
let mut colours = [Rgb::new(0, 0, 0); 16];
|
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||||
for (i, c) in colours.iter_mut().enumerate() {
|
for (i, c) in colours.iter_mut().enumerate() {
|
||||||
@@ -452,8 +378,6 @@ mod tests {
|
|||||||
ansi_styled(text, &palette())
|
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> {
|
fn style_over(text: &str, word: &str) -> Option<Style> {
|
||||||
let out = styled(text);
|
let out = styled(text);
|
||||||
let at = out
|
let at = out
|
||||||
@@ -509,8 +433,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
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");
|
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
||||||
assert_eq!(styled(&text).text, "abcde");
|
assert_eq!(styled(&text).text, "abcde");
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,9 @@
|
|||||||
//! 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 std::io::Read;
|
||||||
|
|
||||||
|
use event_model::SeqEvent;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
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
|
/// `status` is the HTTP status where there was a response at all, and
|
||||||
/// `None` where the server was never reached -- mirroring `ApiException` in
|
/// `None` where the server was never reached -- mirroring `ApiException` in
|
||||||
/// `Api.kt`.
|
/// `Api.kt`.
|
||||||
@@ -32,8 +20,6 @@ impl std::fmt::Display for ApiError {
|
|||||||
}
|
}
|
||||||
impl std::error::Error for ApiError {}
|
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 {
|
pub enum Body {
|
||||||
Json(Value),
|
Json(Value),
|
||||||
Bytes {
|
Bytes {
|
||||||
@@ -50,10 +36,7 @@ pub struct RawResponse {
|
|||||||
pub body: Vec<u8>,
|
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 {
|
pub trait Transport: Send + Sync {
|
||||||
/// One request/response call -- everything but the long-lived SSE GETs.
|
|
||||||
fn request(
|
fn request(
|
||||||
&self,
|
&self,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -61,10 +44,6 @@ pub trait Transport: Send + Sync {
|
|||||||
body: Option<Body>,
|
body: Option<Body>,
|
||||||
) -> Result<RawResponse, ApiError>;
|
) -> 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>;
|
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +82,6 @@ fn default_true() -> bool {
|
|||||||
true
|
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> {
|
pub struct ApiClient<T: Transport> {
|
||||||
transport: T,
|
transport: T,
|
||||||
}
|
}
|
||||||
@@ -116,6 +91,14 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
Self { transport }
|
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>>(
|
fn json_request<R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
method: &str,
|
method: &str,
|
||||||
@@ -256,7 +239,7 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
/// A page of transcript history. `before` is the newest-first cursor
|
/// A page of transcript history. `before` is the newest-first cursor
|
||||||
/// (server default is "the newest page" when absent, which a caller
|
/// (server default is "the newest page" when absent, which a caller
|
||||||
/// gets by passing `None`); the events themselves are handed back as
|
/// gets by passing `None`); the events themselves are handed back as
|
||||||
/// [`event_model::SeqEvent`] via `crate::event_stream`'s parsing, kept
|
/// [`event_model::SeqEvent`] via `crate::client::event_stream`'s parsing, kept
|
||||||
/// out of this method's signature so a caller that only wants the raw
|
/// 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.
|
/// lines (for the transcript cache) is not forced to parse them.
|
||||||
pub fn fetch_transcript_page(
|
pub fn fetch_transcript_page(
|
||||||
@@ -266,23 +249,58 @@ impl<T: Transport> ApiClient<T> {
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
coalesce: bool,
|
coalesce: bool,
|
||||||
) -> Result<Vec<Value>, ApiError> {
|
) -> Result<Vec<Value>, ApiError> {
|
||||||
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
|
self.json_request(
|
||||||
if let Some(before) = before {
|
"GET",
|
||||||
path.push_str(&format!("&before={before}"));
|
&transcript_path(session_id, before, limit, coalesce, None),
|
||||||
}
|
None,
|
||||||
if coalesce {
|
)
|
||||||
path.push_str("&coalesce=true");
|
}
|
||||||
}
|
|
||||||
self.json_request("GET", &path, None)
|
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 blocking [`Transport`] backed by `ureq`, the same crate `server/`
|
fn transcript_path(
|
||||||
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
|
session_id: &str,
|
||||||
/// poll). Verifies the server's leaf against a single pinned CA, the way
|
before: Option<u64>,
|
||||||
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
|
limit: u32,
|
||||||
/// store -- the server's certificate is self-signed on purpose (see
|
coalesce: bool,
|
||||||
/// `wg-app-link`).
|
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
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UreqTransport {
|
pub struct UreqTransport {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -290,8 +308,6 @@ pub struct UreqTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UreqTransport {
|
impl UreqTransport {
|
||||||
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
|
|
||||||
/// exactly as read from `certs/ca.pem`.
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
token: impl Into<String>,
|
token: impl Into<String>,
|
||||||
@@ -306,10 +322,6 @@ impl UreqTransport {
|
|||||||
.build();
|
.build();
|
||||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||||
.tls_config(tls_config)
|
.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)
|
.http_status_as_error(false)
|
||||||
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
.timeout_connect(Some(std::time::Duration::from_secs(5)))
|
||||||
.build()
|
.build()
|
||||||
@@ -387,9 +399,6 @@ impl Transport for UreqTransport {
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", &auth)
|
.header("Authorization", &auth)
|
||||||
.header("Accept", "text/event-stream")
|
.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()
|
.config()
|
||||||
.timeout_recv_response(None)
|
.timeout_recv_response(None)
|
||||||
.build()
|
.build()
|
||||||
@@ -415,9 +424,6 @@ fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
|
||||||
let detail = String::from_utf8_lossy(body).trim().to_string();
|
let detail = String::from_utf8_lossy(body).trim().to_string();
|
||||||
let message = if status == 401 {
|
let message = if status == 401 {
|
||||||
@@ -441,8 +447,6 @@ mod tests {
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::sync::Mutex;
|
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)]
|
#[derive(Default)]
|
||||||
struct FakeTransport {
|
struct FakeTransport {
|
||||||
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
responses: Mutex<Vec<(String, String, RawResponse)>>,
|
||||||
@@ -503,7 +507,6 @@ mod tests {
|
|||||||
assert_eq!(sessions.len(), 1);
|
assert_eq!(sessions.len(), 1);
|
||||||
assert_eq!(sessions[0].id, "s1");
|
assert_eq!(sessions[0].id, "s1");
|
||||||
assert_eq!(sessions[0].setup_name, "desktop");
|
assert_eq!(sessions[0].setup_name, "desktop");
|
||||||
// Defaults for fields the server omits.
|
|
||||||
assert!(sessions[0].notify);
|
assert!(sessions[0].notify);
|
||||||
assert_eq!(sessions[0].model, None);
|
assert_eq!(sessions[0].model, None);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
||||||
|
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
|
||||||
|
/// because an app built on the machine its server runs on pins the CA at
|
||||||
|
/// build time and needs nothing from the link; one built elsewhere -- the
|
||||||
|
/// iris Android client is cross-compiled in a VM and run against the
|
||||||
|
/// host's server -- has no other way to get it. A public certificate
|
||||||
|
/// rather than a secret, so it costs the link nothing but length.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EnrolledServer {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ca_pem: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrolledServer {
|
||||||
|
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
|
||||||
|
/// here, because that is what every consumer of it wants
|
||||||
|
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
|
||||||
|
/// at). A `ca` that does not decode fails the whole link rather than
|
||||||
|
/// enrolling a server with no trust anchor: the link said which
|
||||||
|
/// certificate to pin, and quietly not pinning it is the one outcome
|
||||||
|
/// nothing downstream could notice.
|
||||||
|
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;
|
||||||
|
let mut ca = 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),
|
||||||
|
"ca" => ca = 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'"))?;
|
||||||
|
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
token,
|
||||||
|
ca_pem,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> String {
|
||||||
|
format!("https://{}:{}", self.host, self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pem_from_link_param(ca: &str) -> Result<String, String> {
|
||||||
|
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(ca.as_bytes())
|
||||||
|
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
|
||||||
|
let body = base64::engine::general_purpose::STANDARD.encode(&der);
|
||||||
|
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
|
||||||
|
for line in body.as_bytes().chunks(64) {
|
||||||
|
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
|
||||||
|
pem.push('\n');
|
||||||
|
}
|
||||||
|
pem.push_str("-----END CERTIFICATE-----\n");
|
||||||
|
Ok(pem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where one client keeps the enrollment it should not have to be told
|
||||||
|
/// about a second time. `dir` is the caller's, because that is the only
|
||||||
|
/// part that differs by platform -- see this module's doc.
|
||||||
|
pub struct EnrollmentStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrollmentStore {
|
||||||
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { dir: dir.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file(&self) -> PathBuf {
|
||||||
|
self.dir.join("enrollment.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
|
||||||
|
std::fs::create_dir_all(&self.dir)?;
|
||||||
|
let path = self.file();
|
||||||
|
let json = serde_json::to_vec_pretty(server)
|
||||||
|
.expect("EnrolledServer holds nothing that fails to serialise");
|
||||||
|
std::fs::write(&path, json)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
|
||||||
|
let path = self.file();
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("{} is not a valid enrollment ({e})", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(server))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
ca_pem: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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() {
|
||||||
|
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_ca_in_the_link_comes_back_as_pem() {
|
||||||
|
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
||||||
|
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
|
||||||
|
let server =
|
||||||
|
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
|
||||||
|
.unwrap();
|
||||||
|
let pem = server.ca_pem.expect("the link carried a CA");
|
||||||
|
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
|
||||||
|
assert!(
|
||||||
|
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
|
||||||
|
"{pem}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(
|
||||||
|
pem.lines()
|
||||||
|
.filter(|l| !l.starts_with("-----"))
|
||||||
|
.collect::<String>()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
der
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_ca_parameter_is_none_not_an_error() {
|
||||||
|
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
|
||||||
|
assert_eq!(server.ca_pem, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ca_that_does_not_decode_fails_the_link() {
|
||||||
|
let err =
|
||||||
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("ca"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_saved_enrollment_reads_back_the_same() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
let server = EnrolledServer {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8547,
|
||||||
|
token: "tok".to_string(),
|
||||||
|
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
|
||||||
|
};
|
||||||
|
store.save(&server).unwrap();
|
||||||
|
assert_eq!(store.load().unwrap(), Some(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_saved_yet_is_none_not_an_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_enrollment_without_a_ca_still_loads() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
std::fs::create_dir_all(dir.path()).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("enrollment.json"),
|
||||||
|
br#"{"host":"h","port":1,"token":"t"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn the_saved_file_is_owner_only() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = EnrollmentStore::new(dir.path());
|
||||||
|
store
|
||||||
|
.save(&EnrolledServer {
|
||||||
|
host: "h".to_string(),
|
||||||
|
port: 1,
|
||||||
|
token: "t".to_string(),
|
||||||
|
ca_pem: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_corrupt_file_is_named_in_the_error() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
|
||||||
|
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
|
||||||
|
assert!(err.to_string().contains("enrollment.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,74 @@
|
|||||||
|
/// 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.
|
||||||
|
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(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
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::*;
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
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() {
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
|
||||||
|
assert_eq!(format_millis_text(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
//! 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 std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
use crate::api::{ApiError, Transport};
|
use crate::client::api::{ApiError, Transport};
|
||||||
use crate::sse::SseReader;
|
use crate::client::sse::SseReader;
|
||||||
|
|
||||||
/// The frame name the server uses to say a cursor was too far behind to
|
/// 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`.
|
/// continue from. Must match `send_backlog` in `server/src/routes.rs`.
|
||||||
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
|
|||||||
/// callbacks were for, as a single enum instead, since Rust has no
|
/// callbacks were for, as a single enum instead, since Rust has no
|
||||||
/// equivalent of handing three closures to one blocking call.
|
/// equivalent of handing three closures to one blocking call.
|
||||||
pub enum StreamItem {
|
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,
|
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,
|
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 },
|
Event { raw: String, event: SeqEvent },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +43,6 @@ pub fn follow_session_events(
|
|||||||
let Some(frame) = reader.feed_line(&line) else {
|
let Some(frame) = reader.feed_line(&line) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// A named frame carries no payload and a data frame has no name.
|
|
||||||
if frame.name.as_deref() == Some(RESET_EVENT) {
|
if frame.name.as_deref() == Some(RESET_EVENT) {
|
||||||
if !on_item(StreamItem::Reset) {
|
if !on_item(StreamItem::Reset) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -83,7 +66,7 @@ pub fn follow_session_events(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::{Body, RawResponse};
|
use crate::client::api::{Body, RawResponse};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
struct FixtureTransport {
|
struct FixtureTransport {
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
//! 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;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -32,8 +27,6 @@ pub enum Language {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Language {
|
impl Language {
|
||||||
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
|
||||||
/// (`Language.entries`).
|
|
||||||
pub const ALL: [Language; 22] = [
|
pub const ALL: [Language; 22] = [
|
||||||
Language::C,
|
Language::C,
|
||||||
Language::Coffeescript,
|
Language::Coffeescript,
|
||||||
@@ -60,24 +53,14 @@ impl Language {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Rules {
|
pub struct Rules {
|
||||||
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
|
||||||
/// anything else.
|
|
||||||
pub keywords: HashSet<&'static str>,
|
pub keywords: HashSet<&'static str>,
|
||||||
/// Tokens that open a comment running to the end of the line.
|
|
||||||
pub line_comments: Vec<&'static str>,
|
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 line_comments_at_word_start: bool,
|
||||||
pub block_comment: Option<BlockComment>,
|
pub block_comment: Option<BlockComment>,
|
||||||
/// The string forms. The longest opener that matches wins, so `"""` is
|
|
||||||
/// tried before `"`.
|
|
||||||
pub quotes: Vec<Quote>,
|
pub quotes: Vec<Quote>,
|
||||||
pub attributes: Attributes,
|
pub attributes: Attributes,
|
||||||
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
|
||||||
pub raw_strings: bool,
|
pub raw_strings: bool,
|
||||||
/// Rust: `'` opens a character literal only when a backslash or one
|
/// Rust: `'` opens a character literal only when a backslash or one
|
||||||
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||||
@@ -91,8 +74,6 @@ pub struct BlockComment {
|
|||||||
pub nests: bool,
|
pub nests: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One string form. `escapes` is whether a backslash escapes the closer
|
|
||||||
/// (and itself).
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Quote {
|
pub struct Quote {
|
||||||
pub open: &'static str,
|
pub open: &'static str,
|
||||||
@@ -100,18 +81,13 @@ pub struct Quote {
|
|||||||
pub escapes: bool,
|
pub escapes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What opens a metadata span, of the shapes that exist across these languages.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub enum Attributes {
|
pub enum Attributes {
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
|
||||||
AtWord,
|
AtWord,
|
||||||
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
|
||||||
HashBracket,
|
HashBracket,
|
||||||
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
|
||||||
HashLine,
|
HashLine,
|
||||||
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
|
||||||
LineBracket,
|
LineBracket,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +127,6 @@ fn words(list: &'static str) -> HashSet<&'static str> {
|
|||||||
list.split_whitespace().collect()
|
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 {
|
pub fn rules_for(language: Language) -> Rules {
|
||||||
match language {
|
match language {
|
||||||
Language::C => Rules {
|
Language::C => Rules {
|
||||||
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
quotes: vec![DOUBLE, SINGLE],
|
quotes: vec![DOUBLE, SINGLE],
|
||||||
..Default::default()
|
..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 {
|
Language::Coffeescript => Rules {
|
||||||
keywords: words(KEYWORDS_COFFEESCRIPT),
|
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||||
line_comments: vec!["#"],
|
line_comments: vec!["#"],
|
||||||
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
keywords: words(KEYWORDS_SHELL),
|
keywords: words(KEYWORDS_SHELL),
|
||||||
line_comments: vec!["#"],
|
line_comments: vec!["#"],
|
||||||
line_comments_at_word_start: true,
|
line_comments_at_word_start: true,
|
||||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
|
||||||
quotes: vec![
|
quotes: vec![
|
||||||
DOUBLE,
|
DOUBLE,
|
||||||
Quote {
|
Quote {
|
||||||
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
|
|||||||
attributes: Attributes::AtWord,
|
attributes: Attributes::AtWord,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
|
||||||
Language::Markdown => Rules::default(),
|
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 =
|
const KEYWORDS_C: &str =
|
||||||
"auto break case char const continue default do double else enum extern float for goto if
|
"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
|
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||||
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
|
|||||||
required rethrow return sealed set show static super switch this throw true try var void
|
required rethrow return sealed set show static super switch this throw true try var void
|
||||||
when with while yield";
|
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 =
|
const KEYWORDS_FISH: &str =
|
||||||
"and begin break builtin case command continue else end exec for function if in not or
|
"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";
|
return switch while set echo test string math read source";
|
||||||
@@ -466,7 +426,6 @@ const KEYWORDS_PYTHON: &str =
|
|||||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||||
yield";
|
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_RON: &str = "true false Some None inf NaN";
|
||||||
|
|
||||||
const KEYWORDS_RUBY: &str =
|
const KEYWORDS_RUBY: &str =
|
||||||
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
|
|||||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||||
unowned weak willSet";
|
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_TOML: &str = "true false inf nan";
|
||||||
|
|
||||||
const KEYWORDS_TYPESCRIPT: &str =
|
const KEYWORDS_TYPESCRIPT: &str =
|
||||||
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
|||||||
.map(|(_, language)| *language)
|
.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
|
/// 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
|
/// `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
|
/// extension, it has a name that starts with a dot. A name with no dot at
|
||||||
@@ -1,26 +1,8 @@
|
|||||||
//! 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};
|
use super::{Kind, Span};
|
||||||
|
|
||||||
/// The characters an unordered list may be bulleted with.
|
|
||||||
const BULLETS: &str = "-*+";
|
const BULLETS: &str = "-*+";
|
||||||
/// The characters a thematic break, or a setext heading's underline, can be
|
|
||||||
/// drawn with.
|
|
||||||
const RULE_MARKERS: &str = "-*_=";
|
const RULE_MARKERS: &str = "-*_=";
|
||||||
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
|
||||||
const EMPHASIS: &str = "*_~";
|
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_STOPS: &str = "<>\"'`|";
|
||||||
const URL_TRAILING: &str = ".,:;!?";
|
const URL_TRAILING: &str = ".,:;!?";
|
||||||
|
|
||||||
@@ -46,15 +28,10 @@ impl MarkdownScanner {
|
|||||||
// The delimiter run that opened the fenced block we are inside, or
|
// The delimiter run that opened the fenced block we are inside, or
|
||||||
// None between them.
|
// None between them.
|
||||||
let mut fence: Option<Vec<char>> = None;
|
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;
|
let mut table = false;
|
||||||
loop {
|
loop {
|
||||||
let end = self.line_end(at);
|
let end = self.line_end(at);
|
||||||
if let Some(open) = fence.clone() {
|
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);
|
self.emit(at, end, Kind::String);
|
||||||
if self.closes_fence(at, end, &open) {
|
if self.closes_fence(at, end, &open) {
|
||||||
fence = None;
|
fence = None;
|
||||||
@@ -76,7 +53,6 @@ impl MarkdownScanner {
|
|||||||
self.spans
|
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 {
|
fn line_end(&self, at: usize) -> usize {
|
||||||
self.code[at..]
|
self.code[at..]
|
||||||
.iter()
|
.iter()
|
||||||
@@ -85,8 +61,6 @@ impl MarkdownScanner {
|
|||||||
.unwrap_or(self.code.len())
|
.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 {
|
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
||||||
if self.table_delimiter(start, end) {
|
if self.table_delimiter(start, end) {
|
||||||
let indented = self.indented(start, end);
|
let indented = self.indented(start, end);
|
||||||
@@ -102,13 +76,11 @@ impl MarkdownScanner {
|
|||||||
false
|
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 {
|
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||||
let mut dashes = false;
|
let mut dashes = false;
|
||||||
let mut pipes = false;
|
let mut pipes = false;
|
||||||
for at in self.indented(start, end)..end {
|
for c in &self.code[self.indented(start, end)..end] {
|
||||||
match self.code[at] {
|
match c {
|
||||||
'-' => dashes = true,
|
'-' => dashes = true,
|
||||||
'|' => pipes = true,
|
'|' => pipes = true,
|
||||||
':' | ' ' | '\t' => {}
|
':' | ' ' | '\t' => {}
|
||||||
@@ -132,7 +104,6 @@ impl MarkdownScanner {
|
|||||||
false
|
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) {
|
fn table_row(&mut self, start: usize, end: usize) {
|
||||||
let mut at = self.indented(start, end);
|
let mut at = self.indented(start, end);
|
||||||
let mut cell = at;
|
let mut cell = at;
|
||||||
@@ -151,7 +122,6 @@ impl MarkdownScanner {
|
|||||||
self.inline(cell, end);
|
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) {
|
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
||||||
if end <= start {
|
if end <= start {
|
||||||
return;
|
return;
|
||||||
@@ -166,7 +136,6 @@ impl MarkdownScanner {
|
|||||||
self.spans.push(Span { start, end, kind });
|
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 {
|
fn indented(&self, start: usize, end: usize) -> usize {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||||
@@ -175,8 +144,6 @@ impl MarkdownScanner {
|
|||||||
at
|
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)> {
|
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
||||||
let at = self.indented(start, end);
|
let at = self.indented(start, end);
|
||||||
if at == end {
|
if at == end {
|
||||||
@@ -193,20 +160,14 @@ impl MarkdownScanner {
|
|||||||
if run - at >= 3 { Some((at, run)) } else { None }
|
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>> {
|
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
||||||
let (run_start, run_end) = self.fence_run(start, end)?;
|
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||||
self.emit(run_start, run_end, Kind::String);
|
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);
|
let indented = self.indented(run_end, end);
|
||||||
self.emit(indented, end, Kind::Metadata);
|
self.emit(indented, end, Kind::Metadata);
|
||||||
Some(self.code[run_start..run_end].to_vec())
|
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 {
|
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
||||||
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -217,12 +178,8 @@ impl MarkdownScanner {
|
|||||||
self.indented(run_end, end) == end
|
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) {
|
fn structure(&mut self, start: usize, end: usize) {
|
||||||
let mut at = start;
|
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] == '>' {
|
while at < end && self.code[at] == '>' {
|
||||||
at += 1;
|
at += 1;
|
||||||
self.emit(at - 1, at, Kind::Mark);
|
self.emit(at - 1, at, Kind::Mark);
|
||||||
@@ -238,8 +195,6 @@ impl MarkdownScanner {
|
|||||||
self.inline(text_start, 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 {
|
fn heading(&mut self, start: usize, end: usize) -> bool {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end && self.code[at] == '#' {
|
while at < end && self.code[at] == '#' {
|
||||||
@@ -256,15 +211,13 @@ impl MarkdownScanner {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A line made of one repeated rule character and nothing else.
|
|
||||||
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
if !RULE_MARKERS.contains(marker) {
|
if !RULE_MARKERS.contains(marker) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let mut seen = 0usize;
|
let mut seen = 0usize;
|
||||||
for at in start..end {
|
for &c in &self.code[start..end] {
|
||||||
let c = self.code[at];
|
|
||||||
if c == marker {
|
if c == marker {
|
||||||
seen += 1;
|
seen += 1;
|
||||||
} else if !c.is_whitespace() {
|
} else if !c.is_whitespace() {
|
||||||
@@ -278,8 +231,6 @@ impl MarkdownScanner {
|
|||||||
true
|
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 {
|
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
||||||
@@ -305,16 +256,11 @@ impl MarkdownScanner {
|
|||||||
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
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) {
|
fn inline(&mut self, start: usize, end: usize) {
|
||||||
let mut at = start;
|
let mut at = start;
|
||||||
while at < end {
|
while at < end {
|
||||||
let c = self.code[at];
|
let c = self.code[at];
|
||||||
at = if c == '\\' {
|
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
|
at + 2
|
||||||
} else if c == '`' {
|
} else if c == '`' {
|
||||||
self.code_span(at, end)
|
self.code_span(at, end)
|
||||||
@@ -332,7 +278,6 @@ impl MarkdownScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
|
||||||
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
||||||
let mut open = start;
|
let mut open = start;
|
||||||
while open < end && self.code[open] == '`' {
|
while open < end && self.code[open] == '`' {
|
||||||
@@ -355,11 +300,9 @@ impl MarkdownScanner {
|
|||||||
}
|
}
|
||||||
at = close;
|
at = close;
|
||||||
}
|
}
|
||||||
// Nothing closes it on this line, so those were ordinary backticks.
|
|
||||||
open
|
open
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[text](destination)`, and the same with a leading `!` for an image.
|
|
||||||
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||||
let mut depth = 0i32;
|
let mut depth = 0i32;
|
||||||
let mut close = bracket;
|
let mut close = bracket;
|
||||||
@@ -398,8 +341,6 @@ impl MarkdownScanner {
|
|||||||
paren + 1
|
paren + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
|
||||||
/// destination they are.
|
|
||||||
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||||
let mut at = start + 1;
|
let mut at = start + 1;
|
||||||
let mut addressed = false;
|
let mut addressed = false;
|
||||||
@@ -423,8 +364,6 @@ impl MarkdownScanner {
|
|||||||
start + 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> {
|
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
||||||
if start > 0 && is_word(self.code[start - 1]) {
|
if start > 0 && is_word(self.code[start - 1]) {
|
||||||
return None;
|
return None;
|
||||||
@@ -466,8 +405,6 @@ impl MarkdownScanner {
|
|||||||
Some(at)
|
Some(at)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
|
||||||
/// all.
|
|
||||||
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||||
let marker = self.code[start];
|
let marker = self.code[start];
|
||||||
let mut open = start;
|
let mut open = start;
|
||||||
@@ -1,24 +1,3 @@
|
|||||||
//! `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 languages;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
|
||||||
@@ -26,7 +5,6 @@ pub use languages::{
|
|||||||
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Kind {
|
pub enum Kind {
|
||||||
Keyword,
|
Keyword,
|
||||||
@@ -38,7 +16,6 @@ pub enum Kind {
|
|||||||
Mark,
|
Mark,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Span {
|
pub struct Span {
|
||||||
pub start: usize,
|
pub start: usize,
|
||||||
@@ -70,8 +47,6 @@ pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
|||||||
Scanner::new(code, rules).run()
|
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 PUNCTUATION: &str = ",.:;";
|
||||||
const MARKS: &str = "()={}<>-+[]|&";
|
const MARKS: &str = "()={}<>-+[]|&";
|
||||||
|
|
||||||
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
|
|||||||
|
|
||||||
fn run(mut self) -> Vec<Span> {
|
fn run(mut self) -> Vec<Span> {
|
||||||
while self.at < self.code.len() {
|
while self.at < self.code.len() {
|
||||||
// Every branch that answers true has advanced `self.at`, so
|
|
||||||
// this terminates.
|
|
||||||
let consumed = self.block_comment()
|
let consumed = self.block_comment()
|
||||||
|| self.line_comment()
|
|| self.line_comment()
|
||||||
|| self.raw_string()
|
|| self.raw_string()
|
||||||
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
|
|||||||
starts_with_at(&self.code, self.at, token)
|
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 {
|
fn at_word_start(&self) -> bool {
|
||||||
self.at == 0
|
self.at == 0
|
||||||
|| self.code[self.at - 1].is_whitespace()
|
|| self.code[self.at - 1].is_whitespace()
|
||||||
|| ";|&(".contains(self.code[self.at - 1])
|
|| ";|&(".contains(self.code[self.at - 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether only whitespace stands between the start of this line and here.
|
|
||||||
fn at_line_start(&self) -> bool {
|
fn at_line_start(&self) -> bool {
|
||||||
let mut back = self.at as isize - 1;
|
let mut back = self.at as isize - 1;
|
||||||
while back >= 0 && self.code[back as usize] != '\n' {
|
while back >= 0 && self.code[back as usize] != '\n' {
|
||||||
@@ -152,8 +122,6 @@ impl<'a> Scanner<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// From an open bracket through the one that matches it, or to the end
|
|
||||||
/// if none does.
|
|
||||||
fn advance_to_matching_bracket(&mut self) {
|
fn advance_to_matching_bracket(&mut self) {
|
||||||
let mut depth = 0i32;
|
let mut depth = 0i32;
|
||||||
while self.at < self.code.len() {
|
while self.at < self.code.len() {
|
||||||
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
|
|||||||
self.at += comment.open.chars().count();
|
self.at += comment.open.chars().count();
|
||||||
let mut depth = 1i32;
|
let mut depth = 1i32;
|
||||||
while self.at < self.code.len() && depth > 0 {
|
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) {
|
if self.starts(comment.close) {
|
||||||
depth -= 1;
|
depth -= 1;
|
||||||
self.at += comment.close.chars().count();
|
self.at += comment.close.chars().count();
|
||||||
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
|
||||||
fn raw_string(&mut self) -> bool {
|
fn raw_string(&mut self) -> bool {
|
||||||
if !self.rules.raw_strings {
|
if !self.rules.raw_strings {
|
||||||
return false;
|
return false;
|
||||||
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn string(&mut self) -> bool {
|
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;
|
let mut quote: Option<Quote> = None;
|
||||||
for candidate in &self.rules.quotes {
|
for candidate in &self.rules.quotes {
|
||||||
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||||
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
|
|||||||
true
|
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 {
|
fn number(&mut self) -> bool {
|
||||||
if !self.code[self.at].is_ascii_digit() {
|
if !self.code[self.at].is_ascii_digit() {
|
||||||
return false;
|
return false;
|
||||||
@@ -403,7 +362,6 @@ fn is_word_part(c: char) -> bool {
|
|||||||
c.is_alphanumeric() || c == '_'
|
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 {
|
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||||
let token: Vec<char> = token.chars().collect();
|
let token: Vec<char> = token.chars().collect();
|
||||||
if at + token.len() > code.len() {
|
if at + token.len() > code.len() {
|
||||||
@@ -412,8 +370,6 @@ fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
|||||||
code[at..at + token.len()] == token[..]
|
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> {
|
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||||
if needle.is_empty() || from > code.len() {
|
if needle.is_empty() || from > code.len() {
|
||||||
return None;
|
return None;
|
||||||
@@ -640,10 +596,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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]
|
#[test]
|
||||||
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||||
let nasty = [
|
let nasty = [
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Both bounds apply -- whichever bites first -- because the two failure
|
||||||
|
/// modes are different: a flood of short lines exhausts the count, and one
|
||||||
|
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
|
||||||
|
/// the bytes. A ring bounded only by lines can hold megabytes; one bounded
|
||||||
|
/// only by bytes can be emptied by a single line.
|
||||||
|
pub const DEFAULT_MAX_LINES: usize = 2000;
|
||||||
|
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LogLine {
|
||||||
|
pub seq: u64,
|
||||||
|
/// Milliseconds since the unix epoch, from the app's own clock. The
|
||||||
|
/// app's rather than the receiver's: a line is timestamped when it
|
||||||
|
/// happened, and an upload can be minutes later or never.
|
||||||
|
pub at_ms: u64,
|
||||||
|
pub level: log::Level,
|
||||||
|
pub target: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogLine {
|
||||||
|
fn weight(&self) -> usize {
|
||||||
|
self.target.len() + self.message.len() + 32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `12:34:56.789 INFO iris::android: the message`, the shape a
|
||||||
|
/// person skims. Time of day only -- the date is in the report's own
|
||||||
|
/// header, and a ring never spans one.
|
||||||
|
pub fn format(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {:<5} {}: {}",
|
||||||
|
clock_time(self.at_ms),
|
||||||
|
self.level,
|
||||||
|
self.target,
|
||||||
|
self.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clock_time(at_ms: u64) -> String {
|
||||||
|
let ms = at_ms % 1000;
|
||||||
|
let secs_of_day = (at_ms / 1000) % 86_400;
|
||||||
|
format!(
|
||||||
|
"{:02}:{:02}:{:02}.{:03}",
|
||||||
|
secs_of_day / 3600,
|
||||||
|
(secs_of_day % 3600) / 60,
|
||||||
|
secs_of_day % 60,
|
||||||
|
ms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Now, in unix milliseconds. Saturating rather than panicking on a clock
|
||||||
|
/// before the epoch: a wrong timestamp in a diagnostic is not worth taking
|
||||||
|
/// the app down for.
|
||||||
|
pub fn now_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Inner {
|
||||||
|
lines: VecDeque<LogLine>,
|
||||||
|
bytes: usize,
|
||||||
|
max_lines: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
next_seq: u64,
|
||||||
|
dropped: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LogRing(Arc<Mutex<Inner>>);
|
||||||
|
|
||||||
|
impl LogRing {
|
||||||
|
pub fn new(max_lines: usize, max_bytes: usize) -> Self {
|
||||||
|
assert!(
|
||||||
|
max_lines > 0 && max_bytes > 0,
|
||||||
|
"a ring with no room holds nothing"
|
||||||
|
);
|
||||||
|
Self(Arc::new(Mutex::new(Inner {
|
||||||
|
lines: VecDeque::new(),
|
||||||
|
bytes: 0,
|
||||||
|
max_lines,
|
||||||
|
max_bytes,
|
||||||
|
next_seq: 0,
|
||||||
|
dropped: 0,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_defaults() -> Self {
|
||||||
|
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A poisoned lock is a bug in a panicking logger, not a reason to
|
||||||
|
/// take the app down a second time -- the ring is a diagnostic, and
|
||||||
|
/// losing it must not be worse than the fault it was recording.
|
||||||
|
fn with<R>(&self, f: impl FnOnce(&mut Inner) -> R) -> R {
|
||||||
|
let mut guard = match self.0.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
f(&mut guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&self, level: log::Level, target: &str, message: String) {
|
||||||
|
self.with(|inner| {
|
||||||
|
let line = LogLine {
|
||||||
|
seq: inner.next_seq,
|
||||||
|
at_ms: now_ms(),
|
||||||
|
level,
|
||||||
|
target: target.to_string(),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
inner.next_seq += 1;
|
||||||
|
inner.bytes += line.weight();
|
||||||
|
inner.lines.push_back(line);
|
||||||
|
// `!is_empty()` rather than `len() > 1`: one line larger than
|
||||||
|
// the whole byte bound is kept, because dropping it would
|
||||||
|
// leave the ring silently empty while lines were arriving.
|
||||||
|
while inner.lines.len() > inner.max_lines
|
||||||
|
|| (inner.bytes > inner.max_bytes && inner.lines.len() > 1)
|
||||||
|
{
|
||||||
|
if let Some(evicted) = inner.lines.pop_front() {
|
||||||
|
inner.bytes -= evicted.weight();
|
||||||
|
inner.dropped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Vec<LogLine> {
|
||||||
|
self.with(|inner| inner.lines.iter().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
|
||||||
|
self.with(|inner| {
|
||||||
|
let lines: Vec<LogLine> = inner
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|line| line.seq >= seq)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let next = lines.last().map(|line| line.seq + 1).unwrap_or(seq);
|
||||||
|
(lines, next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.with(|inner| inner.lines.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dropped(&self) -> u64 {
|
||||||
|
self.with(|inner| inner.dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sequence number of the newest line held, or `None` for a ring
|
||||||
|
/// nothing has been written to.
|
||||||
|
///
|
||||||
|
/// What a reader needs to notice that this process **restarted**: the
|
||||||
|
/// ring is in memory, so a new process starts again at zero, and a
|
||||||
|
/// reader holding a cursor from the previous one would otherwise ask
|
||||||
|
/// for lines after a number nothing will reach for hours and see
|
||||||
|
/// nothing at all -- silently, which is worse than seeing the log
|
||||||
|
/// begin again. Answering `None` rather than 0 for an empty ring is
|
||||||
|
/// the same distinction [`Self::summary`] draws: "nothing has been
|
||||||
|
/// logged" is not a sequence number.
|
||||||
|
pub fn newest_seq(&self) -> Option<u64> {
|
||||||
|
self.with(|inner| inner.lines.back().map(|line| line.seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the newest line was written, in unix milliseconds, or `None`
|
||||||
|
/// for a ring nothing has been written to.
|
||||||
|
pub fn last_at_ms(&self) -> Option<u64> {
|
||||||
|
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_text(&self) -> String {
|
||||||
|
self.snapshot()
|
||||||
|
.iter()
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest `max_lines` lines, formatted, or `None` if the ring is
|
||||||
|
/// locked at this instant.
|
||||||
|
///
|
||||||
|
/// For the one caller that must not block: **the panic hook**. A panic
|
||||||
|
/// raised while this ring's own lock was held -- an allocation failing
|
||||||
|
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
|
||||||
|
/// -- would deadlock the hook against the thread that is panicking,
|
||||||
|
/// and the process would hang instead of aborting, with nothing
|
||||||
|
/// written anywhere. Losing the context lines is the right trade
|
||||||
|
/// against that, and `None` says which happened rather than looking
|
||||||
|
/// like an empty log.
|
||||||
|
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
|
||||||
|
let guard = match self.0.try_lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||||
|
Err(std::sync::TryLockError::WouldBlock) => return None,
|
||||||
|
};
|
||||||
|
let lines = &guard.lines;
|
||||||
|
let from = lines.len().saturating_sub(max_lines);
|
||||||
|
Some(
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.skip(from)
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for a diagnostics pane: how much is held, how much was
|
||||||
|
/// dropped, and when the last line arrived. "no lines yet" is its own
|
||||||
|
/// wording rather than a count of zero with a made-up time, because
|
||||||
|
/// "nothing has been logged" and "logging is not running" would
|
||||||
|
/// otherwise look the same.
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
let (len, dropped, last) = self.with(|inner| {
|
||||||
|
(
|
||||||
|
inner.lines.len(),
|
||||||
|
inner.dropped,
|
||||||
|
inner.lines.back().map(|line| line.at_ms),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
match last {
|
||||||
|
None => "app log: no lines yet".to_string(),
|
||||||
|
Some(at) => {
|
||||||
|
let dropped = if dropped > 0 {
|
||||||
|
format!(", {dropped} dropped")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"app log: {len} lines held{dropped}, last {}",
|
||||||
|
clock_time(at)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a target belongs to this app or to `iris` rather than to a
|
||||||
|
/// dependency -- `starts_with` guarded by an
|
||||||
|
/// exact match or a `::` so an unrelated crate that merely begins with the
|
||||||
|
/// same letters (there is no such crate today, but the check should not
|
||||||
|
/// rely on that) is never mistaken for one of ours.
|
||||||
|
fn is_own_target(target: &str) -> bool {
|
||||||
|
target == "iris"
|
||||||
|
|| target.starts_with("iris::")
|
||||||
|
|| target == "ai_app"
|
||||||
|
|| target.starts_with("ai_app::")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
|
||||||
|
/// asked for, applied once here rather than at each `debug!` call site:
|
||||||
|
/// Info and above always ring, from anything, because a real warning or
|
||||||
|
/// error from a dependency is worth keeping. Debug and Trace ring only
|
||||||
|
/// from this app's own targets, and only while tracing is switched on --
|
||||||
|
/// otherwise `naga::front`/`wgpu_core`/`jni` log at Debug unconditionally
|
||||||
|
/// (the process logger's own level, set once at install and unrelated to
|
||||||
|
/// tracing), which is what filled the ring with 1339 lines of it and
|
||||||
|
/// dropped 4050 more before this existed. `iris`'s own Debug lines already
|
||||||
|
/// self-gate on `iris::diagnostics::trace_enabled` at their call sites
|
||||||
|
/// (commit 992c472); this is the backstop for lines this crate does not
|
||||||
|
/// control.
|
||||||
|
fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
|
||||||
|
level <= log::Level::Info || (trace_enabled && is_own_target(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RingLogger {
|
||||||
|
ring: LogRing,
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RingLogger {
|
||||||
|
pub fn new(ring: LogRing, inner: Box<dyn log::Log>, trace_enabled: fn() -> bool) -> Self {
|
||||||
|
Self {
|
||||||
|
ring,
|
||||||
|
inner,
|
||||||
|
trace_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl log::Log for RingLogger {
|
||||||
|
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
if ring_accepts(record.level(), record.target(), (self.trace_enabled)()) {
|
||||||
|
self.ring
|
||||||
|
.push(record.level(), record.target(), record.args().to_string());
|
||||||
|
}
|
||||||
|
if self.inner.enabled(record.metadata()) {
|
||||||
|
self.inner.log(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {
|
||||||
|
self.inner.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fails only if a logger is already installed, which is a programmer
|
||||||
|
/// error (two initialisation paths) rather than a recoverable condition --
|
||||||
|
/// the caller is named in the error so it is findable.
|
||||||
|
pub fn install(
|
||||||
|
ring: LogRing,
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
max_level: log::LevelFilter,
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
) -> Result<(), log::SetLoggerError> {
|
||||||
|
log::set_boxed_logger(Box::new(RingLogger::new(ring, inner, trace_enabled)))?;
|
||||||
|
log::set_max_level(max_level);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A deliberate process-global, where this project's rules otherwise say
|
||||||
|
/// pass context explicitly.** What is being modelled is already one: `log`
|
||||||
|
/// has exactly one backend per process, set once, and every `log::info!`
|
||||||
|
/// anywhere in the binary goes to it. A ring handed around as a parameter
|
||||||
|
/// would be a *second* answer to "which lines exist" -- the report would
|
||||||
|
/// show one ring while the logger filled another, and which one a caller
|
||||||
|
/// got would depend on how far down the call tree it was. The tests above
|
||||||
|
/// all use their own [`LogRing`], so nothing here needs this to be
|
||||||
|
/// testable.
|
||||||
|
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn process_ring() -> &'static LogRing {
|
||||||
|
PROCESS_RING.get_or_init(LogRing::with_defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_process_logger(
|
||||||
|
inner: Box<dyn log::Log>,
|
||||||
|
max_level: log::LevelFilter,
|
||||||
|
trace_enabled: fn() -> bool,
|
||||||
|
) -> Result<(), log::SetLoggerError> {
|
||||||
|
install(process_ring().clone(), inner, max_level, trace_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use log::Level;
|
||||||
|
|
||||||
|
fn fill(ring: &LogRing, count: usize) {
|
||||||
|
for n in 0..count {
|
||||||
|
ring.push(Level::Info, "test", format!("line {n}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lines_come_back_oldest_first() {
|
||||||
|
let ring = LogRing::new(10, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(text, ["line 0", "line 1", "line 2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_line_bound_drops_the_oldest_and_says_how_many() {
|
||||||
|
let ring = LogRing::new(3, 1 << 20);
|
||||||
|
fill(&ring, 5);
|
||||||
|
let text: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(text, ["line 2", "line 3", "line 4"], "the newest survive");
|
||||||
|
assert_eq!(ring.len(), 3);
|
||||||
|
assert_eq!(ring.dropped(), 2, "and the loss is reported, not silent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
|
||||||
|
let ring = LogRing::new(1000, 300);
|
||||||
|
for n in 0..10 {
|
||||||
|
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
ring.len() < 10,
|
||||||
|
"the byte bound evicted: {} held",
|
||||||
|
ring.len()
|
||||||
|
);
|
||||||
|
assert!(ring.dropped() > 0);
|
||||||
|
assert!(
|
||||||
|
ring.snapshot().last().unwrap().message.starts_with('9'),
|
||||||
|
"and it evicted from the old end"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
|
||||||
|
let ring = LogRing::new(100, 64);
|
||||||
|
ring.push(Level::Error, "t", "y".repeat(5000));
|
||||||
|
assert_eq!(ring.len(), 1);
|
||||||
|
assert_eq!(ring.dropped(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_numbers_only_increase_and_survive_eviction() {
|
||||||
|
let ring = LogRing::new(2, 1 << 20);
|
||||||
|
fill(&ring, 5);
|
||||||
|
let seqs: Vec<u64> = ring.snapshot().into_iter().map(|l| l.seq).collect();
|
||||||
|
assert_eq!(seqs, [3, 4], "a gap is exactly what was dropped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn since_returns_only_what_is_new_and_the_next_cursor() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let (first, cursor) = ring.since(0);
|
||||||
|
assert_eq!(first.len(), 3);
|
||||||
|
assert_eq!(cursor, 3);
|
||||||
|
|
||||||
|
let (none, cursor) = ring.since(cursor);
|
||||||
|
assert!(none.is_empty(), "nothing new yet");
|
||||||
|
assert_eq!(cursor, 3, "and the cursor does not move");
|
||||||
|
|
||||||
|
ring.push(Level::Warn, "test", "later".into());
|
||||||
|
let (more, cursor) = ring.since(cursor);
|
||||||
|
assert_eq!(more.len(), 1);
|
||||||
|
assert_eq!(more[0].message, "later");
|
||||||
|
assert_eq!(cursor, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
assert_eq!(ring.newest_seq(), None, "an empty ring has no newest line");
|
||||||
|
fill(&ring, 5);
|
||||||
|
assert_eq!(ring.newest_seq(), Some(4));
|
||||||
|
|
||||||
|
let restarted = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&restarted, 1);
|
||||||
|
assert_eq!(
|
||||||
|
restarted.newest_seq(),
|
||||||
|
Some(0),
|
||||||
|
"a fresh ring starts again, which is exactly what a reader has to notice"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reading_does_not_consume() {
|
||||||
|
let ring = LogRing::new(100, 1 << 20);
|
||||||
|
fill(&ring, 2);
|
||||||
|
let (sent, _) = ring.since(0);
|
||||||
|
assert_eq!(sent.len(), 2);
|
||||||
|
assert_eq!(ring.len(), 2, "the report still has them after an upload");
|
||||||
|
assert_eq!(ring.to_text().lines().count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_tail_text_gives_the_newest_lines_with_no_header() {
|
||||||
|
let ring = LogRing::new(1000, 1 << 20);
|
||||||
|
fill(&ring, 200);
|
||||||
|
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
|
||||||
|
let lines: Vec<&str> = tail.lines().collect();
|
||||||
|
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
|
||||||
|
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
|
||||||
|
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
|
||||||
|
let ring = LogRing::new(10, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let held = ring.0.lock().expect("fresh ring");
|
||||||
|
assert_eq!(ring.try_tail_text(80), None);
|
||||||
|
drop(held);
|
||||||
|
assert!(ring.try_tail_text(80).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
assert_eq!(ring.summary(), "app log: no lines yet");
|
||||||
|
assert_eq!(ring.last_at_ms(), None);
|
||||||
|
assert!(ring.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_summary_names_dropped_lines_only_when_there_are_some() {
|
||||||
|
let ring = LogRing::new(2, 1 << 20);
|
||||||
|
fill(&ring, 2);
|
||||||
|
assert!(!ring.summary().contains("dropped"), "{}", ring.summary());
|
||||||
|
fill(&ring, 2);
|
||||||
|
assert!(ring.summary().contains("2 dropped"), "{}", ring.summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_formats_as_time_level_target_message() {
|
||||||
|
let line = LogLine {
|
||||||
|
seq: 0,
|
||||||
|
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
|
||||||
|
level: Level::Info,
|
||||||
|
target: "iris::android".into(),
|
||||||
|
message: "surface created".into(),
|
||||||
|
}
|
||||||
|
.format();
|
||||||
|
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_ring_logger_forwards_to_the_inner_logger() {
|
||||||
|
use log::Log;
|
||||||
|
struct Collect(Arc<Mutex<Vec<String>>>, log::Level);
|
||||||
|
impl Log for Collect {
|
||||||
|
fn enabled(&self, metadata: &log::Metadata) -> bool {
|
||||||
|
metadata.level() <= self.1
|
||||||
|
}
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
self.0.lock().unwrap().push(record.args().to_string());
|
||||||
|
}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
let logger = RingLogger::new(
|
||||||
|
ring.clone(),
|
||||||
|
Box::new(Collect(seen.clone(), Level::Info)),
|
||||||
|
|| true,
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("kept"))
|
||||||
|
.level(Level::Info)
|
||||||
|
.target("iris::test")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("filtered"))
|
||||||
|
.level(Level::Debug)
|
||||||
|
.target("iris::test")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*seen.lock().unwrap(),
|
||||||
|
["kept"],
|
||||||
|
"the inner logger's own filter still applies"
|
||||||
|
);
|
||||||
|
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(
|
||||||
|
held,
|
||||||
|
["kept", "filtered"],
|
||||||
|
"own-target debug still rings while tracing is on"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
|
||||||
|
use log::Log;
|
||||||
|
struct Discard;
|
||||||
|
impl Log for Discard {
|
||||||
|
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn log(&self, _: &log::Record) {}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ring = LogRing::with_defaults();
|
||||||
|
let logger = RingLogger::new(ring.clone(), Box::new(Discard), || true);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("naga debug spam"))
|
||||||
|
.level(Level::Debug)
|
||||||
|
.target("naga::front")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&log::Record::builder()
|
||||||
|
.args(format_args!("naga warning"))
|
||||||
|
.level(Level::Warn)
|
||||||
|
.target("wgpu_core::device")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let held: Vec<String> = ring.snapshot().into_iter().map(|l| l.message).collect();
|
||||||
|
assert_eq!(
|
||||||
|
held,
|
||||||
|
["naga warning"],
|
||||||
|
"Info-and-above always rings; foreign Debug never does"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ring_accepts_is_own_target_debug_only_while_tracing() {
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Info, "wgpu_core::device", false),
|
||||||
|
"Info+ from anything, tracing off"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Warn, "jni", true),
|
||||||
|
"Info+ from anything, tracing on"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ring_accepts(Level::Debug, "jni", true),
|
||||||
|
"foreign Debug, tracing on: still excluded"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ring_accepts(Level::Debug, "iris::sense", false),
|
||||||
|
"own Debug, tracing off: excluded"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Debug, "iris::sense", true),
|
||||||
|
"own Debug, tracing on: included"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ring_accepts(Level::Trace, "ai_app::api", true),
|
||||||
|
"own Trace, tracing on: included"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_own_target_matches_the_crate_or_its_modules_only() {
|
||||||
|
assert!(is_own_target("iris"));
|
||||||
|
assert!(is_own_target("iris::sense"));
|
||||||
|
assert!(is_own_target("ai_app"));
|
||||||
|
assert!(is_own_target("ai_app::log_ring"));
|
||||||
|
assert!(!is_own_target("iris_something_else"));
|
||||||
|
assert!(!is_own_target("naga::front"));
|
||||||
|
assert!(!is_own_target("jni"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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,
|
||||||
|
Code,
|
||||||
|
List,
|
||||||
|
Table,
|
||||||
|
Quote,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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.";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
//! 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 ansi;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod durations;
|
||||||
pub mod event_stream;
|
pub mod event_stream;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
|
pub mod log_ring;
|
||||||
|
pub mod markdown_blocks;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
|
pub mod text_cap;
|
||||||
|
pub mod tool_summary;
|
||||||
pub mod transcript_cache;
|
pub mod transcript_cache;
|
||||||
pub mod transcript_fold;
|
pub mod transcript_fold;
|
||||||
|
pub mod transcript_source;
|
||||||
|
|
||||||
pub use event_model::*;
|
pub use event_model::*;
|
||||||
@@ -1,27 +1,17 @@
|
|||||||
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
//! `GET /notifications`, the attention stream PLAN.md's "Notifications: two
|
||||||
//! places, never both" describes. Ported from the parsing half of
|
//! places, never both" describes. Ported from the parsing half of
|
||||||
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
//! `app/.../Notifications.kt`'s `NotificationService` -- the framing
|
||||||
//! ([`crate::sse`]) and the wire shape ([`SessionNotification`],
|
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
|
||||||
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
|
||||||
//! `Notification`/`NotificationKind`).
|
//! `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 std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::api::{ApiError, Transport};
|
use crate::client::api::{ApiError, Transport};
|
||||||
use crate::sse::SseReader;
|
use crate::client::sse::SseReader;
|
||||||
|
|
||||||
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
|
|
||||||
/// `Notification` field for field.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SessionNotification {
|
pub struct SessionNotification {
|
||||||
@@ -32,9 +22,6 @@ pub struct SessionNotification {
|
|||||||
pub at: f64,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum NotificationKind {
|
pub enum NotificationKind {
|
||||||
@@ -93,7 +80,7 @@ pub fn follow_notifications(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::{Body, RawResponse};
|
use crate::client::api::{Body, RawResponse};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
struct FixtureTransport {
|
struct FixtureTransport {
|
||||||
@@ -1,15 +1,3 @@
|
|||||||
//! 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.
|
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
@@ -17,10 +5,6 @@ pub struct Frame {
|
|||||||
pub data: 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)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SseReader {
|
pub struct SseReader {
|
||||||
data: String,
|
data: String,
|
||||||
@@ -32,8 +16,6 @@ impl SseReader {
|
|||||||
Self::default()
|
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> {
|
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
if self.name.is_some() || !self.data.is_empty() {
|
if self.name.is_some() || !self.data.is_empty() {
|
||||||
@@ -50,7 +32,6 @@ impl SseReader {
|
|||||||
} else if let Some(rest) = line.strip_prefix("event:") {
|
} else if let Some(rest) = line.strip_prefix("event:") {
|
||||||
self.name = Some(rest.trim().to_string());
|
self.name = Some(rest.trim().to_string());
|
||||||
}
|
}
|
||||||
// `id:`, comments -- nothing to do.
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/// The default bound on a verbatim block -- a tool call's input or its
|
||||||
|
/// output. Short, because this text is a machine's and the reader is
|
||||||
|
/// looking for one line of it.
|
||||||
|
pub const VERBATIM_LINES: usize = 80;
|
||||||
|
pub const VERBATIM_BYTES: usize = 4096;
|
||||||
|
|
||||||
|
pub const MESSAGE_LINES: usize = 200;
|
||||||
|
pub const MESSAGE_BYTES: usize = 16 * 1024;
|
||||||
|
|
||||||
|
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
|
||||||
|
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
|
||||||
|
|
||||||
|
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
|
||||||
|
/// count it was cut *from*; `None` when the whole of it fits.
|
||||||
|
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
|
||||||
|
debug_assert!(
|
||||||
|
max_lines > 0 && max_bytes > 0,
|
||||||
|
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
|
||||||
|
);
|
||||||
|
let by_lines = text
|
||||||
|
.char_indices()
|
||||||
|
.filter(|(_, c)| *c == '\n')
|
||||||
|
.nth(max_lines - 1)
|
||||||
|
.map(|(i, _)| i);
|
||||||
|
let by_bytes = (text.len() > max_bytes).then(|| {
|
||||||
|
let mut end = max_bytes;
|
||||||
|
// Back up to a character boundary: a cut inside a multi-byte
|
||||||
|
// character panics on the slice below, and a transcript is full of
|
||||||
|
// them.
|
||||||
|
while !text.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
end
|
||||||
|
});
|
||||||
|
let cut = match (by_lines, by_bytes) {
|
||||||
|
(Some(a), Some(b)) => a.min(b),
|
||||||
|
(a, b) => a.or(b)?,
|
||||||
|
};
|
||||||
|
Some((&text[..cut], text.lines().count()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_all_label(lines: usize) -> String {
|
||||||
|
format!("Show all {lines} lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_under_both_bounds_is_not_cut() {
|
||||||
|
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_line_bound_cuts_at_a_line_boundary() {
|
||||||
|
let text = "a\nb\nc\nd\n";
|
||||||
|
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
|
||||||
|
assert_eq!(shown, "a\nb");
|
||||||
|
assert_eq!(
|
||||||
|
lines, 4,
|
||||||
|
"the count is the whole text's, not the shown part's"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_byte_bound_cuts_one_long_line() {
|
||||||
|
let text = "x".repeat(5000);
|
||||||
|
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
|
||||||
|
assert_eq!(shown.len(), 4096);
|
||||||
|
assert_eq!(lines, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tighter_of_the_two_bounds_wins() {
|
||||||
|
let text = "aaaa\n".repeat(100);
|
||||||
|
let (shown, _) = cut(&text, 80, 100).expect("over both");
|
||||||
|
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
|
||||||
|
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
|
||||||
|
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
|
||||||
|
let text = "é".repeat(100);
|
||||||
|
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
|
||||||
|
assert_eq!(
|
||||||
|
shown,
|
||||||
|
"é".repeat(5),
|
||||||
|
"11 bytes lands mid-character; 10 is the cut"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use crate::client::durations::format_millis_text;
|
||||||
|
use crate::client::highlight::Language;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ToolInput {
|
||||||
|
pub subject: Option<String>,
|
||||||
|
pub language: Option<Language>,
|
||||||
|
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 {
|
||||||
|
pub fn title(&self) -> Option<&str> {
|
||||||
|
self.description
|
||||||
|
.as_deref()
|
||||||
|
.or(self.subject.as_deref())
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
|
||||||
|
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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() {
|
||||||
|
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() {
|
||||||
|
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() {
|
||||||
|
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);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rest,
|
||||||
|
vec!["command: ".to_string(), "other: 1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,21 @@
|
|||||||
//! This phone's copy of the transcripts it has already been sent, so
|
use std::collections::{HashSet, VecDeque};
|
||||||
//! reopening a session does not download it again. Ported from
|
|
||||||
//! `app/.../TranscriptCache.kt`; see `docs/TRANSCRIPT_CACHE.md`
|
|
||||||
//! for the design and `docs/CLIENT_CORE.md` for how this file corresponds to it.
|
|
||||||
//!
|
|
||||||
//! What is stored is the server's own JSON for one event per line, in
|
|
||||||
//! transcript order. Reading the cache means running the same [`seq_of`]
|
|
||||||
//! the network path runs, so a cached transcript and a fetched one cannot
|
|
||||||
//! draw differently, and an event type this build does not know keeps
|
|
||||||
//! every field it arrived with for the build that will. Rows are
|
|
||||||
//! deliberately *not* what is stored: a row is a rendering, and a cache of
|
|
||||||
//! rows would need throwing away on every update that touched the fold.
|
|
||||||
//!
|
|
||||||
//! Four rules run through all of it:
|
|
||||||
//! 1. what is on screen is what the server's transcript says, in order,
|
|
||||||
//! with nothing missing -- the cache is a copy and is never inferred,
|
|
||||||
//! folded or edited here;
|
|
||||||
//! 2. a cached line is never ahead of the live cursor, and the cursor never
|
|
||||||
//! ahead of the cache;
|
|
||||||
//! 3. the cache is never load-bearing -- missing, evicted, damaged or
|
|
||||||
//! unwritable all degrade to a cold open, never to a blank or a wrong
|
|
||||||
//! screen;
|
|
||||||
//! 4. a line already on the phone is not fetched again.
|
|
||||||
//!
|
|
||||||
//! No JSON parser here: what it needs off a line is the sequence number and
|
|
||||||
//! whether the line is a streamed delta, both read with a regex-free scan
|
|
||||||
//! (see [`seq_of`] and [`is_delta`]). A line it cannot read that way is
|
|
||||||
//! treated as damage.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// How much of this phone's cache directory all of one server's transcripts
|
|
||||||
/// may take. A dozen of the largest transcripts seen in the dev VM (21 MB
|
|
||||||
/// for 24,000 events) and a small fraction of a phone. A number to revisit
|
|
||||||
/// against real use rather than a measurement of anything.
|
|
||||||
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
|
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
|
||||||
|
|
||||||
/// What the newest cached line says, which is what the probe checks against
|
|
||||||
/// the server.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CachedTail {
|
pub struct CachedTail {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
pub line: String,
|
pub line: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This phone's cache root for one server, holding one directory per session.
|
|
||||||
pub struct TranscriptCache {
|
pub struct TranscriptCache {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
warn: Arc<dyn Fn(&str) + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TranscriptCache {
|
impl TranscriptCache {
|
||||||
@@ -64,12 +29,10 @@ impl TranscriptCache {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
root: root.into(),
|
root: root.into(),
|
||||||
warn: std::sync::Arc::new(warn),
|
warn: Arc::new(warn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cache for one session, whether or not anything has been stored
|
|
||||||
/// for it yet.
|
|
||||||
pub fn session(&self, id: &str) -> SessionCache {
|
pub fn session(&self, id: &str) -> SessionCache {
|
||||||
SessionCache::new(self.root.join(id), self.warn.clone())
|
SessionCache::new(self.root.join(id), self.warn.clone())
|
||||||
}
|
}
|
||||||
@@ -78,7 +41,7 @@ impl TranscriptCache {
|
|||||||
/// successful list fetch. The path out for a session deleted on
|
/// successful list fetch. The path out for a session deleted on
|
||||||
/// another device: nothing here would otherwise hear about it, and
|
/// another device: nothing here would otherwise hear about it, and
|
||||||
/// unlike a draft's few bytes what it leaves behind is megabytes.
|
/// unlike a draft's few bytes what it leaves behind is megabytes.
|
||||||
pub fn retain_only(&self, ids: &std::collections::HashSet<String>) {
|
pub fn retain_only(&self, ids: &HashSet<String>) {
|
||||||
guard_io((), self.warn.as_ref(), || {
|
guard_io((), self.warn.as_ref(), || {
|
||||||
for dir in session_dirs(&self.root)? {
|
for dir in session_dirs(&self.root)? {
|
||||||
if let Some(name) = dir.file_name().and_then(|n| n.to_str())
|
if let Some(name) = dir.file_name().and_then(|n| n.to_str())
|
||||||
@@ -99,12 +62,12 @@ impl TranscriptCache {
|
|||||||
/// for.
|
/// for.
|
||||||
pub fn evict_to_budget(&self, keep: &str, budget: u64) {
|
pub fn evict_to_budget(&self, keep: &str, budget: u64) {
|
||||||
guard_io((), self.warn.as_ref(), || {
|
guard_io((), self.warn.as_ref(), || {
|
||||||
let mut dirs: Vec<(PathBuf, std::time::SystemTime)> = session_dirs(&self.root)?
|
let mut dirs: Vec<(PathBuf, SystemTime)> = session_dirs(&self.root)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
let modified = fs::metadata(&d)
|
let modified = fs::metadata(&d)
|
||||||
.and_then(|m| m.modified())
|
.and_then(|m| m.modified())
|
||||||
.unwrap_or(std::time::UNIX_EPOCH);
|
.unwrap_or(UNIX_EPOCH);
|
||||||
(d, modified)
|
(d, modified)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -164,46 +127,18 @@ fn dir_size(path: &Path) -> u64 {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One session's cached lines, as a directory of chunks.
|
|
||||||
///
|
|
||||||
/// A chunk is a set of lines *and a claim about what they cover*, and the
|
|
||||||
/// two are not the same thing: a coalesced page joins each run of streamed
|
|
||||||
/// deltas into one event carrying the seq of the run's oldest delta, so a
|
|
||||||
/// page whose newest event is seq 1,200 may cover everything up to the
|
|
||||||
/// 1,650 it was fetched with, and nothing in the lines says so. So coverage
|
|
||||||
/// is the half-open range in the file's name:
|
|
||||||
/// `<first>-<end>.rows.jsonl` (a coalesced page; `end` is the `before` it
|
|
||||||
/// was fetched with) or `<first>-<end>.raw.jsonl` (an uncoalesced page, or a
|
|
||||||
/// closed live run); `<first>-open.raw.jsonl` is the live run, whose end is
|
|
||||||
/// its last line's seq + 1.
|
|
||||||
///
|
|
||||||
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
|
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
|
||||||
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
|
||||||
/// ever served: chunks behind a gap are kept, because the gap is usually
|
/// ever served: chunks behind a gap are kept, because the gap is usually
|
||||||
/// closed by paging back through it, but nothing is served across one.
|
/// closed by paging back through it, but nothing is served across one.
|
||||||
///
|
|
||||||
/// **The newest chunk is always raw**, which is what makes the stream
|
|
||||||
/// cursor and the probe well defined.
|
|
||||||
///
|
|
||||||
/// Nothing here is load-bearing. Every operation that touches the disk
|
|
||||||
/// answers as though the cache were empty when it cannot, and a write
|
|
||||||
/// failure disables writing for the rest of this instance's life so that a
|
|
||||||
/// full disk costs one log line rather than one per delta.
|
|
||||||
///
|
|
||||||
/// A `Mutex` around the writer state stands in for Kotlin's `@Synchronized`:
|
|
||||||
/// the stream appends live events from its own thread while a reader
|
|
||||||
/// scrolling back reads pages from another, and this is what keeps the open
|
|
||||||
/// chunk's name, its end and its writer from being read half-rotated.
|
|
||||||
pub struct SessionCache {
|
pub struct SessionCache {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
|
warn: Arc<dyn Fn(&str) + Send + Sync>,
|
||||||
state: Mutex<WriterState>,
|
state: Mutex<WriterState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct WriterState {
|
struct WriterState {
|
||||||
/// Set by the first write that fails: a second would fail the same way,
|
|
||||||
/// once per delta.
|
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
writer: Option<fs::File>,
|
writer: Option<fs::File>,
|
||||||
open_file: Option<PathBuf>,
|
open_file: Option<PathBuf>,
|
||||||
@@ -211,7 +146,7 @@ struct WriterState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SessionCache {
|
impl SessionCache {
|
||||||
fn new(dir: PathBuf, warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>) -> Self {
|
fn new(dir: PathBuf, warn: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
dir,
|
dir,
|
||||||
warn,
|
warn,
|
||||||
@@ -238,7 +173,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
|
|
||||||
pub fn newest(&self, limit: usize) -> Vec<String> {
|
pub fn newest(&self, limit: usize) -> Vec<String> {
|
||||||
self.guard(Vec::new(), |this, state| {
|
self.guard(Vec::new(), |this, state| {
|
||||||
let mut taken: VecDeque<String> = VecDeque::new();
|
let mut taken: VecDeque<String> = VecDeque::new();
|
||||||
@@ -262,11 +196,6 @@ impl SessionCache {
|
|||||||
/// below `before` -- and means the server has to be asked. Deliberately
|
/// below `before` -- and means the server has to be asked. Deliberately
|
||||||
/// not an empty list: an empty page is how the screen is told it has
|
/// not an empty list: an empty page is how the screen is told it has
|
||||||
/// reached the start of the conversation.
|
/// reached the start of the conversation.
|
||||||
///
|
|
||||||
/// With `rows` the count is rows rather than lines, mirroring the
|
|
||||||
/// server's `parse_coalesced`. The deltas are not joined here -- the
|
|
||||||
/// fold does that, and the joined row keeps the seq of its first delta
|
|
||||||
/// either way.
|
|
||||||
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
|
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
|
||||||
self.guard(None, |this, state| {
|
self.guard(None, |this, state| {
|
||||||
let suffix = this.suffix(state)?;
|
let suffix = this.suffix(state)?;
|
||||||
@@ -291,18 +220,12 @@ impl SessionCache {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.each_line(state, chunk, |line| {
|
this.each_line(state, chunk, |line| {
|
||||||
// The page is what is *before* the cursor; the rows at
|
|
||||||
// or above it are already on screen.
|
|
||||||
let seq = seq_of(line).expect("chunk lines are checked in each_line");
|
let seq = seq_of(line).expect("chunk lines are checked in each_line");
|
||||||
if seq >= before {
|
if seq >= before {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if rows {
|
if rows {
|
||||||
let delta = is_delta(line);
|
let delta = is_delta(line);
|
||||||
// Stop only between rows: a delta continuing the
|
|
||||||
// run being gathered is part of a row already
|
|
||||||
// counted, and breaking on it would drop the half
|
|
||||||
// of that row already taken.
|
|
||||||
if counted >= limit && !(delta && in_run) {
|
if counted >= limit && !(delta && in_run) {
|
||||||
wanting = false;
|
wanting = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -338,9 +261,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores a fetched page covering `[first, end)`; `false` when it was
|
|
||||||
/// not stored.
|
|
||||||
///
|
|
||||||
/// Refused when it overlaps a chunk already here, because there is no
|
/// Refused when it overlaps a chunk already here, because there is no
|
||||||
/// clean cut: a coalesced event cannot be split at a seq inside its own
|
/// clean cut: a coalesced event cannot be split at a seq inside its own
|
||||||
/// delta run. The caller keeps that from arising by bounding what it
|
/// delta run. The caller keeps that from arising by bounding what it
|
||||||
@@ -361,6 +281,10 @@ impl SessionCache {
|
|||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
debug_assert!(
|
||||||
|
lines.iter().all(|l| !l.contains('\n')),
|
||||||
|
"a stored page's lines must each be one line"
|
||||||
|
);
|
||||||
fs::create_dir_all(&this.dir)?;
|
fs::create_dir_all(&this.dir)?;
|
||||||
let kind = if rows { "rows" } else { "raw" };
|
let kind = if rows { "rows" } else { "raw" };
|
||||||
let mut content = lines.join("\n");
|
let mut content = lines.join("\n");
|
||||||
@@ -373,13 +297,6 @@ impl SessionCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends one live event, which is also how a freshly fetched opening
|
|
||||||
/// window is stored.
|
|
||||||
///
|
|
||||||
/// A seq equal to the open chunk's end extends it. A larger one is a
|
|
||||||
/// gap -- which is what a `reset` looks like from here -- and closes
|
|
||||||
/// the open chunk under the end it turned out to have. A smaller one is
|
|
||||||
/// already covered and is ignored; the SSE contract is `seq > after`.
|
|
||||||
pub fn append(&self, line: &str, seq: u64) {
|
pub fn append(&self, line: &str, seq: u64) {
|
||||||
self.guard((), |this, state| {
|
self.guard((), |this, state| {
|
||||||
if state.disabled {
|
if state.disabled {
|
||||||
@@ -388,9 +305,10 @@ impl SessionCache {
|
|||||||
let Some(writer) = this.writer_for(state, seq)? else {
|
let Some(writer) = this.writer_for(state, seq)? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// Written as it arrived. A newline inside it would split one
|
debug_assert!(
|
||||||
// event into two unreadable halves, but neither source can
|
!line.contains('\n'),
|
||||||
// produce one.
|
"a cached transcript line must be one line: {line}"
|
||||||
|
);
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
writer.write_all(line.as_bytes())?;
|
writer.write_all(line.as_bytes())?;
|
||||||
writer.write_all(b"\n")?;
|
writer.write_all(b"\n")?;
|
||||||
@@ -399,7 +317,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flushes what [`Self::append`] has buffered.
|
|
||||||
pub fn flush(&self) {
|
pub fn flush(&self) {
|
||||||
self.guard((), |_this, state| {
|
self.guard((), |_this, state| {
|
||||||
if let Some(writer) = state.writer.as_mut() {
|
if let Some(writer) = state.writer.as_mut() {
|
||||||
@@ -410,16 +327,14 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`Self::purge`] would discard, for the reload row in session settings.
|
|
||||||
pub fn bytes(&self) -> u64 {
|
pub fn bytes(&self) -> u64 {
|
||||||
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
|
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks this session as visited, which is what eviction ranks by.
|
|
||||||
pub fn touch(&self) {
|
pub fn touch(&self) {
|
||||||
self.guard((), |this, _state| {
|
self.guard((), |this, _state| {
|
||||||
if this.dir.is_dir() {
|
if this.dir.is_dir() {
|
||||||
let now = std::time::SystemTime::now();
|
let now = SystemTime::now();
|
||||||
filetime_set_modified(&this.dir, now)?;
|
filetime_set_modified(&this.dir, now)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -436,8 +351,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- chunks ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Every chunk on disk, oldest first. A name this does not recognise is
|
/// Every chunk on disk, oldest first. A name this does not recognise is
|
||||||
/// not ours and is ignored. Recomputed per operation rather than kept:
|
/// not ours and is ignored. Recomputed per operation rather than kept:
|
||||||
/// another operation may have changed the directory.
|
/// another operation may have changed the directory.
|
||||||
@@ -463,9 +376,6 @@ impl SessionCache {
|
|||||||
} else {
|
} else {
|
||||||
end_str.parse::<u64>().ok()
|
end_str.parse::<u64>().ok()
|
||||||
};
|
};
|
||||||
// A chunk covering nothing is one that was created and never
|
|
||||||
// written to -- an append whose very first write failed. It
|
|
||||||
// says nothing, so it is not a chunk.
|
|
||||||
if let Some(end) = end
|
if let Some(end) = end
|
||||||
&& end > first
|
&& end > first
|
||||||
{
|
{
|
||||||
@@ -482,13 +392,6 @@ impl SessionCache {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The open chunk's end: its last line's seq plus one, or the in-memory
|
|
||||||
/// end while this instance is the one writing it.
|
|
||||||
///
|
|
||||||
/// An open chunk whose last line cannot be read is this app having died
|
|
||||||
/// mid-write. That line is dropped and the file truncated to the last
|
|
||||||
/// good one, which is the one place damage is repaired rather than
|
|
||||||
/// discarded.
|
|
||||||
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
|
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
|
||||||
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
|
||||||
return Some(state.open_end);
|
return Some(state.open_end);
|
||||||
@@ -504,12 +407,6 @@ impl SessionCache {
|
|||||||
Some(end)
|
Some(end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The contiguous run of adjacent chunks ending at the newest one,
|
|
||||||
/// oldest first.
|
|
||||||
///
|
|
||||||
/// A newest chunk that is not raw cannot happen while this code is the
|
|
||||||
/// only writer, and means the directory is not to be trusted -- so the
|
|
||||||
/// session is discarded.
|
|
||||||
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
|
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
|
||||||
let all = self.chunks(state)?;
|
let all = self.chunks(state)?;
|
||||||
let Some(newest) = all.last() else {
|
let Some(newest) = all.last() else {
|
||||||
@@ -528,8 +425,6 @@ impl SessionCache {
|
|||||||
Ok(run.into_iter().collect())
|
Ok(run.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Each line of `chunk`, newest first, until `take` says stop.
|
|
||||||
///
|
|
||||||
/// Damage anywhere but at the tail of the open chunk was not written by
|
/// Damage anywhere but at the tail of the open chunk was not written by
|
||||||
/// this code, and there is no honest way to say what a chunk covers
|
/// this code, and there is no honest way to say what a chunk covers
|
||||||
/// with a line of it unreadable -- so it is treated as damage rather
|
/// with a line of it unreadable -- so it is treated as damage rather
|
||||||
@@ -553,10 +448,6 @@ impl SessionCache {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- writing -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// The writer for the chunk `seq` belongs in, opening or rotating one
|
|
||||||
/// as it has to.
|
|
||||||
fn writer_for<'s>(
|
fn writer_for<'s>(
|
||||||
&self,
|
&self,
|
||||||
state: &'s mut WriterState,
|
state: &'s mut WriterState,
|
||||||
@@ -569,14 +460,10 @@ impl SessionCache {
|
|||||||
if seq < state.open_end {
|
if seq < state.open_end {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// A gap: what this instance has written covers up to `open_end`,
|
|
||||||
// and that is the name the chunk gets before a new one starts
|
|
||||||
// at the arriving seq.
|
|
||||||
let end = state.open_end;
|
let end = state.open_end;
|
||||||
self.close_open_chunk(state, end);
|
self.close_open_chunk(state, end);
|
||||||
}
|
}
|
||||||
fs::create_dir_all(&self.dir)?;
|
fs::create_dir_all(&self.dir)?;
|
||||||
// An open chunk left by an earlier instance, or by an earlier screen.
|
|
||||||
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
|
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
|
||||||
if let Some(existing) = existing {
|
if let Some(existing) = existing {
|
||||||
if seq < existing.end {
|
if seq < existing.end {
|
||||||
@@ -620,8 +507,6 @@ impl SessionCache {
|
|||||||
Ok(state.writer.as_mut())
|
Ok(state.writer.as_mut())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renames the open chunk to the range it turned out to cover, so it
|
|
||||||
/// stops being open.
|
|
||||||
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
|
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
|
||||||
let file = state.open_file.clone();
|
let file = state.open_file.clone();
|
||||||
close_writer(state);
|
close_writer(state);
|
||||||
@@ -635,31 +520,17 @@ impl SessionCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- failure -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Runs `body`, answering `if_broken` when the directory cannot give a
|
|
||||||
/// real answer. None of this is reported on screen: every read here has
|
|
||||||
/// a network path beside it producing the same result, and the reader
|
|
||||||
/// has nothing to do about it. Damage discards this session's cache,
|
|
||||||
/// which makes the next open an ordinary cold one.
|
|
||||||
fn guard<T>(
|
fn guard<T>(
|
||||||
&self,
|
&self,
|
||||||
if_broken: T,
|
if_broken: T,
|
||||||
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
|
||||||
) -> T {
|
) -> T {
|
||||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
// A disk that refused once will refuse again, once per delta, so
|
|
||||||
// the first refusal is also the last.
|
|
||||||
if state.disabled {
|
if state.disabled {
|
||||||
return if_broken;
|
return if_broken;
|
||||||
}
|
}
|
||||||
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
DAMAGED.with(|cell| *cell.borrow_mut() = None);
|
||||||
let result = body(self, &mut state);
|
let result = body(self, &mut state);
|
||||||
// Damage takes priority over whatever `body` returned, `Ok` or
|
|
||||||
// `Err`: `suffix` signals it by returning `Err(damaged(..))`
|
|
||||||
// precisely so this check catches it before the branch below
|
|
||||||
// mistakes it for a real I/O failure and disables the whole cache
|
|
||||||
// over one corrupt session.
|
|
||||||
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
|
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
|
||||||
(self.warn)(&format!(
|
(self.warn)(&format!(
|
||||||
"transcript cache damaged at {}; discarding {}",
|
"transcript cache damaged at {}; discarding {}",
|
||||||
@@ -683,11 +554,6 @@ impl SessionCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// How [`SessionCache::each_line`] reports a line it cannot make sense
|
|
||||||
/// of back up to [`SessionCache::guard`], since the callback it hands
|
|
||||||
/// `each_line_backwards` cannot itself return a `Result`. Thread-local
|
|
||||||
/// rather than a field: the guard that reads it always runs on the same
|
|
||||||
/// call stack that could have set it, one `guard` call at a time.
|
|
||||||
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,8 +581,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
|
|||||||
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
|
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is
|
|
||||||
/// not ours.
|
|
||||||
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
||||||
let rest = name.strip_suffix(".jsonl")?;
|
let rest = name.strip_suffix(".jsonl")?;
|
||||||
let (rest, kind) = rest.rsplit_once('.')?;
|
let (rest, kind) = rest.rsplit_once('.')?;
|
||||||
@@ -732,22 +596,14 @@ fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One line's sequence number, or `None` when the line is not one of ours.
|
/// One line's sequence number, or `None` when the line is not one of ours.
|
||||||
///
|
|
||||||
/// A hand-rolled scan rather than a JSON parse, so this module carries no
|
|
||||||
/// parser and stays testable with no server: the seq is the first field the
|
|
||||||
/// server writes, so the first match is the top-level one.
|
|
||||||
pub fn seq_of(line: &str) -> Option<u64> {
|
pub fn seq_of(line: &str) -> Option<u64> {
|
||||||
find_number_field(line, "seq")
|
find_number_field(line, "seq")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a line is one streamed piece of a reply, which is what makes a
|
|
||||||
/// run of them one row.
|
|
||||||
pub fn is_delta(line: &str) -> bool {
|
pub fn is_delta(line: &str) -> bool {
|
||||||
find_string_field(line, "type").as_deref() == Some("assistantText")
|
find_string_field(line, "type").as_deref() == Some("assistantText")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of `"key":N` (any amount of whitespace around the colon), or
|
|
||||||
/// `None`. Mirrors `Regex(""""seq"\s*:\s*(\d+)""")`'s first match.
|
|
||||||
fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
||||||
let pattern = format!("\"{key}\"");
|
let pattern = format!("\"{key}\"");
|
||||||
let at = line.find(&pattern)?;
|
let at = line.find(&pattern)?;
|
||||||
@@ -765,8 +621,6 @@ fn find_number_field(line: &str, key: &str) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The value of `"key":"..."`, or `None`. Mirrors
|
|
||||||
/// `Regex(""""type"\s*:\s*"([^"]*)"""")`'s first match.
|
|
||||||
fn find_string_field(line: &str, key: &str) -> Option<String> {
|
fn find_string_field(line: &str, key: &str) -> Option<String> {
|
||||||
let pattern = format!("\"{key}\"");
|
let pattern = format!("\"{key}\"");
|
||||||
let at = line.find(&pattern)?;
|
let at = line.find(&pattern)?;
|
||||||
@@ -777,18 +631,11 @@ fn find_string_field(line: &str, key: &str) -> Option<String> {
|
|||||||
Some(after_quote[..end].to_string())
|
Some(after_quote[..end].to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of a file is read at a time when walking it backwards. One
|
|
||||||
/// block covers a page of a transcript comfortably, and the walk stops as
|
|
||||||
/// soon as the caller has what it asked for.
|
|
||||||
const READ_BLOCK: usize = 64 * 1024;
|
const READ_BLOCK: usize = 64 * 1024;
|
||||||
|
|
||||||
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
|
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
|
||||||
/// along with the byte offset it starts at, until `on_line` answers false.
|
/// along with the byte offset it starts at, until `on_line` answers false.
|
||||||
///
|
///
|
||||||
/// Every question the cache is asked is about the newest end of a chunk,
|
|
||||||
/// and a live run reaches the size of the conversation, so reading forwards
|
|
||||||
/// means reading a transcript to answer with the last eighty lines of it.
|
|
||||||
///
|
|
||||||
/// Splitting on bytes is safe because the separator is `\n`, which cannot
|
/// Splitting on bytes is safe because the separator is `\n`, which cannot
|
||||||
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
|
||||||
/// missing file yields nothing.
|
/// missing file yields nothing.
|
||||||
@@ -811,7 +658,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
|||||||
}
|
}
|
||||||
let mut buffer = block;
|
let mut buffer = block;
|
||||||
buffer.extend_from_slice(&pending);
|
buffer.extend_from_slice(&pending);
|
||||||
// `buffer` is now `block` followed by `pending`; walk it backwards.
|
|
||||||
let mut line_end = buffer.len();
|
let mut line_end = buffer.len();
|
||||||
let mut at = buffer.len() as isize - 1;
|
let mut at = buffer.len() as isize - 1;
|
||||||
while at >= 0 {
|
while at >= 0 {
|
||||||
@@ -828,20 +674,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
|
|||||||
pending = buffer[..line_end].to_vec();
|
pending = buffer[..line_end].to_vec();
|
||||||
unread = start;
|
unread = start;
|
||||||
}
|
}
|
||||||
// The first line of a file has no newline before it to be found.
|
|
||||||
let first = String::from_utf8_lossy(&pending);
|
let first = String::from_utf8_lossy(&pending);
|
||||||
if !first.trim().is_empty() {
|
if !first.trim().is_empty() {
|
||||||
on_line(0, &first);
|
on_line(0, &first);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a final line that is not one of ours, by truncating the file to
|
|
||||||
/// where it starts.
|
|
||||||
///
|
|
||||||
/// This app having died mid-write is the one kind of damage that is
|
|
||||||
/// repaired rather than discarded: the tail of an append-only file is the
|
|
||||||
/// only place a partial line can be. A second bad line is not this, and is
|
|
||||||
/// left for the read path to notice.
|
|
||||||
fn repair_tail(file: &Path) -> io::Result<()> {
|
fn repair_tail(file: &Path) -> io::Result<()> {
|
||||||
let mut truncate_to: Option<u64> = None;
|
let mut truncate_to: Option<u64> = None;
|
||||||
each_line_backwards(file, |offset, line| {
|
each_line_backwards(file, |offset, line| {
|
||||||
@@ -857,9 +695,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `body`, translating an I/O or permission failure into `if_broken`
|
|
||||||
/// and a warning -- the disk half of [`SessionCache::guard`], shared with
|
|
||||||
/// [`TranscriptCache`]'s own maintenance.
|
|
||||||
fn guard_io<T>(
|
fn guard_io<T>(
|
||||||
if_broken: T,
|
if_broken: T,
|
||||||
warn: &(impl Fn(&str) + ?Sized),
|
warn: &(impl Fn(&str) + ?Sized),
|
||||||
@@ -874,11 +709,7 @@ fn guard_io<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a path's modified time, without pulling in a crate for it: a single
|
fn filetime_set_modified(path: &Path, _when: SystemTime) -> io::Result<()> {
|
||||||
/// `utimensat`-backed call would be one more platform-specific dependency
|
|
||||||
/// for one call site, so this touches the file instead, which every
|
|
||||||
/// filesystem this runs on updates the mtime for.
|
|
||||||
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
// Rewriting a marker file's contents (rather than the directory itself,
|
// Rewriting a marker file's contents (rather than the directory itself,
|
||||||
// which `std` has no portable "touch" for) bumps the directory's own
|
// which `std` has no portable "touch" for) bumps the directory's own
|
||||||
@@ -898,19 +729,17 @@ fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Resul
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
fn cache(temp: &Path) -> TranscriptCache {
|
fn cache(temp: &Path) -> TranscriptCache {
|
||||||
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
let said: Arc<Mutex<Vec<String>>> = Default::default();
|
||||||
let said2 = said.clone();
|
let said2 = said.clone();
|
||||||
TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| {
|
TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| {
|
||||||
said2.lock().unwrap().push(msg.to_string());
|
said2.lock().unwrap().push(msg.to_string());
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `cache`, but also hands back the messages it warned with.
|
fn cache_with_log(temp: &Path) -> (TranscriptCache, Arc<Mutex<Vec<String>>>) {
|
||||||
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
|
let said: Arc<Mutex<Vec<String>>> = Default::default();
|
||||||
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
|
|
||||||
let said2 = said.clone();
|
let said2 = said.clone();
|
||||||
(
|
(
|
||||||
TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| {
|
TranscriptCache::with_warn(temp.join("v1/host_8443"), move |msg| {
|
||||||
@@ -984,8 +813,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
|
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
|
||||||
// More than there is is what there is, which is a short opening
|
|
||||||
// window and not a failure.
|
|
||||||
assert_eq!(session.newest(80).len(), 3);
|
assert_eq!(session.newest(80).len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,8 +824,6 @@ mod tests {
|
|||||||
for seq in 1..=3u64 {
|
for seq in 1..=3u64 {
|
||||||
session.append(&tool_line(seq), seq);
|
session.append(&tool_line(seq), seq);
|
||||||
}
|
}
|
||||||
// What a `reset` looks like from here: the next event is not the
|
|
||||||
// one after the last.
|
|
||||||
session.append(&tool_line(90), 90);
|
session.append(&tool_line(90), 90);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
@@ -1040,14 +865,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// Adjacent: its end is the open chunk's first.
|
|
||||||
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
let page: Vec<String> = (60..100u64).map(tool_line).collect();
|
||||||
assert!(session.store_page(&page, 60, 100, true));
|
assert!(session.store_page(&page, 60, 100, true));
|
||||||
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
|
||||||
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
|
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
|
||||||
|
|
||||||
// Behind a gap: kept on disk, because paging usually closes the
|
|
||||||
// gap, but never served across it.
|
|
||||||
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
|
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
|
||||||
assert!(session.store_page(&page2, 1, 10, true));
|
assert!(session.store_page(&page2, 1, 10, true));
|
||||||
assert_eq!(session.page(10, 5, false), None);
|
assert_eq!(session.page(10, 5, false), None);
|
||||||
@@ -1083,8 +905,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// At or below where the run starts, so what the reader is
|
|
||||||
// scrolling into is the server's.
|
|
||||||
assert_eq!(session.page(100, 40, true), None);
|
assert_eq!(session.page(100, 40, true), None);
|
||||||
assert_eq!(session.page(40, 40, true), None);
|
assert_eq!(session.page(40, 40, true), None);
|
||||||
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
|
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
|
||||||
@@ -1109,8 +929,6 @@ mod tests {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let cache = cache(temp.path());
|
let cache = cache(temp.path());
|
||||||
let session = cache.session("s");
|
let session = cache.session("s");
|
||||||
// Two replies of three deltas each, split by a tool call: the same
|
|
||||||
// fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`.
|
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
delta(1),
|
delta(1),
|
||||||
delta(2),
|
delta(2),
|
||||||
@@ -1125,14 +943,8 @@ mod tests {
|
|||||||
session.append(&tool_line(9), 9);
|
session.append(&tool_line(9), 9);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// Three rows: the tool call at 8, the run 5..7, and the tool call
|
|
||||||
// at 4. The cut lands between rows, so the older run is not
|
|
||||||
// started.
|
|
||||||
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
|
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
|
||||||
// One row is one whole run, however many deltas it is made of.
|
|
||||||
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
|
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
|
||||||
// A page of lines counts lines, which is what the anchor restore
|
|
||||||
// asks for.
|
|
||||||
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
|
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,10 +963,7 @@ mod tests {
|
|||||||
session.append(&tool_line(10), 10);
|
session.append(&tool_line(10), 10);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// A run straddling the boundary is one row, as it will be once folded.
|
|
||||||
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
|
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
|
||||||
// Asking for more rows than the suffix holds is a short page, not a
|
|
||||||
// failure and not a claim that the conversation starts here.
|
|
||||||
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
|
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,13 +987,9 @@ mod tests {
|
|||||||
session.append(&tool_line(90), 90);
|
session.append(&tool_line(90), 90);
|
||||||
session.flush();
|
session.flush();
|
||||||
|
|
||||||
// The run behind the gap, which is what makes the fetched page
|
|
||||||
// adjacent to it.
|
|
||||||
assert_eq!(session.covered_up_to(90), Some(40));
|
assert_eq!(session.covered_up_to(90), Some(40));
|
||||||
assert_eq!(session.covered_up_to(41), Some(40));
|
assert_eq!(session.covered_up_to(41), Some(40));
|
||||||
assert_eq!(session.covered_up_to(10), Some(10));
|
assert_eq!(session.covered_up_to(10), Some(10));
|
||||||
// Nothing at or below the oldest chunk's start, so the page is
|
|
||||||
// bounded only by its limit.
|
|
||||||
assert_eq!(session.covered_up_to(9), None);
|
assert_eq!(session.covered_up_to(9), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1200,9 +1005,6 @@ mod tests {
|
|||||||
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
|
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only reachable by dying between closing one live run and opening
|
|
||||||
// the next, and there is no cursor to be read off a coalesced line
|
|
||||||
// -- so the open is a cold one.
|
|
||||||
assert_eq!(session.tail(), None);
|
assert_eq!(session.tail(), None);
|
||||||
assert!(!dir_of(temp.path(), "s").exists());
|
assert!(!dir_of(temp.path(), "s").exists());
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1033,6 @@ mod tests {
|
|||||||
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
|
||||||
format!("{}\n{}\n", tool_line(1), tool_line(2))
|
format!("{}\n{}\n", tool_line(1), tool_line(2))
|
||||||
);
|
);
|
||||||
// And the run continues from where the good tail left off.
|
|
||||||
session.append(&tool_line(3), 3);
|
session.append(&tool_line(3), 3);
|
||||||
session.flush();
|
session.flush();
|
||||||
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
|
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
|
||||||
@@ -1249,7 +1050,6 @@ mod tests {
|
|||||||
&[tool_line(1), "not ours".to_string(), tool_line(3)],
|
&[tool_line(1), "not ours".to_string(), tool_line(3)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Not seen by the tail, which reads the newest line and stops.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session.tail(),
|
session.tail(),
|
||||||
Some(CachedTail {
|
Some(CachedTail {
|
||||||
@@ -1257,8 +1057,6 @@ mod tests {
|
|||||||
line: tool_line(3)
|
line: tool_line(3)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
// Reached by a read that walks past it: what is served is nothing,
|
|
||||||
// and the session opens cold from here on.
|
|
||||||
assert_eq!(session.newest(80), Vec::<String>::new());
|
assert_eq!(session.newest(80), Vec::<String>::new());
|
||||||
assert!(!dir_of(temp.path(), "s").exists());
|
assert!(!dir_of(temp.path(), "s").exists());
|
||||||
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
|
||||||
@@ -1286,9 +1084,6 @@ mod tests {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let cache = cache(temp.path());
|
let cache = cache(temp.path());
|
||||||
let session = cache.session("s");
|
let session = cache.session("s");
|
||||||
// Well past the 64 kB block the backwards reader takes at a time,
|
|
||||||
// so a page has to be stitched across several of them -- including
|
|
||||||
// a line that straddles a boundary.
|
|
||||||
let padding = "x".repeat(300);
|
let padding = "x".repeat(300);
|
||||||
let lines: Vec<String> = (1..=500u64)
|
let lines: Vec<String> = (1..=500u64)
|
||||||
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
|
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
|
||||||
@@ -1298,8 +1093,6 @@ mod tests {
|
|||||||
assert_eq!(session.tail().unwrap().seq, 500);
|
assert_eq!(session.tail().unwrap().seq, 500);
|
||||||
assert_eq!(session.newest(80), lines[420..].to_vec());
|
assert_eq!(session.newest(80), lines[420..].to_vec());
|
||||||
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
|
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
|
||||||
// And a non-ASCII line, whose bytes a naive split could cut through
|
|
||||||
// a character.
|
|
||||||
let accented =
|
let accented =
|
||||||
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
|
||||||
session.append(&accented, 501);
|
session.append(&accented, 501);
|
||||||
@@ -1321,15 +1114,10 @@ mod tests {
|
|||||||
let when =
|
let when =
|
||||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
|
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
|
||||||
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
|
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
|
||||||
// The mtime touch above always sets "now", not `when` (see its
|
|
||||||
// own doc) -- space the three writes out in real time instead,
|
|
||||||
// since only relative order matters to eviction.
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
let each = dir_size(&dir_of(temp.path(), "old"));
|
let each = dir_size(&dir_of(temp.path(), "old"));
|
||||||
|
|
||||||
// Room for two of the three, so the oldest goes -- and the session
|
|
||||||
// being read never does, however long ago it was last touched.
|
|
||||||
cache.evict_to_budget("open", each * 2);
|
cache.evict_to_budget("open", each * 2);
|
||||||
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1382,8 +1170,6 @@ mod tests {
|
|||||||
session.purge();
|
session.purge();
|
||||||
assert_eq!(session.bytes(), 0);
|
assert_eq!(session.bytes(), 0);
|
||||||
assert_eq!(session.tail(), None);
|
assert_eq!(session.tail(), None);
|
||||||
// And the session is usable again straight afterwards, which is
|
|
||||||
// what a reload does next.
|
|
||||||
session.append(&tool_line(9), 9);
|
session.append(&tool_line(9), 9);
|
||||||
session.flush();
|
session.flush();
|
||||||
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
|
||||||
@@ -1,32 +1,6 @@
|
|||||||
//! What the transcript renders: the event stream folded into displayable
|
|
||||||
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
|
|
||||||
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
|
|
||||||
//!
|
|
||||||
//! Events are the only data source, and there is deliberately no second
|
|
||||||
//! shape for history to drift from: a page fetched backwards, a live
|
|
||||||
//! frame, and a line read out of the transcript cache are all the same
|
|
||||||
//! events through the same fold.
|
|
||||||
//!
|
|
||||||
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
|
|
||||||
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
|
|
||||||
//! exists to bound how much a lazy list composes per frame, which is a
|
|
||||||
//! fact about the UI framework drawing it, not about the transcript. See
|
|
||||||
//! `CLIENT_CORE.md`.
|
|
||||||
//!
|
|
||||||
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
|
|
||||||
//! deserializes straight into [`event_model::Event`], which has no
|
|
||||||
//! `Unknown` catch-all -- an event type this build does not recognise
|
|
||||||
//! fails to parse rather than degrading to a placeholder row. Closing that
|
|
||||||
//! gap means giving `event_model::Event` its own forward-compatible
|
|
||||||
//! variant, which is a shared-model decision for both sides of the wire
|
|
||||||
//! and is deliberately left for whoever picks this up next (see
|
|
||||||
//! `CLIENT_CORE.md`).
|
|
||||||
|
|
||||||
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
/// A question this build has already asked the reader about, with what was
|
|
||||||
/// answered so far -- distinct from [`QuestionOption`], which is what could
|
|
||||||
/// be chosen.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct QuestionCard {
|
pub struct QuestionCard {
|
||||||
pub seq: u64,
|
pub seq: u64,
|
||||||
@@ -38,24 +12,14 @@ pub struct QuestionCard {
|
|||||||
pub answers: Vec<String>,
|
pub answers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
|
|
||||||
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
|
|
||||||
/// logic name it explicitly.
|
|
||||||
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
|
||||||
|
|
||||||
/// This item's identity in the list: a `Seq` for everything with no
|
|
||||||
/// identity of its own, `RunId` for a tool call (which keeps one across
|
|
||||||
/// however many calls join or leave its run), matching `TranscriptItem.key`
|
|
||||||
/// in the Kotlin original.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub enum ItemKey {
|
pub enum ItemKey {
|
||||||
Seq(u64),
|
Seq(u64),
|
||||||
RunId(String),
|
RunId(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the transcript, folded from [`Event`]s. See each variant's
|
|
||||||
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
|
|
||||||
/// doc only says what changed in translation.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum TranscriptItem {
|
pub enum TranscriptItem {
|
||||||
UserMsg {
|
UserMsg {
|
||||||
@@ -66,8 +30,6 @@ pub enum TranscriptItem {
|
|||||||
AssistantMsg {
|
AssistantMsg {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
|
|
||||||
/// Kotlin doc for why the split it licenses matters.
|
|
||||||
settled: bool,
|
settled: bool,
|
||||||
},
|
},
|
||||||
ToolRun {
|
ToolRun {
|
||||||
@@ -78,6 +40,11 @@ pub enum TranscriptItem {
|
|||||||
input: String,
|
input: String,
|
||||||
output: String,
|
output: String,
|
||||||
done: bool,
|
done: bool,
|
||||||
|
/// Whether the result that arrived said the call failed
|
||||||
|
/// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is
|
||||||
|
/// false, and [`ToolState::of`] is the only thing that reads the
|
||||||
|
/// pair, so the two cannot be combined wrongly at a call site.
|
||||||
|
failed: bool,
|
||||||
asks: Vec<QuestionCard>,
|
asks: Vec<QuestionCard>,
|
||||||
images: Vec<String>,
|
images: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -90,9 +57,6 @@ pub enum TranscriptItem {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
r#ref: String,
|
r#ref: String,
|
||||||
},
|
},
|
||||||
/// A message from another agent. `arrived` is this row's own identity
|
|
||||||
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
|
|
||||||
/// [`place_peer_note`] may set to the turn's opening seq instead.
|
|
||||||
PeerNote {
|
PeerNote {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
from: String,
|
from: String,
|
||||||
@@ -103,8 +67,6 @@ pub enum TranscriptItem {
|
|||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
/// Placeholder for an event kind this build could not fold -- see the
|
|
||||||
/// module doc's "known gap".
|
|
||||||
Note {
|
Note {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
text: String,
|
text: String,
|
||||||
@@ -196,14 +158,10 @@ fn update_tool(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a status means the session is still doing something, mirroring
|
|
||||||
/// `sessionWorking` in `Events.kt`.
|
|
||||||
pub fn session_working(status: SessionStatus) -> bool {
|
pub fn session_working(status: SessionStatus) -> bool {
|
||||||
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A status saying the session stopped working is the moment its newest
|
|
||||||
/// reply is finished.
|
|
||||||
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
|
||||||
if session_working(status) {
|
if session_working(status) {
|
||||||
return items.to_vec();
|
return items.to_vec();
|
||||||
@@ -218,9 +176,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A peer message goes above the turn it started, not where it happened to
|
|
||||||
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
|
|
||||||
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
|
|
||||||
fn place_peer_note(
|
fn place_peer_note(
|
||||||
items: &[TranscriptItem],
|
items: &[TranscriptItem],
|
||||||
seq: u64,
|
seq: u64,
|
||||||
@@ -259,8 +214,6 @@ fn place_peer_note(
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The calls the note now sits in front of, renamed if they were sharing a
|
|
||||||
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
|
|
||||||
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
|
||||||
let Some(TranscriptItem::ToolRun {
|
let Some(TranscriptItem::ToolRun {
|
||||||
run_id: first_run_id,
|
run_id: first_run_id,
|
||||||
@@ -294,10 +247,160 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Folds one transcript event onto `items`, the way `foldEvent` does in
|
/// A boundary lands wherever it lands, and roughly half the time that is
|
||||||
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
|
/// between a call and its result. The newer page then holds a `ToolEnd`
|
||||||
/// for the one difference from the Kotlin original (no `Unknown` fallback
|
/// whose start it never saw, which `fold_event` draws as a row of its own
|
||||||
/// at the parse layer).
|
/// -- correctly, because a call that renders as nothing is indistinguishable
|
||||||
|
/// from one that never happened. When the older page arrives it brings the
|
||||||
|
/// real `ToolStart`, and concatenating the two lists left *both*: the same
|
||||||
|
/// call twice.
|
||||||
|
///
|
||||||
|
/// Merged by the call's own id rather than by position, because position is
|
||||||
|
/// exactly what a page boundary destroys. The older row wins on what a
|
||||||
|
/// start knows and the newer on what an end knows, which is the only way
|
||||||
|
/// round that loses nothing.
|
||||||
|
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||||
|
let (older, newer) = heal_split_message(earlier, later);
|
||||||
|
let started_earlier: HashSet<&str> = older
|
||||||
|
.iter()
|
||||||
|
.filter_map(TranscriptItem::as_tool_run)
|
||||||
|
.collect();
|
||||||
|
let ended_later: HashMap<String, TranscriptItem> = newer
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
|
||||||
|
.filter(|(id, _)| started_earlier.contains(id.as_str()))
|
||||||
|
.collect();
|
||||||
|
let healed: Vec<TranscriptItem> = older
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| match row {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
asks: row_asks,
|
||||||
|
images: row_images,
|
||||||
|
..
|
||||||
|
} if ended_later.contains_key(id.as_str()) => {
|
||||||
|
let &TranscriptItem::ToolRun {
|
||||||
|
ref output,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
asks: ref half_asks,
|
||||||
|
images: ref half_images,
|
||||||
|
..
|
||||||
|
} = &ended_later[id.as_str()]
|
||||||
|
else {
|
||||||
|
unreachable!("filtered to ToolRun above");
|
||||||
|
};
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id,
|
||||||
|
run_id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output: output.clone(),
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
|
||||||
|
images: row_images.into_iter().chain(half_images.clone()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let kept: Vec<TranscriptItem> = newer
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| match item.as_tool_run() {
|
||||||
|
Some(id) => !ended_later.contains_key(id),
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut out = adopt_run(&healed, &kept);
|
||||||
|
out.extend(kept);
|
||||||
|
// What this function exists to prevent, checked rather than assumed: the same
|
||||||
|
// call drawn twice, once from the page that saw its start and once from the page
|
||||||
|
// that saw its end. Not a seq-ordering check -- a peer note is stamped with the
|
||||||
|
// seq its turn began at, which can be older than the page it arrived in, so the
|
||||||
|
// two pages' seqs legitimately interleave at the boundary.
|
||||||
|
debug_assert!(
|
||||||
|
{
|
||||||
|
let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect();
|
||||||
|
let before = ids.len();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
ids.len() == before
|
||||||
|
},
|
||||||
|
"join_pages left the same tool call in both halves"
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fold_event` never leaves two assistant messages next to each other
|
||||||
|
/// inside one page, so two meeting at a join are always the two halves of
|
||||||
|
/// one reply, and leaving them apart drew a single answer as two with a
|
||||||
|
/// paragraph break through the middle of a sentence.
|
||||||
|
fn heal_split_message(
|
||||||
|
earlier: &[TranscriptItem],
|
||||||
|
later: &[TranscriptItem],
|
||||||
|
) -> (Vec<TranscriptItem>, Vec<TranscriptItem>) {
|
||||||
|
let (
|
||||||
|
Some(TranscriptItem::AssistantMsg {
|
||||||
|
text: head_text, ..
|
||||||
|
}),
|
||||||
|
Some(TranscriptItem::AssistantMsg {
|
||||||
|
seq: tail_seq,
|
||||||
|
text: tail_text,
|
||||||
|
settled: tail_settled,
|
||||||
|
}),
|
||||||
|
) = (earlier.last(), later.first())
|
||||||
|
else {
|
||||||
|
return (earlier.to_vec(), later.to_vec());
|
||||||
|
};
|
||||||
|
let merged = TranscriptItem::AssistantMsg {
|
||||||
|
seq: *tail_seq,
|
||||||
|
text: format!("{head_text}{tail_text}"),
|
||||||
|
settled: *tail_settled,
|
||||||
|
};
|
||||||
|
let mut newer = vec![merged];
|
||||||
|
newer.extend(later[1..].iter().cloned());
|
||||||
|
(earlier[..earlier.len() - 1].to_vec(), newer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
|
||||||
|
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
|
||||||
|
return earlier.to_vec();
|
||||||
|
};
|
||||||
|
if tool == ASK_USER_QUESTION {
|
||||||
|
return earlier.to_vec();
|
||||||
|
}
|
||||||
|
let joining = run_id.clone();
|
||||||
|
let tail_len = earlier
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take_while(|item| matches!(item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION))
|
||||||
|
.count();
|
||||||
|
if tail_len == 0 {
|
||||||
|
return earlier.to_vec();
|
||||||
|
}
|
||||||
|
let split = earlier.len() - tail_len;
|
||||||
|
let mut out = earlier[..split].to_vec();
|
||||||
|
out.extend(earlier[split..].iter().cloned().map(|mut item| {
|
||||||
|
// `take_while` above already restricted this slice to non-question tool calls;
|
||||||
|
// this just guards the invariant rather than trusting it silently.
|
||||||
|
debug_assert!(
|
||||||
|
matches!(&item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION),
|
||||||
|
"adopt_run must never rename a question's own run"
|
||||||
|
);
|
||||||
|
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
|
||||||
|
*run_id = joining.clone();
|
||||||
|
}
|
||||||
|
item
|
||||||
|
}));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
|
||||||
let seq = entry.seq;
|
let seq = entry.seq;
|
||||||
match &entry.event {
|
match &entry.event {
|
||||||
@@ -312,9 +415,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
});
|
});
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
// `MessageTaken` is folded into `UserMessage` by the manager before
|
|
||||||
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
|
|
||||||
// (a raw transcript line, say), it reads the same way.
|
|
||||||
Event::MessageTaken {
|
Event::MessageTaken {
|
||||||
text, attachments, ..
|
text, attachments, ..
|
||||||
} => {
|
} => {
|
||||||
@@ -327,12 +427,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
Event::AssistantText { delta } => {
|
Event::AssistantText { delta } => {
|
||||||
// Deltas accumulate into the message they're streaming, which
|
|
||||||
// keeps the seq of the *first* of them: a row whose identity
|
|
||||||
// changed with every delta would be a new row every frame.
|
|
||||||
// "A message growing again is not finished" -- whatever a
|
|
||||||
// status said in between -- is why this always clears
|
|
||||||
// `settled` rather than preserving it.
|
|
||||||
if let Some(TranscriptItem::AssistantMsg {
|
if let Some(TranscriptItem::AssistantMsg {
|
||||||
seq: first_seq,
|
seq: first_seq,
|
||||||
text,
|
text,
|
||||||
@@ -369,6 +463,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: input.to_string(),
|
input: input.to_string(),
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
done: false,
|
done: false,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -379,15 +474,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Event::ToolEnd { id, output } => {
|
Event::ToolEnd {
|
||||||
|
id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
} => {
|
||||||
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) {
|
||||||
update_tool(items, id, |item| {
|
update_tool(items, id, |item| {
|
||||||
if let TranscriptItem::ToolRun {
|
if let TranscriptItem::ToolRun {
|
||||||
output: out, done, ..
|
output: out,
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
..
|
||||||
} = item
|
} = item
|
||||||
{
|
{
|
||||||
*out = output.clone();
|
*out = output.clone();
|
||||||
*done = true;
|
*done = true;
|
||||||
|
*failed = *is_error;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -401,6 +504,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: *is_error,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -457,6 +561,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
images,
|
images,
|
||||||
} if asks.iter().any(|a| &a.id == id) => {
|
} if asks.iter().any(|a| &a.id == id) => {
|
||||||
for ask in asks.iter_mut() {
|
for ask in asks.iter_mut() {
|
||||||
@@ -472,6 +577,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
done,
|
done,
|
||||||
|
failed,
|
||||||
asks,
|
asks,
|
||||||
images,
|
images,
|
||||||
}
|
}
|
||||||
@@ -492,7 +598,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
});
|
});
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
// Screen-level state, not transcript rows.
|
|
||||||
Event::CommandQueued { .. }
|
Event::CommandQueued { .. }
|
||||||
| Event::MessageQueued { .. }
|
| Event::MessageQueued { .. }
|
||||||
| Event::MessageDropped { .. }
|
| Event::MessageDropped { .. }
|
||||||
@@ -557,14 +662,55 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row as the transcript draws it: a run of consecutive tool calls, or
|
/// The pair this enum exists for is [`ToolState::Succeeded`] against
|
||||||
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
|
/// [`ToolState::NoResult`]. A call that finished having printed nothing
|
||||||
/// `groupToolRuns` -- the Compose card rendering in that file is not part
|
/// and a call whose result never arrived both leave an empty `output`,
|
||||||
/// of this crate.
|
/// and drawing them the same way states a verdict nobody reached: "it
|
||||||
|
/// worked and said nothing" reads as a fact, where the truth is that the
|
||||||
|
/// turn ended before anything came back.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolState {
|
||||||
|
Running,
|
||||||
|
/// Stopped on the reader: a permission or question this call carries
|
||||||
|
/// has not been answered, so nothing is happening until somebody
|
||||||
|
/// answers it. Distinct from [`Self::Running`] because whose move it
|
||||||
|
/// is differs, which is the Compose card's "your turn".
|
||||||
|
Deciding,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
NoResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolState {
|
||||||
|
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
|
||||||
|
let TranscriptItem::ToolRun {
|
||||||
|
done, failed, asks, ..
|
||||||
|
} = item
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
debug_assert!(
|
||||||
|
!failed || *done,
|
||||||
|
"a call cannot have failed before its result arrived"
|
||||||
|
);
|
||||||
|
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
|
||||||
|
Self::Deciding
|
||||||
|
} else if !*done {
|
||||||
|
match session_working {
|
||||||
|
true => Self::Running,
|
||||||
|
false => Self::NoResult,
|
||||||
|
}
|
||||||
|
} else if *failed {
|
||||||
|
Self::Failed
|
||||||
|
} else {
|
||||||
|
Self::Succeeded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum TranscriptRow {
|
pub enum TranscriptRow {
|
||||||
Single(TranscriptItem),
|
Single(TranscriptItem),
|
||||||
/// Two or more calls with nothing between them.
|
|
||||||
Tools(Vec<TranscriptItem>),
|
Tools(Vec<TranscriptItem>),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,9 +730,6 @@ impl TranscriptRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs of adjacent tool calls become one row; everything else passes
|
|
||||||
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
|
|
||||||
/// run each call names rather than by adjacency worked out here.
|
|
||||||
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut run: Vec<TranscriptItem> = Vec::new();
|
let mut run: Vec<TranscriptItem> = Vec::new();
|
||||||
@@ -622,15 +765,6 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
|
|
||||||
/// `Vec<Value>`) into the flat item list this module works over. A line
|
|
||||||
/// this build can't parse fails the whole page rather than being skipped --
|
|
||||||
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
|
|
||||||
/// silently dropping one event could hide, say, a user message that then
|
|
||||||
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
|
|
||||||
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
|
|
||||||
/// fold: "write the logic once" applies to any caller embedding
|
|
||||||
/// `transcript-ui` against a live server, not just the first one.
|
|
||||||
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for value in values {
|
for value in values {
|
||||||
@@ -642,13 +776,6 @@ pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, St
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wire `seq` a raw transcript line carries -- the live-stream resume
|
|
||||||
/// cursor after loading a page must be this, not a folded item's `seq()`.
|
|
||||||
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
|
|
||||||
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
|
|
||||||
/// re-deliver every delta already folded into it, duplicating the tail of
|
|
||||||
/// a reply that was mid-stream when the page was fetched -- found via a
|
|
||||||
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
|
|
||||||
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
|
||||||
value.get("seq")?.as_u64()
|
value.get("seq")?.as_u64()
|
||||||
}
|
}
|
||||||
@@ -796,6 +923,7 @@ mod tests {
|
|||||||
Event::ToolEnd {
|
Event::ToolEnd {
|
||||||
id: "x".to_string(),
|
id: "x".to_string(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
|
is_error: false,
|
||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -808,6 +936,7 @@ mod tests {
|
|||||||
input: String::new(),
|
input: String::new(),
|
||||||
output: "done".to_string(),
|
output: "done".to_string(),
|
||||||
done: true,
|
done: true,
|
||||||
|
failed: false,
|
||||||
asks: Vec::new(),
|
asks: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
}]
|
}]
|
||||||
@@ -879,12 +1008,6 @@ mod tests {
|
|||||||
obj
|
obj
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The regression for a bug a real `run-headless.sh` screenshot found
|
|
||||||
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
|
|
||||||
/// last *item's* seq re-delivers the deltas already folded into a
|
|
||||||
/// still-open assistant message, doubling its tail. `raw_seq` of the
|
|
||||||
/// last wire line must be the true high-water mark instead, which for a
|
|
||||||
/// run of deltas is higher than every item's own `seq()`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
|
||||||
let values = vec![
|
let values = vec![
|
||||||
@@ -955,4 +1078,261 @@ mod tests {
|
|||||||
let err = fold_page(&values).unwrap_err();
|
let err = fold_page(&values).unwrap_err();
|
||||||
assert!(err.contains("couldn't parse"));
|
assert!(err.contains("couldn't parse"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_start(seq: u64, id: &str, tool: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
seq,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: id.to_string(),
|
||||||
|
tool: tool.to_string(),
|
||||||
|
input: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_end(seq: u64, id: &str, output: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
seq,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: id.to_string(),
|
||||||
|
output: output.to_string(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
|
||||||
|
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
|
||||||
|
let newer = fold_all(&[tool_start(3, "b", "Bash"), tool_end(4, "b", "new output")]);
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
let run_ids: Vec<_> = joined
|
||||||
|
.iter()
|
||||||
|
.map(|item| match item {
|
||||||
|
TranscriptItem::ToolRun { run_id, .. } => run_id.as_str(),
|
||||||
|
other => panic!("expected only ToolRun items, got {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
run_ids,
|
||||||
|
vec!["b", "b"],
|
||||||
|
"the older call must adopt the newer, already-on-screen run's name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_split_across_the_boundary_merges_into_one_row() {
|
||||||
|
let older = fold_all(&[tool_start(1, "x", "Bash")]);
|
||||||
|
let newer = fold_all(&[tool_end(2, "x", "the result")]);
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
assert_eq!(
|
||||||
|
joined,
|
||||||
|
vec![TranscriptItem::ToolRun {
|
||||||
|
seq: 1,
|
||||||
|
id: "x".to_string(),
|
||||||
|
run_id: "x".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: "{}".to_string(),
|
||||||
|
output: "the result".to_string(),
|
||||||
|
done: true,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}],
|
||||||
|
"the older half's tool/input and the newer half's output/done must both survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity() {
|
||||||
|
let older = vec![TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "Hel".to_string(),
|
||||||
|
settled: false,
|
||||||
|
}];
|
||||||
|
let newer = vec![
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "lo".to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 3,
|
||||||
|
text: "next".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
assert_eq!(
|
||||||
|
joined,
|
||||||
|
vec![
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "Hello".to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq: 3,
|
||||||
|
text: "next".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adopt_run_never_renames_into_a_question_row() {
|
||||||
|
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
|
||||||
|
let newer = vec![TranscriptItem::ToolRun {
|
||||||
|
seq: 3,
|
||||||
|
id: "q".to_string(),
|
||||||
|
run_id: "q".to_string(),
|
||||||
|
tool: ASK_USER_QUESTION.to_string(),
|
||||||
|
input: "{}".to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}];
|
||||||
|
let joined = join_pages(&older, &newer);
|
||||||
|
match &joined[0] {
|
||||||
|
TranscriptItem::ToolRun { run_id, .. } => assert_eq!(run_id, "a"),
|
||||||
|
other => panic!("expected a ToolRun, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tool_state_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn event(seq: u64, e: Event) -> SeqEvent {
|
||||||
|
SeqEvent {
|
||||||
|
seq,
|
||||||
|
ts: 0.0,
|
||||||
|
event: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fold_all(events: &[SeqEvent]) -> Vec<TranscriptItem> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.fold(Vec::new(), |items, e| fold_event(&items, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(id: &str) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
1,
|
||||||
|
Event::ToolStart {
|
||||||
|
id: id.to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: serde_json::json!({"command": "ls"}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(id: &str, output: &str, is_error: bool) -> SeqEvent {
|
||||||
|
event(
|
||||||
|
2,
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: id.to_string(),
|
||||||
|
output: output.to_string(),
|
||||||
|
is_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState {
|
||||||
|
let items = fold_all(events);
|
||||||
|
ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_that_arrived_is_read_from_is_error() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "ok", false)], false),
|
||||||
|
ToolState::Succeeded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "No such file", true)], false),
|
||||||
|
ToolState::Failed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), end("a", "", false)], false),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"a result arrived; it was empty"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a")], false),
|
||||||
|
ToolState::NoResult,
|
||||||
|
"no result, and the session is not working any more"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_result_while_the_session_works_is_still_running() {
|
||||||
|
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() {
|
||||||
|
let asking = event(
|
||||||
|
3,
|
||||||
|
Event::Question {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
prompt: "Allow?".to_string(),
|
||||||
|
header: None,
|
||||||
|
options: vec![QuestionOption {
|
||||||
|
label: "Allow".to_string(),
|
||||||
|
description: None,
|
||||||
|
preview: None,
|
||||||
|
}],
|
||||||
|
multi_select: false,
|
||||||
|
about: Some("a".to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let answered = event(
|
||||||
|
4,
|
||||||
|
Event::Answered {
|
||||||
|
id: "q1".to_string(),
|
||||||
|
answers: vec!["Allow".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], true),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(&[start("a"), asking.clone()], false),
|
||||||
|
ToolState::Deciding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state_of(
|
||||||
|
&[start("a"), asking, answered, end("a", "ok", false)],
|
||||||
|
false
|
||||||
|
),
|
||||||
|
ToolState::Succeeded,
|
||||||
|
"once it is answered the call is an ordinary one again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_but_a_tool_call_has_a_tool_state() {
|
||||||
|
assert_eq!(
|
||||||
|
ToolState::of(
|
||||||
|
&TranscriptItem::UserMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
true
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
use event_model::SeqEvent;
|
||||||
|
|
||||||
|
use crate::client::api::{ApiClient, ApiError, Transport};
|
||||||
|
use crate::client::event_stream::{self, StreamItem};
|
||||||
|
use crate::client::transcript_cache::SessionCache;
|
||||||
|
|
||||||
|
/// 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 {}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum OlderPage {
|
||||||
|
Events(Vec<SeqEvent>),
|
||||||
|
NothingLoaded,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||||
|
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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),
|
||||||
|
Err(ParseError(_)) => {
|
||||||
|
self.cache.purge();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn probe(&self) -> Result<bool, ApiError> {
|
||||||
|
let Some(tail) = self.cache.tail() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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() {
|
||||||
|
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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::client::api::{Body, RawResponse};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[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::client::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);
|
||||||
|
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::client::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();
|
||||||
|
|
||||||
|
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::client::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::client::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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::client::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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(_)),));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::client::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::client::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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +1,21 @@
|
|||||||
//! RUST.md's E4: a session list on the left, `transcript-ui`'s screen (I5)
|
use crate::client::api::{ApiClient, SessionSummary, UreqTransport};
|
||||||
//! filling the rest, both against a real `ai-server` reached through
|
use crate::client::event_stream::{StreamItem, follow_session_events};
|
||||||
//! `client-core`. The layout is the simplest thing that shows both at
|
use crate::client::transcript_fold::{
|
||||||
//! once -- a fixed-width column and `rest(1)` for everything else, using
|
|
||||||
//! `iris::widget::{Span, WidgetPtr}` the way `tabs-ui` already switches
|
|
||||||
//! panes, rather than anything desktop-specific:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! +-----------+--------------------------------------+
|
|
||||||
//! | session | transcript_ui::TranscriptScreen |
|
|
||||||
//! | list | (List of folded rows + composer) |
|
|
||||||
//! | (WidgetPtr| |
|
|
||||||
//! | swapped | (WidgetPtr swapped whole on session |
|
|
||||||
//! | on data) | switch or a new transcript event) |
|
|
||||||
//! +-----------+--------------------------------------+
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a
|
|
||||||
//! full rebuild: `client_core::transcript_fold::fold_event` folds the new
|
|
||||||
//! item list as before, then `apply` updates only the row(s) that actually
|
|
||||||
//! changed (almost always the one still-open assistant message a delta
|
|
||||||
//! landed in) instead of rebuilding the whole right-hand widget tree from
|
|
||||||
//! scratch. `rebuild_transcript` still runs the whole tree once, for a
|
|
||||||
//! freshly loaded/selected session and for `apply`'s own rare
|
|
||||||
//! full-rebuild fallback (a `group_tool_runs` regroup touching a row
|
|
||||||
//! before the tail). The composer's in-progress text survives a rebuild
|
|
||||||
//! (`rebuild_transcript`'s `in_progress` local) since the user typing a
|
|
||||||
//! followup while a reply streams in is the one case a naive rebuild
|
|
||||||
//! would otherwise lose data on -- `apply`'s own path never touches the
|
|
||||||
//! composer at all, so this only matters on the fallback.
|
|
||||||
//!
|
|
||||||
//! Background network I/O (`client_core::api`/`event_stream`, both
|
|
||||||
//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain
|
|
||||||
//! `std::thread`s that report back through `winit`'s `EventLoopProxy`
|
|
||||||
//! (`Proxy<AppEvent>`), rather than through iris's own `Tasks`/`task_on`:
|
|
||||||
//! `Tasks` only requests a redraw once, after its whole async closure
|
|
||||||
//! finishes, which fits a single request-then-update but not a live SSE
|
|
||||||
//! loop that needs to be seen redrawing after *each* event it relays.
|
|
||||||
//! `Proxy::send_event` wakes the window's event loop immediately, once per
|
|
||||||
//! event, which is what a stream wants.
|
|
||||||
|
|
||||||
use client_core::api::{ApiClient, SessionSummary, UreqTransport};
|
|
||||||
use client_core::event_stream::{StreamItem, follow_session_events};
|
|
||||||
use client_core::transcript_fold::{
|
|
||||||
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
|
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
|
||||||
};
|
};
|
||||||
use event_model::SeqEvent;
|
use event_model::SeqEvent;
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
process,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
|
||||||
/// The session list column's width -- a fixed size for the simplest
|
|
||||||
/// layout that shows both panels at once (UI_RULES's text-truncation and
|
|
||||||
/// no-shrink rules apply to what's drawn inside it, not to this choice of
|
|
||||||
/// column width itself).
|
|
||||||
const LIST_WIDTH: f32 = 260.0;
|
const LIST_WIDTH: f32 = 260.0;
|
||||||
|
|
||||||
/// Everything a background thread hands back to the window's event loop.
|
|
||||||
/// `generation` on the session-scoped variants is the generation
|
|
||||||
/// `select_session` was on when the thread started (`Client::generation`)
|
|
||||||
/// -- compared back against the current one before being applied, so a
|
|
||||||
/// slow response from a session the reader has since clicked away from
|
|
||||||
/// can't overwrite what replaced it.
|
|
||||||
enum AppEvent {
|
enum AppEvent {
|
||||||
Sessions(Result<Vec<SessionSummary>, String>),
|
Sessions(Result<Vec<SessionSummary>, String>),
|
||||||
TranscriptLoaded {
|
TranscriptLoaded {
|
||||||
@@ -82,20 +37,13 @@ enum AppEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
DefaultApp::<Client>::run();
|
DesktopApp::<Client>::run();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(DefaultUiState)]
|
#[derive(DesktopUiState)]
|
||||||
struct Client {
|
struct Client {
|
||||||
ui_state: DefaultUiState,
|
ui_state: DesktopUiState,
|
||||||
api: Arc<ApiClient<UreqTransport>>,
|
api: Arc<ApiClient<UreqTransport>>,
|
||||||
/// A second, independent `UreqTransport` to the same server, used only
|
|
||||||
/// by `select_session`'s live-follow loop. `ApiClient` keeps its
|
|
||||||
/// transport private (rightly -- nothing outside it should reach past
|
|
||||||
/// the typed calls), so a caller that also needs the raw
|
|
||||||
/// `Transport::stream` for SSE, as this one does, builds its own
|
|
||||||
/// rather than the crate growing a getter whose only purpose would be
|
|
||||||
/// letting one caller reach around its own abstraction.
|
|
||||||
stream_transport: Arc<UreqTransport>,
|
stream_transport: Arc<UreqTransport>,
|
||||||
proxy: Proxy<AppEvent>,
|
proxy: Proxy<AppEvent>,
|
||||||
sessions: Vec<SessionSummary>,
|
sessions: Vec<SessionSummary>,
|
||||||
@@ -103,29 +51,21 @@ struct Client {
|
|||||||
items: Vec<TranscriptItem>,
|
items: Vec<TranscriptItem>,
|
||||||
list_ptr: WeakWidget<WidgetPtr>,
|
list_ptr: WeakWidget<WidgetPtr>,
|
||||||
transcript_ptr: WeakWidget<WidgetPtr>,
|
transcript_ptr: WeakWidget<WidgetPtr>,
|
||||||
screen: Option<transcript_ui::TranscriptScreen>,
|
screen: Option<crate::ui::TranscriptScreen>,
|
||||||
/// Bumped every time the selected session changes; see `AppEvent`'s
|
|
||||||
/// doc for what it guards against.
|
|
||||||
generation: Arc<AtomicU64>,
|
generation: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultAppState for Client {
|
impl DesktopAppState for Client {
|
||||||
type Event = AppEvent;
|
type Event = AppEvent;
|
||||||
|
|
||||||
fn new(
|
fn new(
|
||||||
mut ui_state: DefaultUiState,
|
mut ui_state: DesktopUiState,
|
||||||
rsc: &mut DefaultRsc<Self>,
|
rsc: &mut DesktopRsc<Self>,
|
||||||
proxy: Proxy<AppEvent>,
|
proxy: Proxy<AppEvent>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Re-validated here rather than threaded through from `main` --
|
let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| {
|
||||||
// `DefaultApp::run()` takes no payload, so there is no other way
|
|
||||||
// to get `main`'s parsed CLI/config into this constructor. `main`
|
|
||||||
// already called this once to fail fast before a window opens;
|
|
||||||
// this call only fails if the filesystem changed underneath the
|
|
||||||
// process in between, which is not a case worth a nicer message.
|
|
||||||
let (server, ca_pem) = crate::load_startup_config().unwrap_or_else(|e| {
|
|
||||||
eprintln!("desktop-app: {e}");
|
eprintln!("desktop-app: {e}");
|
||||||
std::process::exit(2);
|
process::exit(2);
|
||||||
});
|
});
|
||||||
let build_transport =
|
let build_transport =
|
||||||
|| UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem);
|
|| UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem);
|
||||||
@@ -136,7 +76,7 @@ impl DefaultAppState for Client {
|
|||||||
"desktop-app: couldn't set up TLS to {}: {e}",
|
"desktop-app: couldn't set up TLS to {}: {e}",
|
||||||
server.base_url()
|
server.base_url()
|
||||||
);
|
);
|
||||||
std::process::exit(1);
|
process::exit(1);
|
||||||
});
|
});
|
||||||
let api = Arc::new(ApiClient::new(rest_transport));
|
let api = Arc::new(ApiClient::new(rest_transport));
|
||||||
let stream_transport = Arc::new(stream_transport);
|
let stream_transport = Arc::new(stream_transport);
|
||||||
@@ -167,7 +107,7 @@ impl DefaultAppState for Client {
|
|||||||
client
|
client
|
||||||
}
|
}
|
||||||
|
|
||||||
fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc<Self>, _render: &mut UiRenderState) {
|
fn event(&mut self, event: AppEvent, rsc: &mut DesktopRsc<Self>) {
|
||||||
match event {
|
match event {
|
||||||
AppEvent::Sessions(Ok(sessions)) => {
|
AppEvent::Sessions(Ok(sessions)) => {
|
||||||
self.sessions = sessions;
|
self.sessions = sessions;
|
||||||
@@ -207,7 +147,7 @@ impl DefaultAppState for Client {
|
|||||||
if self.current(&session_id, generation) {
|
if self.current(&session_id, generation) {
|
||||||
let old_items = self.items.clone();
|
let old_items = self.items.clone();
|
||||||
self.items = fold_event(&self.items, &event);
|
self.items = fold_event(&self.items, &event);
|
||||||
match &self.screen {
|
match self.screen.as_mut() {
|
||||||
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
Some(screen) => screen.apply(rsc, &old_items, &self.items),
|
||||||
None => self.rebuild_transcript(rsc),
|
None => self.rebuild_transcript(rsc),
|
||||||
}
|
}
|
||||||
@@ -237,10 +177,7 @@ impl Client {
|
|||||||
&& self.generation.load(Ordering::SeqCst) == generation
|
&& self.generation.load(Ordering::SeqCst) == generation
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the right-hand panel with a line of text -- built before
|
fn show_message(&mut self, rsc: &mut DesktopRsc<Self>, message: &str) {
|
||||||
/// `transcript_ptr` is reached for, since building the message and
|
|
||||||
/// swapping it in both need `rsc` and can't overlap as one borrow.
|
|
||||||
fn show_message(&mut self, rsc: &mut DefaultRsc<Self>, message: &str) {
|
|
||||||
let widget = placeholder(rsc, message);
|
let widget = placeholder(rsc, message);
|
||||||
(self.transcript_ptr)(rsc).set(widget);
|
(self.transcript_ptr)(rsc).set(widget);
|
||||||
}
|
}
|
||||||
@@ -248,13 +185,13 @@ impl Client {
|
|||||||
fn spawn_fetch_sessions(&self) {
|
fn spawn_fetch_sessions(&self) {
|
||||||
let api = self.api.clone();
|
let api = self.api.clone();
|
||||||
let proxy = self.proxy.clone();
|
let proxy = self.proxy.clone();
|
||||||
std::thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let result = api.fetch_sessions().map_err(|e| e.to_string());
|
let result = api.fetch_sessions().map_err(|e| e.to_string());
|
||||||
let _ = proxy.send_event(AppEvent::Sessions(result));
|
let _ = proxy.send_event(AppEvent::Sessions(result));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_list(&mut self, rsc: &mut DefaultRsc<Self>) {
|
fn rebuild_list(&mut self, rsc: &mut DesktopRsc<Self>) {
|
||||||
let list = Span::empty(Dir::DOWN).gap(2).add(rsc);
|
let list = Span::empty(Dir::DOWN).gap(2).add(rsc);
|
||||||
for session in &self.sessions {
|
for session in &self.sessions {
|
||||||
let selected = self.selected.as_deref() == Some(session.id.as_str());
|
let selected = self.selected.as_deref() == Some(session.id.as_str());
|
||||||
@@ -262,18 +199,13 @@ impl Client {
|
|||||||
list(rsc).push(row);
|
list(rsc).push(row);
|
||||||
}
|
}
|
||||||
let tree = list
|
let tree = list
|
||||||
.background(rect(Color::rgb(24, 24, 28)))
|
.background(rect(Srgba8::rgb(24, 24, 28)))
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
.any();
|
.any();
|
||||||
(self.list_ptr)(rsc).set(tree);
|
(self.list_ptr)(rsc).set(tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selecting a session starts a fresh generation: any thread still
|
fn select_session(&mut self, rsc: &mut DesktopRsc<Self>, session_id: String) {
|
||||||
/// working for the previous one checks `Client::current` before
|
|
||||||
/// touching state, so a slow response for a session the reader has
|
|
||||||
/// clicked away from is silently dropped rather than overwriting what
|
|
||||||
/// replaced it.
|
|
||||||
fn select_session(&mut self, rsc: &mut DefaultRsc<Self>, session_id: String) {
|
|
||||||
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
self.selected = Some(session_id.clone());
|
self.selected = Some(session_id.clone());
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
@@ -285,25 +217,10 @@ impl Client {
|
|||||||
let stream_transport = self.stream_transport.clone();
|
let stream_transport = self.stream_transport.clone();
|
||||||
let proxy = self.proxy.clone();
|
let proxy = self.proxy.clone();
|
||||||
let live_generation = self.generation.clone();
|
let live_generation = self.generation.clone();
|
||||||
std::thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
// The most recent 200 events, coalesced -- plenty for a
|
|
||||||
// desktop proof; RUST.md's I3/history-paging work is what a
|
|
||||||
// real scrollback would reuse, out of scope here (E4 is only
|
|
||||||
// "the same screen runs in a window").
|
|
||||||
let page: Result<Vec<serde_json::Value>, String> = api
|
let page: Result<Vec<serde_json::Value>, String> = api
|
||||||
.fetch_transcript_page(&session_id, None, 200, true)
|
.fetch_transcript_page(&session_id, None, 200, true)
|
||||||
.map_err(|e| e.to_string());
|
.map_err(|e| e.to_string());
|
||||||
// The raw wire `seq` of the last line fetched -- not the seq of
|
|
||||||
// the last *folded item*. A `TranscriptItem::AssistantMsg` keeps
|
|
||||||
// the seq of the first delta it accumulated (`fold_event`'s own
|
|
||||||
// doc: "a row whose identity changed with every delta would be
|
|
||||||
// a new row every frame"), so resuming the live stream from
|
|
||||||
// that seq re-delivers every delta already folded into it,
|
|
||||||
// duplicating the tail of whatever reply was mid-stream when
|
|
||||||
// the page was fetched. Found by screenshotting a real reply
|
|
||||||
// through `run-headless.sh`: the assistant's line read "You
|
|
||||||
// said: ... testsaid: ... test", the back half being deltas 2
|
|
||||||
// through N replayed onto an already-complete message.
|
|
||||||
let after = page
|
let after = page
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -316,9 +233,6 @@ impl Client {
|
|||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Follows live from here in the same thread -- sequential
|
|
||||||
// rather than a second thread, since there is nothing to do
|
|
||||||
// with the stream until the page above has been sent anyway.
|
|
||||||
let stop = || live_generation.load(Ordering::SeqCst) != generation;
|
let stop = || live_generation.load(Ordering::SeqCst) != generation;
|
||||||
if stop() {
|
if stop() {
|
||||||
return;
|
return;
|
||||||
@@ -349,14 +263,14 @@ impl Client {
|
|||||||
fn send_message(&mut self, session_id: String, text: String) {
|
fn send_message(&mut self, session_id: String, text: String) {
|
||||||
let api = self.api.clone();
|
let api = self.api.clone();
|
||||||
let proxy = self.proxy.clone();
|
let proxy = self.proxy.clone();
|
||||||
std::thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
if let Err(e) = api.send_message(&session_id, &text, &[]) {
|
if let Err(e) = api.send_message(&session_id, &text, &[]) {
|
||||||
let _ = proxy.send_event(AppEvent::SendFailed(e.to_string()));
|
let _ = proxy.send_event(AppEvent::SendFailed(e.to_string()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_transcript(&mut self, rsc: &mut DefaultRsc<Self>) {
|
fn rebuild_transcript(&mut self, rsc: &mut DesktopRsc<Self>) {
|
||||||
let in_progress = self
|
let in_progress = self
|
||||||
.screen
|
.screen
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -364,7 +278,7 @@ impl Client {
|
|||||||
.filter(|t| !t.is_empty());
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
let rows = group_tool_runs(&self.items);
|
let rows = group_tool_runs(&self.items);
|
||||||
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
let (screen, tree) = crate::ui::build_tree(rsc, rows);
|
||||||
|
|
||||||
if let Some(text) = in_progress {
|
if let Some(text) = in_progress {
|
||||||
screen.composer.field.edit(rsc).set(&text);
|
screen.composer.field.edit(rsc).set(&text);
|
||||||
@@ -385,29 +299,27 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row in the session list: title on top, status below, highlighted
|
|
||||||
/// when it's the one currently shown.
|
|
||||||
fn session_row(
|
fn session_row(
|
||||||
rsc: &mut DefaultRsc<Client>,
|
rsc: &mut DesktopRsc<Client>,
|
||||||
session: &SessionSummary,
|
session: &SessionSummary,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
) -> StrongWidget {
|
) -> StrongWidget {
|
||||||
let bg = if selected {
|
let bg = if selected {
|
||||||
Color::rgb(58, 90, 138)
|
Srgba8::rgb(58, 90, 138)
|
||||||
} else {
|
} else {
|
||||||
Color::rgb(38, 38, 44)
|
Srgba8::rgb(38, 38, 44)
|
||||||
};
|
};
|
||||||
let id = session.id.clone();
|
let id = session.id.clone();
|
||||||
let label = format!("{}\n{}", session.title, session.status);
|
let label = format!("{}\n{}", session.title, session.status);
|
||||||
wtext(label)
|
wtext(label)
|
||||||
.color(Color::WHITE)
|
.color(PaintId::WHITE)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.pad(10)
|
.pad(10)
|
||||||
.width(rest(1))
|
.width(rest(1))
|
||||||
.background(rect(bg))
|
.background(rect(bg))
|
||||||
.on(
|
.on(
|
||||||
CursorSense::click(),
|
CursorSense::click(),
|
||||||
move |ctx, rsc: &mut DefaultRsc<Client>| {
|
move |ctx, rsc: &mut DesktopRsc<Client>| {
|
||||||
ctx.state.select_session(rsc, id.clone());
|
ctx.state.select_session(rsc, id.clone());
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -415,9 +327,9 @@ fn session_row(
|
|||||||
.any()
|
.any()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn placeholder(rsc: &mut DefaultRsc<Client>, message: &str) -> StrongWidget {
|
fn placeholder(rsc: &mut DesktopRsc<Client>, message: &str) -> StrongWidget {
|
||||||
wtext(message.to_string())
|
wtext(message.to_string())
|
||||||
.color(Color::WHITE)
|
.color(PaintId::WHITE)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
.pad(16)
|
.pad(16)
|
||||||
.add_strong(rsc)
|
.add_strong(rsc)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use crate::client::config::EnrollmentStore;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
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-desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store() -> EnrollmentStore {
|
||||||
|
EnrollmentStore::new(config_dir())
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod config;
|
||||||
|
pub mod startup;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! The desktop binary's command line and the enrolment it resolves --
|
||||||
|
//! `--link`/`--ca`, parsed once at startup and again from `app.rs`'s
|
||||||
|
//! `Client::new`. Here rather than in `src/bin_desktop.rs` because both
|
||||||
|
//! callers are in the library; the binary is only `fn main`.
|
||||||
|
|
||||||
|
use crate::client::config::EnrolledServer;
|
||||||
|
use std::{env, fs, path::PathBuf};
|
||||||
|
|
||||||
|
use super::config;
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
ca_path: Option<PathBuf>,
|
||||||
|
link: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut ca_path = None;
|
||||||
|
let mut link = None;
|
||||||
|
let mut args = env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--ca" => ca_path = Some(PathBuf::from(args.next().ok_or("--ca needs a path")?)),
|
||||||
|
"--link" => link = Some(args.next().ok_or("--link needs a value")?),
|
||||||
|
other => return Err(format!("unrecognised argument '{other}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Args { ca_path, link })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
|
||||||
|
let args = parse_args()?;
|
||||||
|
let store = config::store();
|
||||||
|
let server = match args.link {
|
||||||
|
Some(link) => {
|
||||||
|
let server = EnrolledServer::parse_link(&link)?;
|
||||||
|
store
|
||||||
|
.save(&server)
|
||||||
|
.map_err(|e| format!("couldn't save the enrollment: {e}"))?;
|
||||||
|
server
|
||||||
|
}
|
||||||
|
None => store
|
||||||
|
.load()
|
||||||
|
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"no server enrolled yet under {} -- pass --link 'aiapp://enroll?...' \
|
||||||
|
once (app/ui-sandbox.sh's start banner prints one)",
|
||||||
|
config::config_dir().display()
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
// `--ca` wins where it was given, so a caller can point a link's
|
||||||
|
// server at a certificate it did not carry -- and so the flag still
|
||||||
|
// means what it did before the link could carry one.
|
||||||
|
let ca_pem = match (&args.ca_path, &server.ca_pem) {
|
||||||
|
(Some(path), _) => fs::read(path)
|
||||||
|
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
|
||||||
|
(None, Some(pem)) => pem.clone().into_bytes(),
|
||||||
|
(None, None) => {
|
||||||
|
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
|
||||||
|
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
|
||||||
|
minted by a server that includes one"
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok((server, ca_pem))
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
pub mod client;
|
||||||
|
|
||||||
|
#[cfg(feature = "screens")]
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "screens", not(target_os = "android")))]
|
||||||
|
pub mod desktop;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "screens", target_os = "android"))]
|
||||||
|
pub mod android;
|
||||||
|
|
||||||
|
// `jni` 0.22's `native_method!` expands to `AtomicBool::fetch_update`,
|
||||||
|
// which this toolchain deprecates in favour of `try_update`. The call is
|
||||||
|
// inside the macro, so there is nothing here to migrate -- the fix is a
|
||||||
|
// `jni` release, and this allow comes out when one lands. Scoped to the
|
||||||
|
// module the macro is used in rather than the crate, so a deprecation in
|
||||||
|
// our own code is still a warning.
|
||||||
|
#[cfg(feature = "shell")]
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub mod shell;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
||||||
|
if CLASS_LOADER.get().is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -1,23 +1,3 @@
|
|||||||
//! 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 jcall;
|
||||||
mod notify;
|
mod notify;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -28,15 +8,6 @@ use jni::objects::{JClass, JObject};
|
|||||||
use jni::sys::jint;
|
use jni::sys::jint;
|
||||||
use jni::{Env, NativeMethod, native_method};
|
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() {
|
fn ensure_logger() {
|
||||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
ONCE.call_once(|| {
|
ONCE.call_once(|| {
|
||||||
@@ -65,8 +36,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
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>(
|
fn native_handle_intent<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -84,9 +53,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
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>(
|
fn native_sync<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -103,7 +69,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
|
|
||||||
fn native_on_start_command<'local>(
|
fn native_on_start_command<'local>(
|
||||||
env: &mut Env<'local>,
|
env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -120,7 +85,6 @@ const _: NativeMethod = native_method! {
|
|||||||
error_policy = LogErrorAndDefault,
|
error_policy = LogErrorAndDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
|
|
||||||
fn native_on_destroy<'local>(
|
fn native_on_destroy<'local>(
|
||||||
_env: &mut Env<'local>,
|
_env: &mut Env<'local>,
|
||||||
_class: JClass<'local>,
|
_class: JClass<'local>,
|
||||||
@@ -12,20 +12,19 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client_core::api::UreqTransport;
|
use crate::client::api::UreqTransport;
|
||||||
use client_core::notifications::{SessionNotification, follow_notifications};
|
use crate::client::notifications::{SessionNotification, follow_notifications};
|
||||||
use jni::Env;
|
use jni::Env;
|
||||||
use jni::errors::Result;
|
use jni::errors::Result;
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
use jni::sys::{JNI_TRUE, jint};
|
use jni::sys::{JNI_TRUE, jint};
|
||||||
|
|
||||||
use crate::settings::{self, ServerSettings};
|
use crate::shell::settings::{self, ServerSettings};
|
||||||
|
|
||||||
const ALERT_CHANNEL: &str = "sessions";
|
const ALERT_CHANNEL: &str = "sessions";
|
||||||
const ONGOING_CHANNEL: &str = "connection";
|
const ONGOING_CHANNEL: &str = "connection";
|
||||||
const ONGOING_ID: i32 = 1;
|
const ONGOING_ID: i32 = 1;
|
||||||
const ALERT_ID: i32 = 2;
|
const ALERT_ID: i32 = 2;
|
||||||
/// Same backoff as `Notifications.kt`'s `RECONNECT_DELAY_MS`.
|
|
||||||
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
||||||
|
|
||||||
/// Whether the follow-loop thread is already running. **A deviation from
|
/// Whether the follow-loop thread is already running. **A deviation from
|
||||||
@@ -45,25 +44,14 @@ const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
|
|||||||
/// same guard back to `Notifications.kt` separately.
|
/// same guard back to `Notifications.kt` separately.
|
||||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
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);
|
static STOPPING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
|
||||||
crate::jcall::get_static_field(env, class, field, "I")?.i()
|
crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationManagerCompat",
|
"androidx/core/app/NotificationManagerCompat",
|
||||||
"from",
|
"from",
|
||||||
@@ -80,22 +68,22 @@ fn create_channel(
|
|||||||
name: &str,
|
name: &str,
|
||||||
importance: i32,
|
importance: i32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let id_j = crate::jcall::jstr_obj(env, id)?;
|
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationChannelCompat$Builder",
|
"androidx/core/app/NotificationChannelCompat$Builder",
|
||||||
"(Ljava/lang/String;I)V",
|
"(Ljava/lang/String;I)V",
|
||||||
&[JValue::Object(&id_j), JValue::Int(importance)],
|
&[JValue::Object(&id_j), JValue::Int(importance)],
|
||||||
)?;
|
)?;
|
||||||
let name_j = crate::jcall::jstr_obj(env, name)?;
|
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"setName",
|
"setName",
|
||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
|
||||||
&[JValue::Object(&name_j)],
|
&[JValue::Object(&name_j)],
|
||||||
)?;
|
)?;
|
||||||
let channel = crate::jcall::call_method(
|
let channel = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"build",
|
"build",
|
||||||
@@ -103,7 +91,7 @@ fn create_channel(
|
|||||||
&[],
|
&[],
|
||||||
)?
|
)?
|
||||||
.l()?;
|
.l()?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
manager,
|
manager,
|
||||||
"createNotificationChannel",
|
"createNotificationChannel",
|
||||||
@@ -146,8 +134,8 @@ fn new_intent_for<'l>(
|
|||||||
context: &JObject,
|
context: &JObject,
|
||||||
class_name: &str,
|
class_name: &str,
|
||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let target_class = crate::jcall::find_class(env, class_name)?;
|
let target_class = crate::shell::jcall::find_class(env, class_name)?;
|
||||||
crate::jcall::new_object(
|
crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"android/content/Intent",
|
"android/content/Intent",
|
||||||
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
"(Landroid/content/Context;Ljava/lang/Class;)V",
|
||||||
@@ -165,41 +153,42 @@ fn session_intent<'l>(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
|
||||||
let action_view = crate::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&intent,
|
&intent,
|
||||||
"setAction",
|
"setAction",
|
||||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||||
&[JValue::Object(&action_view)],
|
&[JValue::Object(&action_view)],
|
||||||
)?;
|
)?;
|
||||||
let builder = crate::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
|
||||||
let scheme = crate::jcall::jstr_obj(env, settings::SCHEME)?;
|
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"scheme",
|
"scheme",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&scheme)],
|
&[JValue::Object(&scheme)],
|
||||||
)?;
|
)?;
|
||||||
let authority = crate::jcall::jstr_obj(env, "session")?;
|
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"authority",
|
"authority",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&authority)],
|
&[JValue::Object(&authority)],
|
||||||
)?;
|
)?;
|
||||||
let path = crate::jcall::jstr_obj(env, session_id)?;
|
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
"appendPath",
|
"appendPath",
|
||||||
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
|
||||||
&[JValue::Object(&path)],
|
&[JValue::Object(&path)],
|
||||||
)?;
|
)?;
|
||||||
let uri = crate::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?.l()?;
|
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
|
||||||
crate::jcall::call_method(
|
.l()?;
|
||||||
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&intent,
|
&intent,
|
||||||
"setData",
|
"setData",
|
||||||
@@ -216,7 +205,7 @@ fn pending_activity<'l>(
|
|||||||
) -> Result<JObject<'l>> {
|
) -> Result<JObject<'l>> {
|
||||||
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
|
||||||
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"android/app/PendingIntent",
|
"android/app/PendingIntent",
|
||||||
"getActivity",
|
"getActivity",
|
||||||
@@ -238,7 +227,7 @@ fn builder_call<'l>(
|
|||||||
sig: &str,
|
sig: &str,
|
||||||
args: &[JValue],
|
args: &[JValue],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
crate::jcall::call_method(env, builder, method, sig, args)?;
|
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,14 +248,14 @@ fn foreground_type(env: &mut Env) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
|
||||||
let channel = crate::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationCompat$Builder",
|
"androidx/core/app/NotificationCompat$Builder",
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
&[JValue::Object(context), JValue::Object(&channel)],
|
&[JValue::Object(context), JValue::Object(&channel)],
|
||||||
)?;
|
)?;
|
||||||
let title = crate::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -297,7 +286,8 @@ fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObj
|
|||||||
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
"(I)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Int(priority_min)],
|
&[JValue::Int(priority_min)],
|
||||||
)?;
|
)?;
|
||||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?.l()
|
crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
||||||
|
.l()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the service if there is a server to connect to, and stops it
|
/// Starts the service if there is a server to connect to, and stops it
|
||||||
@@ -306,7 +296,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
let service_intent =
|
let service_intent =
|
||||||
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
|
||||||
if settings::load(env, context)?.is_none() {
|
if settings::load(env, context)?.is_none() {
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
context,
|
context,
|
||||||
"stopService",
|
"stopService",
|
||||||
@@ -316,7 +306,7 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
create_channels(env, context)?;
|
create_channels(env, context)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/content/ContextCompat",
|
"androidx/core/content/ContextCompat",
|
||||||
"startForegroundService",
|
"startForegroundService",
|
||||||
@@ -326,16 +316,11 @@ pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
|
|||||||
Ok(())
|
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 {
|
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
|
||||||
match try_start(env, &service) {
|
match try_start(env, &service) {
|
||||||
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let _ = crate::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
|
||||||
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -352,7 +337,7 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
let ca = settings::load_pinned_ca(env)?;
|
let ca = settings::load_pinned_ca(env)?;
|
||||||
let notification = ongoing_notification(env, service)?;
|
let notification = ongoing_notification(env, service)?;
|
||||||
let fg_type = foreground_type(env)?;
|
let fg_type = foreground_type(env)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/ServiceCompat",
|
"androidx/core/app/ServiceCompat",
|
||||||
"startForeground",
|
"startForeground",
|
||||||
@@ -379,9 +364,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("ai-app-notifications".to_string())
|
.name("ai-app-notifications".to_string())
|
||||||
.spawn(move || {
|
.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| {
|
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
|
||||||
follow_loop(env, &context, settings, &ca);
|
follow_loop(env, &context, settings, &ca);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -391,12 +373,6 @@ fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
|
|||||||
Ok(true)
|
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]) {
|
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
|
||||||
while !STOPPING.load(Ordering::SeqCst) {
|
while !STOPPING.load(Ordering::SeqCst) {
|
||||||
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
|
||||||
@@ -414,9 +390,6 @@ fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<()> {
|
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
|
||||||
let manager = notification_manager(env, context)?;
|
let manager = notification_manager(env, context)?;
|
||||||
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
|
||||||
@@ -424,13 +397,14 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
let allowed = if sdk < tiramisu {
|
let allowed = if sdk < tiramisu {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
let permission = crate::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
let permission =
|
||||||
|
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
|
||||||
let granted = static_int(
|
let granted = static_int(
|
||||||
env,
|
env,
|
||||||
"android/content/pm/PackageManager",
|
"android/content/pm/PackageManager",
|
||||||
"PERMISSION_GRANTED",
|
"PERMISSION_GRANTED",
|
||||||
)?;
|
)?;
|
||||||
let result = crate::jcall::call_static_method(
|
let result = crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"androidx/core/content/ContextCompat",
|
"androidx/core/content/ContextCompat",
|
||||||
"checkSelfPermission",
|
"checkSelfPermission",
|
||||||
@@ -441,20 +415,21 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
result == granted
|
result == granted
|
||||||
};
|
};
|
||||||
let enabled =
|
let enabled =
|
||||||
crate::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?.z()?;
|
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
|
||||||
|
.z()?;
|
||||||
if !allowed || !enabled {
|
if !allowed || !enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let intent = session_intent(env, context, ¬ification.session_id)?;
|
let intent = session_intent(env, context, ¬ification.session_id)?;
|
||||||
let pending = pending_activity(env, context, &intent)?;
|
let pending = pending_activity(env, context, &intent)?;
|
||||||
let channel = crate::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
|
||||||
let builder = crate::jcall::new_object(
|
let builder = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
"androidx/core/app/NotificationCompat$Builder",
|
"androidx/core/app/NotificationCompat$Builder",
|
||||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||||
&[JValue::Object(context), JValue::Object(&channel)],
|
&[JValue::Object(context), JValue::Object(&channel)],
|
||||||
)?;
|
)?;
|
||||||
let title = crate::jcall::jstr_obj(env, ¬ification.title)?;
|
let title = crate::shell::jcall::jstr_obj(env, ¬ification.title)?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -462,7 +437,7 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Object(&title)],
|
&[JValue::Object(&title)],
|
||||||
)?;
|
)?;
|
||||||
let text = crate::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
|
||||||
builder_call(
|
builder_call(
|
||||||
env,
|
env,
|
||||||
&builder,
|
&builder,
|
||||||
@@ -507,11 +482,16 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
|
||||||
&[JValue::Bool(JNI_TRUE)],
|
&[JValue::Bool(JNI_TRUE)],
|
||||||
)?;
|
)?;
|
||||||
let built =
|
let built = crate::shell::jcall::call_method(
|
||||||
crate::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
|
env,
|
||||||
.l()?;
|
&builder,
|
||||||
let tag = crate::jcall::jstr_obj(env, ¬ification.session_id)?;
|
"build",
|
||||||
crate::jcall::call_method(
|
"()Landroid/app/Notification;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let tag = crate::shell::jcall::jstr_obj(env, ¬ification.session_id)?;
|
||||||
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&manager,
|
&manager,
|
||||||
"notify",
|
"notify",
|
||||||
@@ -525,8 +505,6 @@ fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the follow loop -- mirrors `Notifications.kt`'s `onDestroy`, with
|
|
||||||
/// the gap this module's `STOPPING` doc explains.
|
|
||||||
pub fn on_destroy() {
|
pub fn on_destroy() {
|
||||||
STOPPING.store(true, Ordering::SeqCst);
|
STOPPING.store(true, Ordering::SeqCst);
|
||||||
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
|
||||||
@@ -542,9 +520,9 @@ pub fn on_destroy() {
|
|||||||
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
|
||||||
let message = format!("android-shell: {where_}: {error}");
|
let message = format!("android-shell: {where_}: {error}");
|
||||||
let _ = (|| -> Result<()> {
|
let _ = (|| -> Result<()> {
|
||||||
let tag = crate::jcall::jstr_obj(env, "android-shell")?;
|
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
|
||||||
let msg = crate::jcall::jstr_obj(env, &message)?;
|
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"android/util/Log",
|
"android/util/Log",
|
||||||
"e",
|
"e",
|
||||||
@@ -28,20 +28,15 @@ impl ServerSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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";
|
pub(crate) const SCHEME: &str = "aiappshell";
|
||||||
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
const KEY_ALIAS: &str = "aiapp-shell-token-key";
|
||||||
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
|
||||||
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
|
||||||
|
|
||||||
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
||||||
let scheme = crate::jcall::jstr_obj(env, SCHEME)?;
|
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
|
||||||
let alias = crate::jcall::jstr_obj(env, KEY_ALIAS)?;
|
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
|
||||||
crate::jcall::new_object(
|
crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
STORE_CLASS,
|
STORE_CLASS,
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||||
@@ -51,13 +46,14 @@ fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
|
|||||||
|
|
||||||
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
|
||||||
let host = get_string(env, settings_obj, "getHost")?;
|
let host = get_string(env, settings_obj, "getHost")?;
|
||||||
let port = crate::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
|
||||||
let token = get_string(env, settings_obj, "getToken")?;
|
let token = get_string(env, settings_obj, "getToken")?;
|
||||||
Ok(ServerSettings { host, port, token })
|
Ok(ServerSettings { host, port, token })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
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 value =
|
||||||
|
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
let jstr: JString = env.cast_local::<JString>(value)?;
|
let jstr: JString = env.cast_local::<JString>(value)?;
|
||||||
jstr.try_to_string(env)
|
jstr.try_to_string(env)
|
||||||
}
|
}
|
||||||
@@ -66,7 +62,7 @@ fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
|
|||||||
/// `ServerConfig.kt`'s `loadServerSettings`.
|
/// `ServerConfig.kt`'s `loadServerSettings`.
|
||||||
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let settings_obj = crate::jcall::call_method(
|
let settings_obj = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"load",
|
"load",
|
||||||
@@ -80,12 +76,11 @@ pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>>
|
|||||||
Ok(Some(read_settings(env, &settings_obj)?))
|
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<()> {
|
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let host = crate::jcall::jstr_obj(env, &settings.host)?;
|
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
|
||||||
let token = crate::jcall::jstr_obj(env, &settings.token)?;
|
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
|
||||||
let settings_obj = crate::jcall::new_object(
|
let settings_obj = crate::shell::jcall::new_object(
|
||||||
env,
|
env,
|
||||||
SETTINGS_CLASS,
|
SETTINGS_CLASS,
|
||||||
"(Ljava/lang/String;ILjava/lang/String;)V",
|
"(Ljava/lang/String;ILjava/lang/String;)V",
|
||||||
@@ -95,7 +90,7 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
|
|||||||
JValue::Object(&token),
|
JValue::Object(&token),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
crate::jcall::call_method(
|
crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"save",
|
"save",
|
||||||
@@ -105,12 +100,9 @@ pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Resu
|
|||||||
Ok(())
|
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>> {
|
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
|
||||||
let store = new_store(env)?;
|
let store = new_store(env)?;
|
||||||
let settings_obj = crate::jcall::call_method(
|
let settings_obj = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
&store,
|
&store,
|
||||||
"parseEnrollmentUri",
|
"parseEnrollmentUri",
|
||||||
@@ -129,7 +121,7 @@ pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<Serve
|
|||||||
/// but into a plain Java constant, since this module has no Kotlin of its
|
/// but into a plain Java constant, since this module has no Kotlin of its
|
||||||
/// own to generate into.
|
/// own to generate into.
|
||||||
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
|
||||||
let value = crate::jcall::get_static_field(
|
let value = crate::shell::jcall::get_static_field(
|
||||||
env,
|
env,
|
||||||
"com/example/aiapp/shell/PinnedCa",
|
"com/example/aiapp/shell/PinnedCa",
|
||||||
"PINNED_CA_PEM",
|
"PINNED_CA_PEM",
|
||||||
@@ -1,30 +1,10 @@
|
|||||||
//! Deep links and the share sheet -- ported from `MainActivity.kt`'s
|
use crate::client::api::{ApiClient, UreqTransport};
|
||||||
//! `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::Env;
|
||||||
use jni::errors::Result;
|
use jni::errors::Result;
|
||||||
use jni::objects::{JObject, JString, JValue};
|
use jni::objects::{JObject, JString, JValue};
|
||||||
|
|
||||||
use crate::notify;
|
use crate::shell::notify;
|
||||||
use crate::settings;
|
use crate::shell::settings;
|
||||||
|
|
||||||
const ACTION_SEND: &str = "android.intent.action.SEND";
|
const ACTION_SEND: &str = "android.intent.action.SEND";
|
||||||
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
|
||||||
@@ -32,7 +12,8 @@ const ACTION_VIEW: &str = "android.intent.action.VIEW";
|
|||||||
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
|
||||||
|
|
||||||
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
|
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()?;
|
let value =
|
||||||
|
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
|
||||||
if value.is_null() {
|
if value.is_null() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -41,8 +22,8 @@ fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Optio
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
||||||
let message = crate::jcall::jstr_obj(env, message)?;
|
let message = crate::shell::jcall::jstr_obj(env, message)?;
|
||||||
crate::jcall::call_static_method(
|
crate::shell::jcall::call_static_method(
|
||||||
env,
|
env,
|
||||||
"com/example/aiapp/shell/MainActivity",
|
"com/example/aiapp/shell/MainActivity",
|
||||||
"toast",
|
"toast",
|
||||||
@@ -52,8 +33,6 @@ fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
let action = get_string_method(env, intent, "getAction")?;
|
let action = get_string_method(env, intent, "getAction")?;
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -65,7 +44,8 @@ pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Res
|
|||||||
if action.as_deref() != Some(ACTION_VIEW) {
|
if action.as_deref() != Some(ACTION_VIEW) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let uri = crate::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?.l()?;
|
let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
|
||||||
|
.l()?;
|
||||||
if uri.is_null() {
|
if uri.is_null() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -84,9 +64,6 @@ fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Resu
|
|||||||
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
|
||||||
return Ok(());
|
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}"))
|
toast(env, activity, &format!("Opened session {session_id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +82,9 @@ fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<()> {
|
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
|
||||||
let extra_text = crate::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
|
||||||
let text = crate::jcall::call_method(
|
let text = crate::shell::jcall::call_method(
|
||||||
env,
|
env,
|
||||||
intent,
|
intent,
|
||||||
"getStringExtra",
|
"getStringExtra",
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
const MAX_LINES: f32 = 6.0;
|
||||||
|
const APPROX_LINE_HEIGHT_DP: f32 = 24.0;
|
||||||
|
const FIELD_PAD_DP: f32 = 12.0;
|
||||||
|
|
||||||
|
/// `field` is exposed so the caller can read its content on submit
|
||||||
|
/// (`field.edit(rsc).text()`) and clear it afterward
|
||||||
|
/// (`field.edit(rsc).set("")`).
|
||||||
|
pub struct Composer {
|
||||||
|
pub field: WeakWidget<TextEdit>,
|
||||||
|
/// The bar's own outer padding -- only `bottom` is ever changed, by
|
||||||
|
/// [`Self::set_bottom_inset`]. A `Pad` around the whole bar rather than
|
||||||
|
/// a rebuilt tree, because `field` lives inside it and cannot be
|
||||||
|
/// re-added to a new wrapper once it is strongly owned here.
|
||||||
|
outer_pad: WeakWidget<Pad>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Composer {
|
||||||
|
/// Called by the platform shell (Android's `on_insets_changed`, e.g.)
|
||||||
|
/// whenever the space below the bar changes: the IME's own inset while
|
||||||
|
/// it is open, the navigation-bar inset otherwise. Takes a plain
|
||||||
|
/// `f32` in the caller's own physical-pixel units rather than an
|
||||||
|
/// Android-specific insets type, so this crate stays usable from the
|
||||||
|
/// winit backend too, which has no navigation bar to report.
|
||||||
|
/// Rewrites the existing `Pad` in place (marking it dirty through the
|
||||||
|
/// ordinary `Widgets::get_mut` path) instead of swapping in a new one,
|
||||||
|
/// so the field's focus, selection and in-progress text are untouched.
|
||||||
|
pub fn set_bottom_inset(&self, rsc: &mut impl UiRsc, inset: f32) {
|
||||||
|
if let Some(pad) = rsc.ui_mut().widgets.get_mut(&self.outer_pad) {
|
||||||
|
pad.padding.bottom = Len::abs(inset);
|
||||||
|
pad.exact_region = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the composer plus its own bar as a **weak** id -- the caller
|
||||||
|
/// (`lib.rs::build`) embeds it in the screen's own top-level tuple, whose
|
||||||
|
/// `set_root` performs the one real strong registration. Calling
|
||||||
|
/// `.add_strong`/`.upgrade` a second time on an id already strong-owned
|
||||||
|
/// panics ("was already added", `core/src/widget/like.rs:12`) -- the same
|
||||||
|
/// mistake this box's `row.rs` first made with its sender-label header, see
|
||||||
|
/// that file's comment for the fuller account.
|
||||||
|
pub fn build_composer<Rsc: HasEvents>(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
let field = wtext("")
|
||||||
|
.editable(EditMode::MultiLine)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.wrap(true)
|
||||||
|
.size(18)
|
||||||
|
.color(theme.text.clone())
|
||||||
|
.attr::<Selectable>(())
|
||||||
|
.label("Message")
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
// Without any mask at all the overflow paints *above* the bar, over
|
||||||
|
// the transcript: measured at 58px of stray text for a 475px message
|
||||||
|
// in a 417px box.
|
||||||
|
let content = field
|
||||||
|
.width(rest(1))
|
||||||
|
.scrollable(Axis::Y, Pin::End)
|
||||||
|
.pad(dp(FIELD_PAD_DP))
|
||||||
|
.max_height(dp(APPROX_LINE_HEIGHT_DP * MAX_LINES + FIELD_PAD_DP * 2.0))
|
||||||
|
.width(rest(1))
|
||||||
|
.masked_by(rect(theme.composer_surface.clone()))
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let outer_pad: WeakWidget<Pad> = content.pad(Padding::ZERO).add(rsc);
|
||||||
|
|
||||||
|
(Composer { field, outer_pad }, outer_pad)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
pub const BACKLOG_COUNT: usize = 3202;
|
||||||
|
|
||||||
|
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||||
|
|
||||||
|
pub const PHONE_WIDTH: f32 = 1080.0;
|
||||||
|
pub const PHONE_HEIGHT: f32 = 2424.0;
|
||||||
|
pub const PHONE_SCALE: f32 = 2.55;
|
||||||
|
pub const PHONE_FRAME_MS: u64 = 8;
|
||||||
|
|
||||||
|
pub fn phone_size() -> Vec2 {
|
||||||
|
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Fixture {
|
||||||
|
pub backlog: Vec<serde_json::Value>,
|
||||||
|
pub stream_tail: Vec<SeqEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
/// Parses the whole fixture. Panics on malformed input: this is a
|
||||||
|
/// generated file compiled into the binary, so a parse failure is a
|
||||||
|
/// broken build rather than a condition a caller could recover from
|
||||||
|
/// (CODE_RULES: separate recoverable conditions from programmer
|
||||||
|
/// error).
|
||||||
|
pub fn parse() -> Self {
|
||||||
|
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
|
||||||
|
let mut stream_tail = Vec::new();
|
||||||
|
for (i, line) in FIXTURE_JSONL
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
||||||
|
if i < BACKLOG_COUNT {
|
||||||
|
backlog.push(value);
|
||||||
|
} else {
|
||||||
|
stream_tail.push(
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.expect("bench fixture event matches event-model's SeqEvent"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
backlog,
|
||||||
|
stream_tail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opening page folded into transcript items -- the same
|
||||||
|
/// `fold_page` a real first load runs. `Err` carries the fold's own
|
||||||
|
/// message, which a caller shows on screen rather than panicking, so
|
||||||
|
/// a fixture that stops folding is visible in the app instead of
|
||||||
|
/// being a crash on launch.
|
||||||
|
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
|
||||||
|
fold_page(&self.backlog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
|
group_tool_runs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a caller needs to run the fixture as an app screen would:
|
||||||
|
/// the screen, the folded items behind it, and the events not yet
|
||||||
|
/// streamed. The tree itself comes back separately from
|
||||||
|
/// [`build_screen`], since whoever takes it owns it.
|
||||||
|
pub struct Opened {
|
||||||
|
pub screen: crate::ui::TranscriptScreen,
|
||||||
|
pub items: Vec<TranscriptItem>,
|
||||||
|
/// The tail, for a caller that goes on replaying it one event at a
|
||||||
|
/// time through `fold_event`/`TranscriptScreen::apply` -- the
|
||||||
|
/// streaming phase of either app's benchmark.
|
||||||
|
pub stream_tail: Vec<SeqEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the transcript screen over the fixture's opening page, without
|
||||||
|
/// claiming the window's root -- `crate::ui::build_tree`'s own split,
|
||||||
|
/// for a caller (the Android bench) that puts the screen inside a shell
|
||||||
|
/// of its own.
|
||||||
|
pub fn build_screen<Rsc: HasEvents>(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String>
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let fixture = Fixture::parse();
|
||||||
|
let items = fixture.backlog_items()?;
|
||||||
|
let (screen, tree) = crate::ui::build_tree(rsc, rows(&items));
|
||||||
|
Ok((
|
||||||
|
Opened {
|
||||||
|
screen,
|
||||||
|
items,
|
||||||
|
stream_tail: fixture.stream_tail,
|
||||||
|
},
|
||||||
|
tree,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
ui_state: &mut impl HasRoot<Rsc>,
|
||||||
|
) -> Result<Opened, String>
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (opened, tree) = build_screen(rsc)?;
|
||||||
|
ui_state.set_root(rsc, tree);
|
||||||
|
Ok(opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
|
||||||
|
let fixture = Fixture::parse();
|
||||||
|
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
|
||||||
|
assert!(
|
||||||
|
fixture.stream_tail.len() >= 400,
|
||||||
|
"the stream phase replays 400 events; the fixture has {}",
|
||||||
|
fixture.stream_tail.len()
|
||||||
|
);
|
||||||
|
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
use crate::client::highlight::{self, Kind, Language};
|
||||||
|
use crate::client::markdown_blocks::{Block, BlockKind};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
fn syntax_color(kind: Kind, theme: &Theme) -> PaintId {
|
||||||
|
match kind {
|
||||||
|
Kind::Keyword => theme.syntax_keyword.clone(),
|
||||||
|
Kind::String => theme.syntax_string.clone(),
|
||||||
|
Kind::Literal => theme.syntax_literal.clone(),
|
||||||
|
Kind::Comment => theme.syntax_comment.clone(),
|
||||||
|
Kind::Metadata => theme.syntax_metadata.clone(),
|
||||||
|
Kind::Punctuation => theme.syntax_punctuation.clone(),
|
||||||
|
Kind::Mark => theme.syntax_mark.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum BlockFrame {
|
||||||
|
Plain,
|
||||||
|
Verbatim { fill: PaintId },
|
||||||
|
Quote,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame_of(kind: BlockKind, theme: &Theme) -> BlockFrame {
|
||||||
|
match kind {
|
||||||
|
BlockKind::Code => BlockFrame::Verbatim {
|
||||||
|
fill: theme.verbatim_surface.clone(),
|
||||||
|
},
|
||||||
|
BlockKind::Table => BlockFrame::Verbatim {
|
||||||
|
fill: theme.table_surface.clone(),
|
||||||
|
},
|
||||||
|
BlockKind::Quote => BlockFrame::Quote,
|
||||||
|
BlockKind::Paragraph | BlockKind::Heading | BlockKind::List | BlockKind::Other => {
|
||||||
|
BlockFrame::Plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Link {
|
||||||
|
pub range: Range<usize>,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Rendered {
|
||||||
|
pub text: String,
|
||||||
|
pub spans: Vec<SpanStyle>,
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rendered {
|
||||||
|
pub fn link_at(&self, byte: usize) -> Option<&Link> {
|
||||||
|
self.links.iter().find(|l| l.range.contains(&byte))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading ladder, in points at a 16pt body: it starts near the body
|
||||||
|
/// text and descends, because these are headings inside a chat message
|
||||||
|
/// rather than the top of a document. The numbers are Material's
|
||||||
|
/// `headlineSmall`/`titleLarge`/`titleMedium`/`titleSmall`/`labelMedium`/
|
||||||
|
/// `labelSmall`, which is what `Markdown.kt`'s `markdownTypography` picks
|
||||||
|
/// -- kept as literals rather than derived from `base_size` so the two
|
||||||
|
/// apps agree exactly.
|
||||||
|
fn heading_size(level: HeadingLevel) -> f32 {
|
||||||
|
match level {
|
||||||
|
HeadingLevel::H1 => 24.0,
|
||||||
|
HeadingLevel::H2 => 22.0,
|
||||||
|
HeadingLevel::H3 => 16.0,
|
||||||
|
HeadingLevel::H4 => 14.0,
|
||||||
|
HeadingLevel::H5 => 12.0,
|
||||||
|
HeadingLevel::H6 => 11.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BULLETS: [&str; 3] = ["\u{2022} ", "\u{25e6} ", "\u{25aa} "];
|
||||||
|
|
||||||
|
/// A block-level separator inside one block's own text (a list item's
|
||||||
|
/// paragraphs, a quote's): two never run into each other with no gap, but
|
||||||
|
/// an empty `out` gets no leading blank.
|
||||||
|
fn ensure_blank_line(out: &mut String) {
|
||||||
|
if !out.is_empty() && !out.ends_with("\n\n") {
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_line(out: &mut String) {
|
||||||
|
if !out.is_empty() && !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_block(block: &Block, base_size: f32, theme: &Theme) -> Rendered {
|
||||||
|
match block.kind {
|
||||||
|
BlockKind::Table => table_text(&block.source, theme),
|
||||||
|
_ => render_markdown(&block.source, base_size, theme),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One markdown source string rendered into plain text plus the spans that
|
||||||
|
/// style it. `base_size` is the row's ordinary paragraph font size, needed
|
||||||
|
/// only so a heading's override is relative to it rather than a hardcoded
|
||||||
|
/// absolute the caller cannot retune.
|
||||||
|
pub fn render_markdown(src: &str, base_size: f32, theme: &Theme) -> Rendered {
|
||||||
|
let _ = base_size; // headings use the fixed Material ladder; see `heading_size`
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
let mut links = Vec::new();
|
||||||
|
// Stack of start byte offsets for whatever inline/block styling is
|
||||||
|
// currently open -- pulldown-cmark's `Start`/`End` events are always
|
||||||
|
// balanced and each `End` already names its own kind (`TagEnd`), so a
|
||||||
|
// plain offset stack (rather than a tree, or repeating the kind here
|
||||||
|
// too) is enough. A link's destination rides along beside its offset,
|
||||||
|
// since `TagEnd::Link` does not carry it.
|
||||||
|
let mut open: Vec<(usize, Option<String>)> = Vec::new();
|
||||||
|
// One entry per open list: `Some(next number)` for an ordered list,
|
||||||
|
// `None` for a bulleted one. Depth is this vector's length, which is
|
||||||
|
// what picks the bullet glyph.
|
||||||
|
let mut lists: Vec<Option<u64>> = Vec::new();
|
||||||
|
let mut fence_language: Option<Language> = None;
|
||||||
|
|
||||||
|
let parser = Parser::new_ext(src, options());
|
||||||
|
for event in parser {
|
||||||
|
match event {
|
||||||
|
Event::Start(tag) => match tag {
|
||||||
|
Tag::Heading { .. }
|
||||||
|
| Tag::Emphasis
|
||||||
|
| Tag::Strong
|
||||||
|
| Tag::Strikethrough
|
||||||
|
| Tag::Image { .. } => open.push((out.len(), None)),
|
||||||
|
Tag::Link { dest_url, .. } => open.push((out.len(), Some(dest_url.to_string()))),
|
||||||
|
Tag::CodeBlock(kind) => {
|
||||||
|
fence_language = match &kind {
|
||||||
|
CodeBlockKind::Fenced(info) => {
|
||||||
|
highlight::fence_language(info.split_whitespace().next())
|
||||||
|
}
|
||||||
|
CodeBlockKind::Indented => None,
|
||||||
|
};
|
||||||
|
ensure_blank_line(&mut out);
|
||||||
|
open.push((out.len(), None));
|
||||||
|
}
|
||||||
|
Tag::Item => {
|
||||||
|
ensure_line(&mut out);
|
||||||
|
let depth = lists.len().max(1);
|
||||||
|
out.push_str(&" ".repeat(depth - 1));
|
||||||
|
let start = out.len();
|
||||||
|
match lists.last_mut() {
|
||||||
|
Some(Some(n)) => {
|
||||||
|
out.push_str(&format!("{n}. "));
|
||||||
|
*n += 1;
|
||||||
|
}
|
||||||
|
_ => out.push_str(BULLETS[(depth - 1) % BULLETS.len()]),
|
||||||
|
}
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
|
||||||
|
}
|
||||||
|
Tag::List(first) => lists.push(first),
|
||||||
|
Tag::Paragraph | Tag::BlockQuote(_) => ensure_blank_line(&mut out),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Event::End(
|
||||||
|
tag_end @ (TagEnd::Heading(_)
|
||||||
|
| TagEnd::Emphasis
|
||||||
|
| TagEnd::Strong
|
||||||
|
| TagEnd::Strikethrough
|
||||||
|
| TagEnd::Link
|
||||||
|
| TagEnd::Image
|
||||||
|
| TagEnd::CodeBlock),
|
||||||
|
) => {
|
||||||
|
let Some((start, dest)) = open.pop() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matches!(tag_end, TagEnd::CodeBlock) {
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let range = start..out.len();
|
||||||
|
if range.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match tag_end {
|
||||||
|
TagEnd::Heading(level) => {
|
||||||
|
spans.push(SpanStyle::new(range).font_size(heading_size(level)).bold());
|
||||||
|
}
|
||||||
|
TagEnd::Emphasis => spans.push(SpanStyle::new(range).italic()),
|
||||||
|
TagEnd::Strong => spans.push(SpanStyle::new(range).bold()),
|
||||||
|
TagEnd::Strikethrough => {
|
||||||
|
spans.push(SpanStyle::new(range).color(theme.strikethrough.clone()));
|
||||||
|
}
|
||||||
|
// An image draws as its alt text until the port has a
|
||||||
|
// transcript image widget (IRIS_TODO's "scaled
|
||||||
|
// thumbnail"); marked as a link so it is at least
|
||||||
|
// followable rather than silently inert.
|
||||||
|
TagEnd::Link | TagEnd::Image => {
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(range.clone())
|
||||||
|
.color(theme.link.clone())
|
||||||
|
.underline(),
|
||||||
|
);
|
||||||
|
if let Some(url) = dest {
|
||||||
|
links.push(Link { range, url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TagEnd::CodeBlock => {
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(range.clone())
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.color(theme.code.clone()),
|
||||||
|
);
|
||||||
|
if let Some(language) = fence_language.take() {
|
||||||
|
highlight_into(&mut spans, &out, range, language, theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!("filtered by the outer match arm"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Text(text) => out.push_str(&text),
|
||||||
|
Event::Code(text) => {
|
||||||
|
let start = out.len();
|
||||||
|
out.push_str(&text);
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(start..out.len())
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.color(theme.code.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Event::SoftBreak => out.push(' '),
|
||||||
|
Event::HardBreak => out.push('\n'),
|
||||||
|
Event::Rule => {
|
||||||
|
ensure_line(&mut out);
|
||||||
|
out.push_str("\u{2500}\u{2500}\u{2500}\n");
|
||||||
|
}
|
||||||
|
Event::TaskListMarker(done) => {
|
||||||
|
let start = out.len();
|
||||||
|
out.push_str(if done { "[x] " } else { "[ ] " });
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).color(theme.marker.clone()));
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::List(_)) => {
|
||||||
|
lists.pop();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while out.ends_with('\n') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
spans.retain(|s| s.range.end <= out.len());
|
||||||
|
links.retain(|l| l.range.end <= out.len());
|
||||||
|
Rendered {
|
||||||
|
text: out,
|
||||||
|
spans,
|
||||||
|
links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options() -> Options {
|
||||||
|
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn highlight_into(
|
||||||
|
spans: &mut Vec<SpanStyle>,
|
||||||
|
text: &str,
|
||||||
|
range: Range<usize>,
|
||||||
|
language: Language,
|
||||||
|
theme: &Theme,
|
||||||
|
) {
|
||||||
|
let code = &text[range.clone()];
|
||||||
|
// char index -> byte offset within `code`, plus the end, so a span's
|
||||||
|
// `end` is always in range.
|
||||||
|
let bytes: Vec<usize> = code
|
||||||
|
.char_indices()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.chain(std::iter::once(code.len()))
|
||||||
|
.collect();
|
||||||
|
for span in highlight::spans_of(code, language) {
|
||||||
|
let (Some(&start), Some(&end)) = (bytes.get(span.start), bytes.get(span.end)) else {
|
||||||
|
debug_assert!(
|
||||||
|
false,
|
||||||
|
"highlight span {}..{} outside {} chars of code",
|
||||||
|
span.start,
|
||||||
|
span.end,
|
||||||
|
bytes.len() - 1
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
spans.push(
|
||||||
|
SpanStyle::new(range.start + start..range.start + end)
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.color(syntax_color(span.kind, theme)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE_MAX_COL: usize = 28;
|
||||||
|
|
||||||
|
pub fn table_text(src: &str, theme: &Theme) -> Rendered {
|
||||||
|
let rows = table_cells(src);
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Rendered::default();
|
||||||
|
}
|
||||||
|
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||||
|
let wrapped: Vec<Vec<Vec<String>>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.iter().map(|c| wrap_cell(c, TABLE_MAX_COL)).collect())
|
||||||
|
.collect();
|
||||||
|
let widths: Vec<usize> = (0..columns)
|
||||||
|
.map(|c| {
|
||||||
|
wrapped
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| row.get(c))
|
||||||
|
.flat_map(|lines| lines.iter())
|
||||||
|
.map(|l| l.chars().count())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
for (r, row) in wrapped.iter().enumerate() {
|
||||||
|
let height = row.iter().map(Vec::len).max().unwrap_or(1);
|
||||||
|
let start = out.len();
|
||||||
|
for line in 0..height {
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
for (c, width) in widths.iter().enumerate() {
|
||||||
|
if c > 0 {
|
||||||
|
out.push_str(" ");
|
||||||
|
}
|
||||||
|
let text = row.get(c).and_then(|l| l.get(line)).map(String::as_str);
|
||||||
|
let text = text.unwrap_or("");
|
||||||
|
out.push_str(text);
|
||||||
|
if c + 1 < widths.len() {
|
||||||
|
for _ in text.chars().count()..*width {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r == 0 {
|
||||||
|
spans.push(SpanStyle::new(start..out.len()).bold());
|
||||||
|
out.push('\n');
|
||||||
|
let rule: usize = widths.iter().sum::<usize>() + 2 * widths.len().saturating_sub(1);
|
||||||
|
let rule_start = out.len();
|
||||||
|
out.extend(std::iter::repeat_n('\u{2500}', rule));
|
||||||
|
spans.push(SpanStyle::new(rule_start..out.len()).color(theme.quote_bar.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rendered {
|
||||||
|
text: out,
|
||||||
|
spans,
|
||||||
|
links: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_cells(src: &str) -> Vec<Vec<String>> {
|
||||||
|
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||||
|
let mut cell = String::new();
|
||||||
|
let mut in_cell = false;
|
||||||
|
for event in Parser::new_ext(src, options()) {
|
||||||
|
match event {
|
||||||
|
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => rows.push(Vec::new()),
|
||||||
|
Event::Start(Tag::TableCell) => {
|
||||||
|
cell.clear();
|
||||||
|
in_cell = true;
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::TableCell) => {
|
||||||
|
in_cell = false;
|
||||||
|
if let Some(row) = rows.last_mut() {
|
||||||
|
row.push(cell.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Text(text) | Event::Code(text) if in_cell => cell.push_str(&text),
|
||||||
|
Event::SoftBreak | Event::HardBreak if in_cell => cell.push(' '),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.retain(|r| !r.is_empty());
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_cell(text: &str, width: usize) -> Vec<String> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut line = String::new();
|
||||||
|
for word in text.split_whitespace() {
|
||||||
|
let extra = if line.is_empty() { 0 } else { 1 };
|
||||||
|
if !line.is_empty() && line.chars().count() + extra + word.chars().count() > width {
|
||||||
|
lines.push(std::mem::take(&mut line));
|
||||||
|
}
|
||||||
|
if !line.is_empty() {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
line.push_str(word);
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::markdown_blocks::split_blocks;
|
||||||
|
|
||||||
|
fn with_theme<T>(f: impl FnOnce(&Theme) -> T) -> T {
|
||||||
|
let mut paints = Paints::new();
|
||||||
|
let theme = Theme::new(&mut paints);
|
||||||
|
f(&theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown(src: &str, base_size: f32) -> Rendered {
|
||||||
|
with_theme(|theme| super::render_markdown(src, base_size, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_block(block: &Block, base_size: f32) -> Rendered {
|
||||||
|
with_theme(|theme| super::render_block(block, base_size, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_of(kind: BlockKind) -> BlockFrame {
|
||||||
|
with_theme(|theme| super::frame_of(kind, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syntax_color(kind: Kind) -> PaintId {
|
||||||
|
with_theme(|theme| super::syntax_color(kind, theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_color() -> PaintId {
|
||||||
|
with_theme(|theme| theme.code.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marker_color() -> PaintId {
|
||||||
|
with_theme(|theme| theme.marker.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block(src: &str) -> Rendered {
|
||||||
|
let blocks = split_blocks(src);
|
||||||
|
assert_eq!(blocks.len(), 1, "test wants exactly one block: {blocks:?}");
|
||||||
|
render_block(&blocks[0], 16.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_paragraph_has_no_spans() {
|
||||||
|
let r = render_markdown("just some words", 16.0);
|
||||||
|
assert_eq!(r.text, "just some words");
|
||||||
|
assert!(r.spans.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bold_and_italic_produce_spans_over_the_right_range() {
|
||||||
|
let r = render_markdown("a **bold** and *italic* word", 16.0);
|
||||||
|
assert_eq!(r.text, "a bold and italic word");
|
||||||
|
let bold = r.spans.iter().find(|s| s.bold && !s.italic).unwrap();
|
||||||
|
assert_eq!(&r.text[bold.range.clone()], "bold");
|
||||||
|
let italic = r.spans.iter().find(|s| s.italic).unwrap();
|
||||||
|
assert_eq!(&r.text[italic.range.clone()], "italic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heading_gets_a_bigger_font_size_span() {
|
||||||
|
let r = render_markdown("# A Title", 16.0);
|
||||||
|
assert!(r.text.starts_with("A Title"));
|
||||||
|
let heading = r.spans.iter().find(|s| s.font_size.is_some()).unwrap();
|
||||||
|
assert_eq!(&r.text[heading.range.clone()], "A Title");
|
||||||
|
assert_eq!(heading.font_size, Some(24.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_heading_level_is_a_different_size() {
|
||||||
|
let mut sizes = Vec::new();
|
||||||
|
for level in 1..=6 {
|
||||||
|
let src = format!("{} h", "#".repeat(level));
|
||||||
|
let r = render_markdown(&src, 16.0);
|
||||||
|
sizes.push(r.spans.iter().find_map(|s| s.font_size).unwrap());
|
||||||
|
}
|
||||||
|
let mut sorted = sizes.clone();
|
||||||
|
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sizes, sorted, "the ladder must descend with no repeats");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_keeps_its_text_and_its_url_and_can_be_hit() {
|
||||||
|
let r = render_markdown("see [the docs](https://example.com) for more", 16.0);
|
||||||
|
assert!(r.text.contains("the docs"));
|
||||||
|
assert!(
|
||||||
|
!r.text.contains("example.com"),
|
||||||
|
"the URL should not leak into the visible text"
|
||||||
|
);
|
||||||
|
let link = r.spans.iter().find(|s| s.underline).unwrap();
|
||||||
|
assert_eq!(&r.text[link.range.clone()], "the docs");
|
||||||
|
let at = r.text.find("docs").unwrap();
|
||||||
|
assert_eq!(r.link_at(at).unwrap().url, "https://example.com");
|
||||||
|
assert!(r.link_at(0).is_none(), "the word 'see' is not the link");
|
||||||
|
let past = r.text.find("for").unwrap();
|
||||||
|
assert!(r.link_at(past).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fenced_code_block_is_monospaced_and_highlighted_by_its_language() {
|
||||||
|
let r = block("```rust\nlet x = 1; // note\n```");
|
||||||
|
assert_eq!(r.text, "let x = 1; // note");
|
||||||
|
let keyword = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||||
|
.expect("a rust fence colours its keywords");
|
||||||
|
assert_eq!(&r.text[keyword.range.clone()], "let");
|
||||||
|
let comment = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::Comment)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&r.text[comment.range.clone()], "// note");
|
||||||
|
assert!(r.spans.iter().all(|s| s.range.end <= r.text.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fence_in_an_unknown_language_is_monospace_and_uncoloured() {
|
||||||
|
let r = block("```brainfuck\nlet x = 1;\n```");
|
||||||
|
assert_eq!(r.text, "let x = 1;");
|
||||||
|
assert_eq!(r.spans.len(), 1);
|
||||||
|
assert!(r.spans[0].family == Some(Family::Monospace));
|
||||||
|
assert_eq!(r.spans[0].color, Some(code_color()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_spans_are_byte_offsets_even_with_multibyte_code() {
|
||||||
|
let r = block("```rust\nlet s = \"café ☕\"; // é\n```");
|
||||||
|
for span in &r.spans {
|
||||||
|
assert!(
|
||||||
|
r.text.is_char_boundary(span.range.start)
|
||||||
|
&& r.text.is_char_boundary(span.range.end),
|
||||||
|
"span {:?} is not on a char boundary of {:?}",
|
||||||
|
span.range,
|
||||||
|
r.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let string = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.color == Some(syntax_color(Kind::String)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&r.text[string.range.clone()], "\"café ☕\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_fence_still_renders_what_arrived() {
|
||||||
|
let r = block("```rust\nlet x = 1;");
|
||||||
|
assert_eq!(r.text, "let x = 1;");
|
||||||
|
assert!(
|
||||||
|
r.spans
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.color == Some(syntax_color(Kind::Keyword)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bulleted_list_gets_a_marker_per_item_and_indents_nesting() {
|
||||||
|
let r = block("- one\n- two\n - deep");
|
||||||
|
assert_eq!(r.text, "\u{2022} one\n\u{2022} two\n \u{25e6} deep");
|
||||||
|
let markers: Vec<_> = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.color == Some(marker_color()))
|
||||||
|
.map(|s| r.text[s.range.clone()].to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, ["\u{2022} ", "\u{2022} ", "\u{25e6} "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_numbered_list_counts_from_the_number_it_was_written_with() {
|
||||||
|
let r = block("3. three\n4. four");
|
||||||
|
assert_eq!(r.text, "3. three\n4. four");
|
||||||
|
let markers: Vec<_> = r
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.color == Some(marker_color()))
|
||||||
|
.map(|s| r.text[s.range.clone()].to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, ["3. ", "4. "]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quote_is_its_text_and_takes_the_quote_frame() {
|
||||||
|
let blocks = split_blocks("> quoted words\n> still quoted");
|
||||||
|
assert_eq!(frame_of(blocks[0].kind), BlockFrame::Quote);
|
||||||
|
let r = render_block(&blocks[0], 16.0);
|
||||||
|
assert_eq!(r.text, "quoted words still quoted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_block_kind_maps_to_the_frame_it_is_drawn_in() {
|
||||||
|
use BlockKind::*;
|
||||||
|
assert_eq!(frame_of(Paragraph), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Heading), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(List), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Other), BlockFrame::Plain);
|
||||||
|
assert_eq!(frame_of(Quote), BlockFrame::Quote);
|
||||||
|
assert!(matches!(frame_of(Code), BlockFrame::Verbatim { .. }));
|
||||||
|
assert!(matches!(frame_of(Table), BlockFrame::Verbatim { .. }));
|
||||||
|
assert_ne!(
|
||||||
|
frame_of(Code),
|
||||||
|
frame_of(Table),
|
||||||
|
"a fence and a table sit on different fills"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_table_pads_its_columns_to_the_widest_cell() {
|
||||||
|
let r = block("| a | bb |\n|---|---|\n| cccc | d |");
|
||||||
|
let lines: Vec<&str> = r.text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a bb");
|
||||||
|
assert_eq!(lines[1], "\u{2500}".repeat(8));
|
||||||
|
assert_eq!(lines[2], "cccc d");
|
||||||
|
let bold = r.spans.iter().find(|s| s.bold).unwrap();
|
||||||
|
assert_eq!(&r.text[bold.range.clone()], "a bb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_table_cell_wraps_inside_its_column() {
|
||||||
|
let long = "one two three four five six seven eight nine ten eleven twelve";
|
||||||
|
let r = block(&format!("| k | v |\n|---|---|\n| a | {long} |"));
|
||||||
|
for line in r.text.lines() {
|
||||||
|
assert!(
|
||||||
|
line.chars().count() <= TABLE_MAX_COL + 1 + 2 + 1,
|
||||||
|
"line too wide: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(r.text.contains("twelve"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_list_marks_its_boxes() {
|
||||||
|
let r = block("- [x] done\n- [ ] not");
|
||||||
|
assert!(r.text.contains("[x] done"));
|
||||||
|
assert!(r.text.contains("[ ] not"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
pub mod composer;
|
||||||
|
// Keep the 1.9 MB fixture out of ordinary APKs.
|
||||||
|
#[cfg(feature = "fixture")]
|
||||||
|
pub mod fixture;
|
||||||
|
pub mod markdown;
|
||||||
|
pub mod row;
|
||||||
|
pub(crate) mod tap;
|
||||||
|
pub mod theme;
|
||||||
|
pub mod tool;
|
||||||
|
|
||||||
|
use crate::client::transcript_fold::{TranscriptItem, TranscriptRow as FoldedRow, group_tool_runs};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::{mem, rc::Rc};
|
||||||
|
use theme::Theme;
|
||||||
|
|
||||||
|
pub struct TranscriptScreen {
|
||||||
|
/// The transcript's own `LazySpan` -- the layout *and* the scroll
|
||||||
|
/// position, since a lazy span owns a `ScrollController` of its own
|
||||||
|
/// rather than being wrapped in a `ScrollArea` (`docs/SCROLL.md`).
|
||||||
|
/// Exposed so a caller can read `.extent()`, drive it through
|
||||||
|
/// `Scrollable` (`.scroll()`, `.fling()`, `.amt()`) or call
|
||||||
|
/// `.jump_to_end()` directly.
|
||||||
|
pub list: WeakWidget<LazySpan>,
|
||||||
|
pub composer: composer::Composer,
|
||||||
|
rebuilds: usize,
|
||||||
|
tail: Option<(RowKey, row::TailRow)>,
|
||||||
|
session_working: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TranscriptScreen {
|
||||||
|
/// Append one more folded row at the live end of the transcript --
|
||||||
|
/// what a caller's SSE loop or a sent message calls as new events
|
||||||
|
/// arrive. `LazySpan::push_back` is O(1) and keeps the view pinned to the
|
||||||
|
/// newest content when it already was (I3).
|
||||||
|
pub fn push_row<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, row: &FoldedRow)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
// Capped like any other row (`row::build_row`'s `cap`). A reply
|
||||||
|
// that goes on to *grow* past the cap is never capped, because it
|
||||||
|
// grows through `RowBlocks::apply_delta`, which appends to what is
|
||||||
|
// already drawn -- so the cap only ever catches a row that arrived
|
||||||
|
// long, which is the one nobody is watching arrive.
|
||||||
|
let row::BuiltRow { key, widget, tail } = row::build_row(
|
||||||
|
rsc,
|
||||||
|
self.list,
|
||||||
|
row,
|
||||||
|
self.session_working,
|
||||||
|
true,
|
||||||
|
self.theme.clone(),
|
||||||
|
);
|
||||||
|
(self.list)(rsc).push_back(LazyItem::new(key, widget));
|
||||||
|
self.tail = tail.map(|t| (key, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_working<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, working: bool)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if self.session_working == working {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.session_working = working;
|
||||||
|
if let Some((_, row::TailRow::Tools(tools))) = self.tail.as_mut() {
|
||||||
|
let calls = tools.calls();
|
||||||
|
tools.apply_calls(rsc, &calls, working);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn tail_card_count(&self) -> usize {
|
||||||
|
match self.tail.as_ref() {
|
||||||
|
Some((_, row::TailRow::Tools(tools))) => tools.card_count(),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open or close the newest row's tool run, when it is one -- what a
|
||||||
|
/// caller with no finger needs (`run-headless.sh`'s screenshot on this
|
||||||
|
/// displayless machine, and the tests below). Answers whether there
|
||||||
|
/// was such a row to act on, so a caller that expected one can say so
|
||||||
|
/// rather than silently producing the collapsed picture.
|
||||||
|
pub fn expand_tail_tools<Rsc: HasEvents>(&mut self, rsc: &mut Rsc, expanded: bool) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let Some((_, row::TailRow::Tools(tools))) = self.tail.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
tools.set_group_expanded(rsc, expanded);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `ReplaceLast` fast path: update the tail row in place if this
|
||||||
|
/// really is a change to the same row, and say whether that worked.
|
||||||
|
/// `false` for anything the caller must rebuild instead.
|
||||||
|
fn apply_tail_delta<Rsc: HasEvents>(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
key: RowKey,
|
||||||
|
row: &FoldedRow,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let Some((tail_key, kept)) = self.tail.as_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if *tail_key != key {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (kept, row) {
|
||||||
|
(row::TailRow::Blocks(blocks), FoldedRow::Single(item)) => {
|
||||||
|
let (sender, markdown_src) = row::item_content(item);
|
||||||
|
// A tool call is drawn as a card, never as markdown, so a
|
||||||
|
// row that kept blocks and now holds one is a different
|
||||||
|
// row -- rebuild it.
|
||||||
|
if matches!(item, TranscriptItem::ToolRun { .. }) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
blocks.apply_delta(rsc, sender, &markdown_src)
|
||||||
|
}
|
||||||
|
(row::TailRow::Tools(tools), FoldedRow::Tools(calls)) => {
|
||||||
|
tools.apply_calls(rsc, calls, self.session_working)
|
||||||
|
}
|
||||||
|
(row::TailRow::Tools(tools), FoldedRow::Single(item)) => {
|
||||||
|
tools.apply_calls(rsc, std::slice::from_ref(item), self.session_working)
|
||||||
|
}
|
||||||
|
(row::TailRow::Blocks(_), FoldedRow::Tools(_)) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything else -- a row *before* the tail changed, which only
|
||||||
|
/// happens when `group_tool_runs` regroups already-seen items (a tool
|
||||||
|
/// run's calls that used to be separate rows join once the run closes)
|
||||||
|
/// -- falls back to a full rebuild: every row is dropped
|
||||||
|
/// (`LazySpan::clear`) and rebuilt from `new`. Counted in
|
||||||
|
/// [`Self::take_rebuilds`] so a caller (a report, a test) can see how
|
||||||
|
/// often the fallback actually fires rather than assuming it never
|
||||||
|
/// does.
|
||||||
|
pub fn apply<Rsc: HasEvents>(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
old: &[TranscriptItem],
|
||||||
|
new: &[TranscriptItem],
|
||||||
|
) where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let old_rows = group_tool_runs(old);
|
||||||
|
let new_rows = group_tool_runs(new);
|
||||||
|
|
||||||
|
match diff_rows(&old_rows, &new_rows) {
|
||||||
|
RowDiff::Unchanged => {}
|
||||||
|
RowDiff::Appended { common } => {
|
||||||
|
for row in &new_rows[common..] {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RowDiff::ReplaceLast { common } => {
|
||||||
|
let old_key = row::row_key(&old_rows[common].key());
|
||||||
|
let new_key = row::row_key(&new_rows[common].key());
|
||||||
|
if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) {
|
||||||
|
for row in &new_rows[common + 1..] {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let row::BuiltRow {
|
||||||
|
key: new_key,
|
||||||
|
widget,
|
||||||
|
tail: kept,
|
||||||
|
} = row::build_row(
|
||||||
|
rsc,
|
||||||
|
self.list,
|
||||||
|
&new_rows[common],
|
||||||
|
self.session_working,
|
||||||
|
false,
|
||||||
|
self.theme.clone(),
|
||||||
|
);
|
||||||
|
let evicted = (self.list)(rsc).replace_back(LazyItem::new(new_key, widget));
|
||||||
|
drop(evicted); // frees the old row's widget, same as a pop would
|
||||||
|
self.tail = kept.map(|t| (new_key, t));
|
||||||
|
for row in &new_rows[common + 1..] {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RowDiff::Rebuild => {
|
||||||
|
self.rebuilds += 1;
|
||||||
|
(self.list)(rsc).clear();
|
||||||
|
self.tail = None;
|
||||||
|
for row in &new_rows {
|
||||||
|
self.push_row(rsc, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_rebuilds(&mut self) -> usize {
|
||||||
|
mem::take(&mut self.rebuilds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The semantic paint IDs used by this screen. A caller can replace
|
||||||
|
/// their entries through `rsc.ui_mut().paints.set(...)`; retained text
|
||||||
|
/// and rect primitives keep the IDs and need no widget rebuild.
|
||||||
|
pub fn theme(&self) -> &Theme {
|
||||||
|
&self.theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The concatenated text of whatever is currently selected across one
|
||||||
|
/// or more rows, `None` if nothing is -- what a copy command reads.
|
||||||
|
pub fn selected_text<Rsc: HasEvents>(&self, rsc: &mut Rsc) -> Option<String> {
|
||||||
|
let id = rsc
|
||||||
|
.events()
|
||||||
|
.controllers
|
||||||
|
.id::<SelectionController>(self.list.id())?;
|
||||||
|
rsc.with_controller::<SelectionController, _>(id, |selection, rsc| {
|
||||||
|
selection.selected_text(rsc)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
ui_state: &mut impl HasRoot<Rsc>,
|
||||||
|
rows: Vec<FoldedRow>,
|
||||||
|
) -> TranscriptScreen
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (screen, tree) = build_tree(rsc, rows);
|
||||||
|
ui_state.set_root(rsc, tree);
|
||||||
|
screen
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_tree<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
rows: Vec<FoldedRow>,
|
||||||
|
) -> (TranscriptScreen, StrongWidget)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let theme = Rc::new(Theme::new(&mut rsc.ui_mut().paints));
|
||||||
|
let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc);
|
||||||
|
list.controller(
|
||||||
|
SelectionController::new()
|
||||||
|
.with_scroll(list)
|
||||||
|
.separator("\n\n"),
|
||||||
|
)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
// The last row's block widgets are kept for the same reason
|
||||||
|
// `push_row` keeps them: a reply that is *already* streaming when the
|
||||||
|
// screen is built takes its next delta through `apply`, and a `None`
|
||||||
|
// here would send that delta down the rebuild path instead -- the
|
||||||
|
// whole message re-shaped, which is exactly what the per-block column
|
||||||
|
// exists to avoid, and nothing on screen or in `take_rebuilds` would
|
||||||
|
// say so.
|
||||||
|
let mut tail = None;
|
||||||
|
for (i, row) in rows.iter().enumerate() {
|
||||||
|
// `false`: a row built here is history until the caller says the
|
||||||
|
// session is working (`TranscriptScreen::set_session_working`),
|
||||||
|
// and claiming a call is running because the screen happens to be
|
||||||
|
// opening is exactly the inferred-as-measured mistake.
|
||||||
|
// `cap`: every row but the last. The last is the tail, which may
|
||||||
|
// be a reply already streaming when this screen opened, and a
|
||||||
|
// capped row cannot take a delta (`RowBlocks::capped`).
|
||||||
|
let cap = i + 1 < rows.len();
|
||||||
|
let row::BuiltRow {
|
||||||
|
key,
|
||||||
|
widget,
|
||||||
|
tail: kept,
|
||||||
|
} = row::build_row(rsc, list, row, false, cap, theme.clone());
|
||||||
|
list(rsc).push_back(LazyItem::new(key, widget));
|
||||||
|
tail = kept.map(|t| (key, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The controller host covers gaps as well as text, so a tap anywhere in
|
||||||
|
// the transcript can dismiss a selection. Text and link listeners may
|
||||||
|
// see the same physical sample; `SelectionController` deduplicates it by
|
||||||
|
// the sample's own timestamp while still returning the same tap decision
|
||||||
|
// to whichever leaf owns the link action.
|
||||||
|
{
|
||||||
|
list.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||||
|
let input = &ctx.data;
|
||||||
|
rsc.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
|
||||||
|
selection.drag(id, rsc, input)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.on(CursorSense::Scroll(Axis::Y), |ctx, rsc| {
|
||||||
|
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||||
|
ctx.widget(rsc).scroll(delta);
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let (composer, composer_bar) = composer::build_composer(rsc, &theme);
|
||||||
|
|
||||||
|
let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar)
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
|
||||||
|
(
|
||||||
|
TranscriptScreen {
|
||||||
|
tail,
|
||||||
|
session_working: false,
|
||||||
|
list,
|
||||||
|
composer,
|
||||||
|
rebuilds: 0,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
tree,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What changed at the tail between two folded row lists -- the decision
|
||||||
|
/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no
|
||||||
|
/// widget and no `Rsc`, so the three cases can be tested directly against
|
||||||
|
/// synthetic `Vec<FoldedRow>`s (below) rather than needing a full widget
|
||||||
|
/// harness to exercise logic that never touches one.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum RowDiff {
|
||||||
|
Unchanged,
|
||||||
|
Appended { common: usize },
|
||||||
|
ReplaceLast { common: usize },
|
||||||
|
Rebuild,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff {
|
||||||
|
let common = old
|
||||||
|
.iter()
|
||||||
|
.zip(new.iter())
|
||||||
|
.take_while(|(a, b)| a == b)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if common == old.len() && common == new.len() {
|
||||||
|
RowDiff::Unchanged
|
||||||
|
} else if common == old.len() {
|
||||||
|
RowDiff::Appended { common }
|
||||||
|
} else if !old.is_empty() && common == old.len() - 1 && common < new.len() {
|
||||||
|
RowDiff::ReplaceLast { common }
|
||||||
|
} else {
|
||||||
|
RowDiff::Rebuild
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod diff_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn user(seq: u64, text: &str) -> FoldedRow {
|
||||||
|
FoldedRow::Single(TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow {
|
||||||
|
FoldedRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq,
|
||||||
|
id: format!("id{seq}"),
|
||||||
|
run_id: run_id.to_string(),
|
||||||
|
tool: "grep".to_string(),
|
||||||
|
input: "x".to_string(),
|
||||||
|
output: String::new(),
|
||||||
|
done: false,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_lists_are_unchanged() {
|
||||||
|
let rows = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_list_growing_by_one_is_an_append_from_zero() {
|
||||||
|
let old: Vec<FoldedRow> = Vec::new();
|
||||||
|
let new = vec![user(1, "hi")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_message_after_a_settled_reply_is_a_pure_append() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_into_the_open_reply_is_a_last_row_replace() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", false)];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hel", false)];
|
||||||
|
let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() {
|
||||||
|
let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")];
|
||||||
|
let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shrinking_the_list_is_a_rebuild() {
|
||||||
|
let old = vec![user(1, "hi"), assistant(2, "hello", true)];
|
||||||
|
let new = vec![user(1, "hi")];
|
||||||
|
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod apply_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::text_cap::MESSAGE_LINES;
|
||||||
|
use std::iter;
|
||||||
|
|
||||||
|
struct TestFocus {
|
||||||
|
focus: Option<WeakWidget<TextEdit>>,
|
||||||
|
}
|
||||||
|
impl OpenUrl for TestFocus {
|
||||||
|
fn open_url(&mut self, _url: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusHost for TestFocus {
|
||||||
|
fn recent_click(&mut self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||||
|
self.focus = id;
|
||||||
|
}
|
||||||
|
fn focus_gained(&mut self, _region: Option<PixelRegion>) {}
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
|
self.focus == Some(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestRsc {
|
||||||
|
ui: Ui,
|
||||||
|
events: EventManager<TestRsc>,
|
||||||
|
}
|
||||||
|
impl UiRsc for TestRsc {
|
||||||
|
fn ui(&self) -> &Ui {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut Ui {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
fn on_draw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.draw(active);
|
||||||
|
}
|
||||||
|
fn on_undraw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.undraw(active);
|
||||||
|
}
|
||||||
|
fn on_remove(&mut self, id: WidgetId) {
|
||||||
|
self.events.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl HasState for TestRsc {
|
||||||
|
type State = TestFocus;
|
||||||
|
}
|
||||||
|
impl HasEvents for TestRsc {
|
||||||
|
fn events(&self) -> &EventManager<Self> {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||||
|
&mut self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user(seq: u64, text: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::UserMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assistant(seq: u64, text: &str) -> TranscriptItem {
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(paragraphs: usize, tail: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for i in 0..paragraphs {
|
||||||
|
out.push_str(&format!("Paragraph number {i} of a streamed reply.\n\n"));
|
||||||
|
}
|
||||||
|
out.push_str(tail);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: Ui::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let old_items = vec![assistant(1, &reply(paragraphs, "and the last one is st"))];
|
||||||
|
let new_items = vec![assistant(
|
||||||
|
1,
|
||||||
|
&reply(paragraphs, "and the last one is still going."),
|
||||||
|
)];
|
||||||
|
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
screen.apply(&mut rsc, &old_items, &new_items);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(screen.take_rebuilds(), 0, "the delta path must be taken");
|
||||||
|
let RenderCounters { draws, shapes, .. } = render.take_counters();
|
||||||
|
(draws, shapes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one() {
|
||||||
|
assert!(
|
||||||
|
reply(100, "").len() > 3_000,
|
||||||
|
"the long case must actually be a long message"
|
||||||
|
);
|
||||||
|
let (short_draws, short_shapes) = cost_of_one_delta(1);
|
||||||
|
let (long_draws, long_shapes) = cost_of_one_delta(100);
|
||||||
|
assert_eq!(
|
||||||
|
short_draws, long_draws,
|
||||||
|
"a delta into a 100-paragraph reply redrew {long_draws} widgets against \
|
||||||
|
{short_draws} for a one-paragraph reply -- the earlier blocks are not being kept"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(short_shapes, long_shapes),
|
||||||
|
(1, 1),
|
||||||
|
"a delta shaped {long_shapes} text layouts in a 100-paragraph reply and \
|
||||||
|
{short_shapes} in a one-paragraph one; it must be the last block and nothing else"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(id: &str, output: &str, done: bool) -> TranscriptItem {
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
seq: 1,
|
||||||
|
id: id.to_string(),
|
||||||
|
run_id: "run".to_string(),
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
input: format!(r#"{{"command":"grep -rn {id} ."}}"#),
|
||||||
|
output: output.to_string(),
|
||||||
|
done,
|
||||||
|
failed: false,
|
||||||
|
asks: Vec::new(),
|
||||||
|
images: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_of(count: usize, output: &str, done: bool) -> Vec<TranscriptItem> {
|
||||||
|
(0..count)
|
||||||
|
.map(|i| call(&format!("t{i}"), output, done))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_run(
|
||||||
|
rsc: &mut TestRsc,
|
||||||
|
items: &[TranscriptItem],
|
||||||
|
) -> (TranscriptScreen, StrongWidget, UiRenderState) {
|
||||||
|
let (mut screen, tree) = build_tree(rsc, group_tool_runs(items));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, rsc);
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(rsc, true),
|
||||||
|
"the fixture's only row must be the tool run"
|
||||||
|
);
|
||||||
|
render.update(&tree, rsc);
|
||||||
|
render.take_counters();
|
||||||
|
(screen, tree, render)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shapes_to_open(output: &str) -> u64 {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: Ui::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let items = run_of(3, output, true);
|
||||||
|
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&items));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
screen.expand_tail_tools(&mut rsc, true),
|
||||||
|
"the fixture's only row must be the tool run"
|
||||||
|
);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
render.take_counters().shapes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsed_cards_shape_only_their_summary_lines() {
|
||||||
|
let long: String = iter::repeat_n("a line of tool output\n", 4_000).collect();
|
||||||
|
assert!(long.len() > 80_000, "the long case must actually be long");
|
||||||
|
|
||||||
|
let short_shapes = shapes_to_open("ok\n");
|
||||||
|
let long_shapes = shapes_to_open(&long);
|
||||||
|
assert!(
|
||||||
|
short_shapes > 0,
|
||||||
|
"opening a group must shape something, or this compares two zeroes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
short_shapes, long_shapes,
|
||||||
|
"three collapsed cards shaped {long_shapes} text layouts over 80 kB of output \
|
||||||
|
against {short_shapes} over three bytes -- a collapsed card is laying out \
|
||||||
|
something it does not draw"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shapes_for_message(text: &str) -> u64 {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: Ui::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let items = vec![
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 1,
|
||||||
|
text: text.to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
TranscriptItem::AssistantMsg {
|
||||||
|
seq: 2,
|
||||||
|
text: "ok".to_string(),
|
||||||
|
settled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let (_screen, tree) = build_tree(&mut rsc, group_tool_runs(&items));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
render.take_counters().shapes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_message_is_drawn_only_as_far_as_the_cap() {
|
||||||
|
let paragraphs = |n: usize| "a paragraph of a reply\n\n".repeat(n);
|
||||||
|
let capped = shapes_for_message(¶graphs(MESSAGE_LINES * 4));
|
||||||
|
let bigger = shapes_for_message(¶graphs(MESSAGE_LINES * 40));
|
||||||
|
assert!(
|
||||||
|
capped > 0,
|
||||||
|
"the screen shaped nothing, so this compares zeroes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
capped, bigger,
|
||||||
|
"a message ten times longer cost {bigger} text layouts against {capped} -- the cap \
|
||||||
|
is not bounding what gets laid out",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cost_of_one_result(count: usize) -> u64 {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: Ui::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(count, "", false);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after[0] = call("t0", "the result", true);
|
||||||
|
|
||||||
|
let (mut screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"a result arriving must not rebuild the whole screen"
|
||||||
|
);
|
||||||
|
render.take_counters().draws
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_result_arriving_redraws_one_card_whatever_the_run_holds() {
|
||||||
|
let small = cost_of_one_result(3);
|
||||||
|
let large = cost_of_one_result(12);
|
||||||
|
assert!(
|
||||||
|
small > 0,
|
||||||
|
"a result must redraw *something*, or this compares two zeroes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
small, large,
|
||||||
|
"one result redrew {large} widgets in a twelve-call run against {small} in a \
|
||||||
|
three-call one -- the other cards are being rebuilt with it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_group_opens_and_closes_and_keeps_its_state_across_a_result() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: Ui::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(3, "", false);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after[1] = call("t1", "done", true);
|
||||||
|
|
||||||
|
let (mut screen, tree) = build_tree(&mut rsc, group_tool_runs(&before));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 20000.0));
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
|
||||||
|
assert_eq!(screen.tail_card_count(), 0);
|
||||||
|
assert!(screen.expand_tail_tools(&mut rsc, true));
|
||||||
|
assert_eq!(screen.tail_card_count(), 3);
|
||||||
|
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(screen.take_rebuilds(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
screen.tail_card_count(),
|
||||||
|
3,
|
||||||
|
"the group closed under a result"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(screen.expand_tail_tools(&mut rsc, false));
|
||||||
|
assert_eq!(screen.tail_card_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_joining_an_open_run_appends_one_card() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: Ui::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = run_of(2, "ok", true);
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.push(call("t2", "", false));
|
||||||
|
|
||||||
|
let (mut screen, tree, mut render) = open_run(&mut rsc, &before);
|
||||||
|
assert_eq!(screen.tail_card_count(), 2);
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
render.update(&tree, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"an appended call is not a rebuild"
|
||||||
|
);
|
||||||
|
assert_eq!(screen.tail_card_count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tail_that_stops_being_tool_calls_falls_back_to_a_rebuild() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: Ui::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let before = vec![user(1, "stable"), call("t0", "", false)];
|
||||||
|
let after = vec![user(1, "stable"), user(2, "not a tool call at all")];
|
||||||
|
let (mut screen, _tree) = build_tree(&mut rsc, group_tool_runs(&before));
|
||||||
|
screen.apply(&mut rsc, &before, &after);
|
||||||
|
assert_eq!(
|
||||||
|
screen.take_rebuilds(),
|
||||||
|
0,
|
||||||
|
"this is a ReplaceLast, not a whole-screen rebuild"
|
||||||
|
);
|
||||||
|
assert_eq!(screen.tail_card_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
use crate::client::markdown_blocks::{Block, BlockKind, common_prefix, split_blocks};
|
||||||
|
use crate::client::text_cap::{MESSAGE_BYTES, MESSAGE_LINES, cut, show_all_label};
|
||||||
|
use crate::client::transcript_fold::{
|
||||||
|
ItemKey, QuestionCard, TranscriptItem, TranscriptRow as FoldedRow,
|
||||||
|
};
|
||||||
|
use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block};
|
||||||
|
use crate::ui::tap::{hold_edge, on_tap};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use crate::ui::tool::{ToolRow, build_tool_row};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
collections::hash_map::DefaultHasher,
|
||||||
|
hash::{Hash, Hasher},
|
||||||
|
rc::Rc,
|
||||||
|
slice,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCK_GAP_DP: f32 = 8.0;
|
||||||
|
|
||||||
|
pub const BASE_SIZE: f32 = 16.0;
|
||||||
|
|
||||||
|
/// Maps string run IDs above the sequence-number range used by transcripts.
|
||||||
|
pub fn row_key(key: &ItemKey) -> RowKey {
|
||||||
|
match key {
|
||||||
|
ItemKey::Seq(seq) => *seq,
|
||||||
|
ItemKey::RunId(id) => {
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
id.hash(&mut h);
|
||||||
|
h.finish() | (1 << 63)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sender label shown above a row's text, and the markdown source to
|
||||||
|
/// render below it. `None` for a system-style note that has no sender.
|
||||||
|
pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) {
|
||||||
|
match item {
|
||||||
|
TranscriptItem::UserMsg { text, .. } => (Some("You"), text.clone()),
|
||||||
|
TranscriptItem::AssistantMsg { text, .. } => (Some("Claude"), text.clone()),
|
||||||
|
TranscriptItem::ErrorMsg { message, .. } => (Some("Error"), message.clone()),
|
||||||
|
TranscriptItem::CommandRow { text, .. } => (Some("Command"), format!("`/{text}`")),
|
||||||
|
TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()),
|
||||||
|
TranscriptItem::Note { text, .. } => (None, text.clone()),
|
||||||
|
TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()),
|
||||||
|
// Epoch seconds as-is until the port has a relative-time formatter
|
||||||
|
// (P1); the Compose `LimitRow` draws it as a countdown.
|
||||||
|
TranscriptItem::LimitNote { resets_at, .. } => (
|
||||||
|
None,
|
||||||
|
match resets_at {
|
||||||
|
Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"),
|
||||||
|
None => "_Usage limit reached._".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TranscriptItem::CompactedNote {
|
||||||
|
pre_tokens,
|
||||||
|
post_tokens,
|
||||||
|
..
|
||||||
|
} => (
|
||||||
|
None,
|
||||||
|
match (pre_tokens, post_tokens) {
|
||||||
|
(Some(pre), Some(post)) => format!("_Compacted: {pre} -> {post} tokens._"),
|
||||||
|
_ => "_Compacted._".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TranscriptItem::ImageItem { r#ref, .. } => (None, format!("_[image: {ref}]_")),
|
||||||
|
TranscriptItem::QuestionCard(card) => (Some("Question"), question_markdown(card)),
|
||||||
|
TranscriptItem::ToolRun {
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
..
|
||||||
|
} => (Some(tool.as_str()), tool_call_markdown(tool, input, output)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn question_markdown(card: &QuestionCard) -> String {
|
||||||
|
let mut out = card.prompt.clone();
|
||||||
|
for opt in &card.options {
|
||||||
|
out.push_str(&format!("\n- {}", opt.label));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String {
|
||||||
|
let mut out = format!("**{tool}**\n\n```\n{input}\n```");
|
||||||
|
if !output.is_empty() {
|
||||||
|
out.push_str(&format!("\n\n```\n{output}\n```"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RowBlocks {
|
||||||
|
blocks: Vec<Block>,
|
||||||
|
fields: Vec<WeakWidget<Text>>,
|
||||||
|
links: Vec<LinkTargets>,
|
||||||
|
column: WeakWidget<Span>,
|
||||||
|
sender: Option<String>,
|
||||||
|
/// A capped row must be rebuilt before accepting a delta.
|
||||||
|
capped: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split for display: never empty, so a row with nothing in it yet is
|
||||||
|
/// still one (empty) text widget rather than no widget at all -- an empty
|
||||||
|
/// column reports a zero size and the row would vanish from the list.
|
||||||
|
fn display_blocks(markdown_src: &str) -> Vec<Block> {
|
||||||
|
let blocks = split_blocks(markdown_src);
|
||||||
|
if blocks.is_empty() {
|
||||||
|
vec![Block {
|
||||||
|
kind: BlockKind::Paragraph,
|
||||||
|
source: markdown_src.to_string(),
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caps at a block boundary when possible, or within the first oversized block.
|
||||||
|
fn cap_message(blocks: Vec<Block>, cap: bool) -> (Vec<Block>, Option<usize>) {
|
||||||
|
let total = || blocks.iter().map(|b| b.source.lines().count()).sum();
|
||||||
|
if !cap {
|
||||||
|
return (blocks, None);
|
||||||
|
}
|
||||||
|
let mut kept = Vec::with_capacity(blocks.len());
|
||||||
|
let (mut lines_left, mut bytes_left) = (MESSAGE_LINES, MESSAGE_BYTES);
|
||||||
|
for block in &blocks {
|
||||||
|
if lines_left == 0 || bytes_left == 0 {
|
||||||
|
return (kept, Some(total()));
|
||||||
|
}
|
||||||
|
match cut(&block.source, lines_left, bytes_left) {
|
||||||
|
Some((head, _)) if kept.is_empty() => {
|
||||||
|
kept.push(Block {
|
||||||
|
kind: block.kind,
|
||||||
|
source: head.to_string(),
|
||||||
|
});
|
||||||
|
return (kept, Some(total()));
|
||||||
|
}
|
||||||
|
Some(_) => return (kept, Some(total())),
|
||||||
|
None => {
|
||||||
|
lines_left -= block.source.lines().count().min(lines_left);
|
||||||
|
bytes_left -= block.source.len().min(bytes_left);
|
||||||
|
kept.push(block.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(kept, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared with the "Show all" callback to avoid copying a long message.
|
||||||
|
struct RowSource {
|
||||||
|
sender: Option<String>,
|
||||||
|
markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct LinkTargets(Rc<RefCell<Vec<Link>>>);
|
||||||
|
|
||||||
|
impl LinkTargets {
|
||||||
|
fn new(links: Vec<Link>) -> Self {
|
||||||
|
Self(Rc::new(RefCell::new(links)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace(&self, links: Vec<Link>) {
|
||||||
|
*self.0.borrow_mut() = links;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_at(&self, byte: usize) -> Option<String> {
|
||||||
|
self.0
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|link| link.range.contains(&byte))
|
||||||
|
.map(|link| link.url.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BuiltBlock {
|
||||||
|
field: WeakWidget<Text>,
|
||||||
|
widget: StrongWidget,
|
||||||
|
links: LinkTargets,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRAME_PAD_DP: f32 = 10.0;
|
||||||
|
const QUOTE_BAR_DP: f32 = 3.0;
|
||||||
|
const FRAME_RADIUS_DP: f32 = 8.0;
|
||||||
|
|
||||||
|
fn build_block<Rsc: HasEvents>(rsc: &mut Rsc, block: &Block, theme: &Theme) -> BuiltBlock
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let frame = frame_of(block.kind, theme);
|
||||||
|
let rendered = render_block(block, BASE_SIZE, theme);
|
||||||
|
let links = LinkTargets::new(rendered.links);
|
||||||
|
let verbatim = matches!(frame, BlockFrame::Verbatim { .. });
|
||||||
|
let field = wtext(rendered.text)
|
||||||
|
.spans(rendered.spans)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.wrap(!verbatim)
|
||||||
|
.family(if verbatim {
|
||||||
|
Family::Monospace
|
||||||
|
} else {
|
||||||
|
Family::SansSerif
|
||||||
|
})
|
||||||
|
.size(BASE_SIZE)
|
||||||
|
.color(match frame {
|
||||||
|
BlockFrame::Quote => theme.quote_text.clone(),
|
||||||
|
_ => theme.text.clone(),
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let tap_links = links.clone();
|
||||||
|
field
|
||||||
|
.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||||
|
let (pos, size) = (ctx.data.pos, ctx.data.size);
|
||||||
|
let input = &ctx.data;
|
||||||
|
let outcome = rsc
|
||||||
|
.with_nearest_controller::<SelectionController, _>(field, |id, selection, rsc| {
|
||||||
|
selection.drag(id, rsc, input)
|
||||||
|
})
|
||||||
|
.unwrap_or(SelectionInput::Tapped);
|
||||||
|
// Panning or selecting across a link must not open it.
|
||||||
|
if outcome == SelectionInput::Tapped {
|
||||||
|
let byte = field.selection(rsc).byte_at(pos, size);
|
||||||
|
let url = tap_links.url_at(byte);
|
||||||
|
if let Some(url) = url {
|
||||||
|
log::info!("iris link: opening {url}");
|
||||||
|
<Rsc::State as OpenUrl>::open_url(ctx.state, &url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let framed = match frame {
|
||||||
|
BlockFrame::Plain => field.width(rest(1)).add_strong(rsc).any(),
|
||||||
|
BlockFrame::Verbatim { fill } => field
|
||||||
|
.scrollable(Axis::X, Pin::Start)
|
||||||
|
.pad(dp(FRAME_PAD_DP))
|
||||||
|
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
BlockFrame::Quote => field
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(Padding {
|
||||||
|
left: dp(QUOTE_BAR_DP + FRAME_PAD_DP),
|
||||||
|
..Padding::ZERO
|
||||||
|
})
|
||||||
|
.background(rect(theme.quote_bar.clone()).width(dp(QUOTE_BAR_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
};
|
||||||
|
BuiltBlock {
|
||||||
|
field,
|
||||||
|
widget: framed,
|
||||||
|
links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build_text_row<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
sender: Option<&str>,
|
||||||
|
markdown_src: &str,
|
||||||
|
cap: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (StrongWidget, RowBlocks)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let source = Rc::new(RowSource {
|
||||||
|
sender: sender.map(str::to_string),
|
||||||
|
markdown: markdown_src.to_string(),
|
||||||
|
});
|
||||||
|
let strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let ptr = strong.weak();
|
||||||
|
let (content, blocks) = row_content(rsc, list, key, source, ptr, cap, theme);
|
||||||
|
ptr(rsc).set(content);
|
||||||
|
(strong.any(), blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate from [`build_text_row`] because the tap calls it a second
|
||||||
|
/// time, with `cap` false, and writes the result back into the same
|
||||||
|
/// `WidgetPtr`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn row_content<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
source: Rc<RowSource>,
|
||||||
|
ptr: WeakWidget<WidgetPtr>,
|
||||||
|
cap: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (StrongWidget, RowBlocks)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (blocks, hidden) = cap_message(display_blocks(&source.markdown), cap);
|
||||||
|
let mut column = Span::empty(Dir::DOWN).gap(dp(BLOCK_GAP_DP));
|
||||||
|
let mut fields = Vec::with_capacity(blocks.len());
|
||||||
|
let mut links = Vec::with_capacity(blocks.len());
|
||||||
|
for block in &blocks {
|
||||||
|
let built = build_block(rsc, block, &theme);
|
||||||
|
fields.push(built.field);
|
||||||
|
links.push(built.links);
|
||||||
|
column.push(built.widget);
|
||||||
|
}
|
||||||
|
if let Some(lines) = hidden {
|
||||||
|
column.push(show_all(
|
||||||
|
rsc,
|
||||||
|
list,
|
||||||
|
key,
|
||||||
|
source.clone(),
|
||||||
|
ptr,
|
||||||
|
lines,
|
||||||
|
theme.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let column = column.add(rsc);
|
||||||
|
|
||||||
|
// The parent composition performs the header's single strong registration.
|
||||||
|
let header: WeakWidget = match &source.sender {
|
||||||
|
Some(name) => wtext(name.clone())
|
||||||
|
.size(13.0)
|
||||||
|
.color(theme.secondary_text.clone())
|
||||||
|
.add(rsc),
|
||||||
|
None => Span::empty(Dir::DOWN).add(rsc),
|
||||||
|
};
|
||||||
|
|
||||||
|
let widget = (header, column.width(rest(1)))
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.gap(dp(4))
|
||||||
|
.pad(dp(10))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
(
|
||||||
|
widget,
|
||||||
|
RowBlocks {
|
||||||
|
blocks,
|
||||||
|
fields,
|
||||||
|
links,
|
||||||
|
column,
|
||||||
|
sender: source.sender.clone(),
|
||||||
|
capped: hidden.is_some(),
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds a capped row uncapped; its incremental state is intentionally discarded.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn show_all<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
source: Rc<RowSource>,
|
||||||
|
ptr: WeakWidget<WidgetPtr>,
|
||||||
|
lines: usize,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let label = show_all_label(lines);
|
||||||
|
let more_strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let more = more_strong.weak();
|
||||||
|
let words = wtext(label.clone())
|
||||||
|
.size(13.0)
|
||||||
|
.color(theme.secondary_text.clone())
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.label(label)
|
||||||
|
.add_strong(rsc);
|
||||||
|
more(rsc).set(words);
|
||||||
|
on_tap(rsc, more, list, move |rsc| {
|
||||||
|
hold_edge(rsc, list, key);
|
||||||
|
let (content, _blocks) =
|
||||||
|
row_content(rsc, list, key, source.clone(), ptr, false, theme.clone());
|
||||||
|
let _old = ptr(rsc).replace(content);
|
||||||
|
});
|
||||||
|
more_strong.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RowBlocks {
|
||||||
|
/// Updates only the changed tail blocks, or returns `false` when a rebuild is required.
|
||||||
|
pub fn apply_delta<Rsc: HasEvents>(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
sender: Option<&str>,
|
||||||
|
markdown_src: &str,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if self.sender.as_deref() != sender {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.capped {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let new_blocks = display_blocks(markdown_src);
|
||||||
|
let common = common_prefix(&self.blocks, &new_blocks);
|
||||||
|
// A delta may append or rewrite only the current final block.
|
||||||
|
if new_blocks.len() < self.blocks.len() || common + 1 < self.blocks.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if new_blocks.len() == self.blocks.len()
|
||||||
|
&& common < self.blocks.len()
|
||||||
|
&& new_blocks[common].kind != self.blocks[common].kind
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
debug_assert!(
|
||||||
|
self.fields.len() == self.blocks.len() && self.links.len() == self.blocks.len(),
|
||||||
|
"one field and one link list per block: {} fields, {} links, {} blocks",
|
||||||
|
self.fields.len(),
|
||||||
|
self.links.len(),
|
||||||
|
self.blocks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, block) in new_blocks.iter().enumerate().skip(common) {
|
||||||
|
match (self.fields.get(i), self.links.get(i)) {
|
||||||
|
(Some(field), Some(links)) => {
|
||||||
|
let rendered = render_block(block, BASE_SIZE, &self.theme);
|
||||||
|
field(rsc).set_with_spans(rendered.text, rendered.spans);
|
||||||
|
links.replace(rendered.links);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let built = build_block(rsc, block, &self.theme);
|
||||||
|
self.fields.push(built.field);
|
||||||
|
self.links.push(built.links);
|
||||||
|
if let Some(column) = rsc.ui_mut().widgets.get_mut(&self.column) {
|
||||||
|
column.push(built.widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.blocks = new_blocks;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_single<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
item: &TranscriptItem,
|
||||||
|
cap: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (StrongWidget, RowBlocks)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let (sender, markdown_src) = item_content(item);
|
||||||
|
build_text_row(rsc, list, key, sender, &markdown_src, cap, theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incremental state retained for whichever kind of row is currently last.
|
||||||
|
pub enum TailRow {
|
||||||
|
Blocks(RowBlocks),
|
||||||
|
Tools(ToolRow),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BuiltRow {
|
||||||
|
pub key: RowKey,
|
||||||
|
pub widget: StrongWidget,
|
||||||
|
pub tail: Option<TailRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cap` limits historical rows; a live tail must remain uncapped.
|
||||||
|
pub fn build_row<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
row: &FoldedRow,
|
||||||
|
working: bool,
|
||||||
|
cap: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> BuiltRow
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
// A single tool call still draws as a card, without a redundant group wrapper.
|
||||||
|
let calls = match row {
|
||||||
|
FoldedRow::Single(item @ TranscriptItem::ToolRun { .. }) => Some(slice::from_ref(item)),
|
||||||
|
FoldedRow::Tools(calls) => Some(calls.as_slice()),
|
||||||
|
FoldedRow::Single(_) => None,
|
||||||
|
};
|
||||||
|
if let Some(calls) = calls {
|
||||||
|
let key = row_key(&calls[0].key());
|
||||||
|
let (widget, tools) = build_tool_row(rsc, list, key, calls.to_vec(), working, theme);
|
||||||
|
return BuiltRow {
|
||||||
|
key,
|
||||||
|
widget,
|
||||||
|
tail: Some(TailRow::Tools(tools)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let FoldedRow::Single(item) = row else {
|
||||||
|
unreachable!("every Tools row took the branch above");
|
||||||
|
};
|
||||||
|
let key = row_key(&item.key());
|
||||||
|
let (widget, blocks) = build_single(rsc, list, key, item, cap, theme);
|
||||||
|
BuiltRow {
|
||||||
|
key,
|
||||||
|
widget,
|
||||||
|
tail: Some(TailRow::Blocks(blocks)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn blocks(src: &str) -> Vec<Block> {
|
||||||
|
display_blocks(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_message_inside_the_bounds_is_not_capped() {
|
||||||
|
let (kept, hidden) = cap_message(blocks("hello\n\nthere"), true);
|
||||||
|
assert_eq!(kept.len(), 2);
|
||||||
|
assert_eq!(hidden, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_false_keeps_everything() {
|
||||||
|
let src = "a\n\n".repeat(MESSAGE_LINES * 2);
|
||||||
|
let (kept, hidden) = cap_message(blocks(&src), false);
|
||||||
|
assert_eq!(kept.len(), MESSAGE_LINES * 2);
|
||||||
|
assert_eq!(hidden, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_message_is_cut_on_a_block_boundary() {
|
||||||
|
let src = "a paragraph\n\n".repeat(MESSAGE_LINES * 2);
|
||||||
|
let all = blocks(&src);
|
||||||
|
let (kept, hidden) = cap_message(all.clone(), true);
|
||||||
|
assert!(kept.len() < all.len(), "nothing was left out");
|
||||||
|
assert!(
|
||||||
|
kept.iter().zip(&all).all(|(k, a)| k == a),
|
||||||
|
"a block was truncated where a boundary was available",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hidden,
|
||||||
|
Some(all.iter().map(|b| b.source.lines().count()).sum()),
|
||||||
|
"the offer says the whole message's line count, not the shown part's",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_block_over_the_bound_by_itself_is_truncated() {
|
||||||
|
let src = format!("```\n{}```", "x\n".repeat(MESSAGE_LINES * 2));
|
||||||
|
let all = blocks(&src);
|
||||||
|
assert_eq!(all.len(), 1, "the fixture must be a single block");
|
||||||
|
let (kept, hidden) = cap_message(all.clone(), true);
|
||||||
|
assert_eq!(kept.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
kept[0].kind, all[0].kind,
|
||||||
|
"truncation changed the block's kind"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
kept[0].source.len() < all[0].source.len(),
|
||||||
|
"the one over-long block was drawn whole",
|
||||||
|
);
|
||||||
|
assert!(hidden.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
pub(crate) fn on_tap<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
ptr: WeakWidget<WidgetPtr>,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
f: impl Fn(&mut Rsc) + 'static,
|
||||||
|
) where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
ptr.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
|
||||||
|
let input = &ctx.data;
|
||||||
|
let outcome = rsc
|
||||||
|
.with_nearest_controller::<SelectionController, _>(list, |id, selection, rsc| {
|
||||||
|
selection.drag(id, rsc, input)
|
||||||
|
})
|
||||||
|
.unwrap_or(SelectionInput::Tapped);
|
||||||
|
if outcome == SelectionInput::Tapped {
|
||||||
|
f(rsc);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.add(rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn hold_edge(rsc: &mut impl UiRsc, list: WeakWidget<LazySpan>, key: RowKey) {
|
||||||
|
if let Some((top, _bottom)) = list(rsc).extent(key) {
|
||||||
|
list(rsc).note_tap(top);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
/// The shared phone/desktop paint handles. Replacing their paint-table
|
||||||
|
/// entries changes the theme without rebuilding widgets or primitives.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub text: PaintId,
|
||||||
|
pub code: PaintId,
|
||||||
|
pub link: PaintId,
|
||||||
|
pub marker: PaintId,
|
||||||
|
pub verbatim_surface: PaintId,
|
||||||
|
pub table_surface: PaintId,
|
||||||
|
pub quote_bar: PaintId,
|
||||||
|
pub quote_text: PaintId,
|
||||||
|
pub strikethrough: PaintId,
|
||||||
|
pub card_surface: PaintId,
|
||||||
|
pub group_surface: PaintId,
|
||||||
|
pub muted: PaintId,
|
||||||
|
pub awaiting: PaintId,
|
||||||
|
pub failed: PaintId,
|
||||||
|
pub unknown: PaintId,
|
||||||
|
pub composer_surface: PaintId,
|
||||||
|
pub secondary_text: PaintId,
|
||||||
|
pub syntax_keyword: PaintId,
|
||||||
|
pub syntax_string: PaintId,
|
||||||
|
pub syntax_literal: PaintId,
|
||||||
|
pub syntax_comment: PaintId,
|
||||||
|
pub syntax_metadata: PaintId,
|
||||||
|
pub syntax_punctuation: PaintId,
|
||||||
|
pub syntax_mark: PaintId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn new(paints: &mut Paints) -> Self {
|
||||||
|
Self {
|
||||||
|
text: paints.add(srgb(0xCDD6F4)),
|
||||||
|
code: paints.add(srgb(0xCDD6F4)),
|
||||||
|
link: paints.add(srgb(0x89B4FA)),
|
||||||
|
marker: paints.add(srgb(0xB4BEFE)),
|
||||||
|
verbatim_surface: paints.add(srgb(0x11111B)),
|
||||||
|
table_surface: paints.add(srgb(0x313244)),
|
||||||
|
quote_bar: paints.add(srgb(0x585B70)),
|
||||||
|
quote_text: paints.add(srgb(0xA6ADC8)),
|
||||||
|
strikethrough: paints.add(srgb(0x6C7086)),
|
||||||
|
card_surface: paints.add(srgb(0x313244)),
|
||||||
|
group_surface: paints.add(srgb(0x181825)),
|
||||||
|
muted: paints.add(srgb(0xA6ADC8)),
|
||||||
|
awaiting: paints.add(srgb(0xFAB387)),
|
||||||
|
failed: paints.add(srgb(0xF38BA8)),
|
||||||
|
unknown: paints.add(srgb(0xF9E2AF)),
|
||||||
|
composer_surface: paints.add(Srgba8::rgb(40, 40, 46)),
|
||||||
|
secondary_text: paints.add(Srgba8::rgb(150, 150, 160)),
|
||||||
|
syntax_keyword: paints.add(srgb(0xCBA6F7)),
|
||||||
|
syntax_string: paints.add(srgb(0xA6E3A1)),
|
||||||
|
syntax_literal: paints.add(srgb(0xFAB387)),
|
||||||
|
syntax_comment: paints.add(srgb(0x6C7086)),
|
||||||
|
syntax_metadata: paints.add(srgb(0xF9E2AF)),
|
||||||
|
syntax_punctuation: paints.add(srgb(0xA6ADC8)),
|
||||||
|
syntax_mark: paints.add(srgb(0x89DCEB)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn srgb(hex: u32) -> Srgba8 {
|
||||||
|
Srgba8::rgb(
|
||||||
|
((hex >> 16) & 0xff) as u8,
|
||||||
|
((hex >> 8) & 0xff) as u8,
|
||||||
|
(hex & 0xff) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label};
|
||||||
|
use crate::client::tool_summary::{ToolInput, parse_tool_input};
|
||||||
|
use crate::client::transcript_fold::{ToolState, TranscriptItem};
|
||||||
|
use crate::ui::markdown::highlight_into;
|
||||||
|
use crate::ui::tap::{hold_edge, on_tap};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::{
|
||||||
|
cell::{Cell, RefCell},
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
rc::Rc,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAME_SIZE: f32 = 14.0;
|
||||||
|
const BODY_SIZE: f32 = 12.0;
|
||||||
|
const LABEL_SIZE: f32 = 11.0;
|
||||||
|
|
||||||
|
const CARD_PAD_DP: f32 = 12.0;
|
||||||
|
const CARD_RADIUS_DP: f32 = 12.0;
|
||||||
|
const GAP_DP: f32 = 8.0;
|
||||||
|
const RAW_RADIUS_DP: f32 = 4.0;
|
||||||
|
const RAW_PAD_DP: f32 = 8.0;
|
||||||
|
const GROUP_INSET_DP: f32 = 4.0;
|
||||||
|
|
||||||
|
const MARK_DP: f32 = 9.0;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ToolRowState {
|
||||||
|
group_expanded: bool,
|
||||||
|
open: HashMap<String, bool>,
|
||||||
|
whole: HashMap<(String, Part), bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||||
|
enum Part {
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Shared {
|
||||||
|
calls: RefCell<Vec<TranscriptItem>>,
|
||||||
|
state: RefCell<ToolRowState>,
|
||||||
|
/// One `WidgetPtr` per call, in order -- what makes a result cost one
|
||||||
|
/// card. Empty while the group is collapsed, because a collapsed group
|
||||||
|
/// draws no cards at all. Its path out is [`build_content`], which
|
||||||
|
/// clears it before building whatever replaces them.
|
||||||
|
cards: RefCell<Vec<WeakWidget<WidgetPtr>>>,
|
||||||
|
content: RefCell<Option<WeakWidget<WidgetPtr>>>,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
working: Cell<bool>,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One transcript row's worth of tool calls, kept by the caller for the
|
||||||
|
/// row a result can still land in -- the tool-call counterpart of
|
||||||
|
/// [`crate::ui::row::RowBlocks`], and the reason a `ToolEnd` costs one card
|
||||||
|
/// rather than a row.
|
||||||
|
pub struct ToolRow {
|
||||||
|
shared: Rc<Shared>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text<Rsc>(content: impl Into<String>, size: f32, color: PaintId) -> TextBuilder<Rsc> {
|
||||||
|
wtext(content)
|
||||||
|
.size(size)
|
||||||
|
.color(color)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disclosure<Rsc>(glyph: &'static str, theme: &Theme) -> TextBuilder<Rsc> {
|
||||||
|
text(glyph, MARK_DP, theme.muted.clone()).family(Family::Icons)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, theme: &Theme) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
let field = body
|
||||||
|
.family(Family::Monospace)
|
||||||
|
.size(BODY_SIZE)
|
||||||
|
.wrap(false)
|
||||||
|
.add(rsc);
|
||||||
|
field
|
||||||
|
.scrollable(Axis::X, Pin::Start)
|
||||||
|
.pad(dp(RAW_PAD_DP))
|
||||||
|
.masked_by(rect(theme.verbatim_surface.clone()).radius(dp(RAW_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_word(state: ToolState) -> Option<&'static str> {
|
||||||
|
match state {
|
||||||
|
ToolState::Deciding => Some("your turn"),
|
||||||
|
ToolState::Running => Some("running"),
|
||||||
|
ToolState::Failed => Some("failed"),
|
||||||
|
ToolState::NoResult => Some("no result"),
|
||||||
|
ToolState::Succeeded => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_mark(state: ToolState, theme: &Theme) -> Option<(&'static str, PaintId)> {
|
||||||
|
let color = match state {
|
||||||
|
ToolState::Deciding => theme.awaiting.clone(),
|
||||||
|
ToolState::Running => theme.muted.clone(),
|
||||||
|
ToolState::Failed => theme.failed.clone(),
|
||||||
|
ToolState::NoResult => theme.unknown.clone(),
|
||||||
|
ToolState::Succeeded => return None,
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
state_word(state).expect("non-success state has a label"),
|
||||||
|
color,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a screen reader is given for one card, and what a `ui-trace`
|
||||||
|
/// script taps by: the tool, what the call is for, and how it went when
|
||||||
|
/// that is anything but "fine" -- the same three things the Compose card's
|
||||||
|
/// own text says, in the order it says them.
|
||||||
|
fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String {
|
||||||
|
let mut name = tool.to_string();
|
||||||
|
if let Some(title) = parsed.title() {
|
||||||
|
name.push_str(": ");
|
||||||
|
name.push_str(title);
|
||||||
|
}
|
||||||
|
if let Some(word) = state_word(state) {
|
||||||
|
name.push_str(" (");
|
||||||
|
name.push_str(word);
|
||||||
|
name.push(')');
|
||||||
|
}
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading a group carries, closed or open. Compose's exact wording,
|
||||||
|
/// because it is also the name every `ui-trace` script taps it by.
|
||||||
|
fn group_label(count: usize) -> String {
|
||||||
|
format!("Called {count} tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capped(body: &str, whole: bool) -> (&str, usize, bool) {
|
||||||
|
match cut(body, VERBATIM_LINES, VERBATIM_BYTES) {
|
||||||
|
Some((head, lines)) if !whole => (head, lines, true),
|
||||||
|
Some((_, lines)) => (body, lines, false),
|
||||||
|
None => (body, body.lines().count(), false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool {
|
||||||
|
shared
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.whole
|
||||||
|
.get(&(id.to_string(), part))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A control rather than a note, and it says the count rather than "more",
|
||||||
|
/// because the reader is deciding whether to ask for it: "Show all 4,000
|
||||||
|
/// lines" and "Show all 12 lines" are different decisions and the word
|
||||||
|
/// "more" tells them apart not at all.
|
||||||
|
fn show_all<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
index: usize,
|
||||||
|
id: &str,
|
||||||
|
part: Part,
|
||||||
|
lines: usize,
|
||||||
|
) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let label = show_all_label(lines);
|
||||||
|
let more_strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let more = more_strong.weak();
|
||||||
|
let words = text(label.clone(), LABEL_SIZE, shared.theme.muted.clone())
|
||||||
|
.label(label)
|
||||||
|
.add_strong(rsc);
|
||||||
|
more(rsc).set(words);
|
||||||
|
let shared_for_tap = shared.clone();
|
||||||
|
let key = (id.to_string(), part);
|
||||||
|
on_tap(rsc, more, shared.list, move |rsc| {
|
||||||
|
hold_edge(rsc, shared_for_tap.list, shared_for_tap.key);
|
||||||
|
shared_for_tap
|
||||||
|
.state
|
||||||
|
.borrow_mut()
|
||||||
|
.whole
|
||||||
|
.insert(key.clone(), true);
|
||||||
|
redraw_card(rsc, &shared_for_tap, index);
|
||||||
|
});
|
||||||
|
more_strong.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_block<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
index: usize,
|
||||||
|
id: &str,
|
||||||
|
output: &str,
|
||||||
|
call_state: ToolState,
|
||||||
|
) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if output.is_empty() {
|
||||||
|
let (words, colour) = match call_state {
|
||||||
|
ToolState::Succeeded => ("No output", shared.theme.muted.clone()),
|
||||||
|
ToolState::Failed => ("Failed, with no output", shared.theme.failed.clone()),
|
||||||
|
ToolState::NoResult => ("No result ever arrived", shared.theme.unknown.clone()),
|
||||||
|
ToolState::Running | ToolState::Deciding => {
|
||||||
|
("No output yet", shared.theme.muted.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return text(words, LABEL_SIZE, colour).add_strong(rsc).any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (shown, lines, was_cut) = capped(output, wants_whole(shared, id, Part::Output));
|
||||||
|
let mut column = Span::empty(Dir::DOWN).gap(dp(2));
|
||||||
|
column.push(
|
||||||
|
text("Output", LABEL_SIZE, shared.theme.text.clone())
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone());
|
||||||
|
column.push(raw_block(rsc, body, &shared.theme));
|
||||||
|
if was_cut {
|
||||||
|
column.push(show_all(rsc, shared, index, id, Part::Output, lines));
|
||||||
|
}
|
||||||
|
column.width(rest(1)).add_strong(rsc).any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let call = shared.calls.borrow()[index].clone();
|
||||||
|
let TranscriptItem::ToolRun {
|
||||||
|
id,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
..
|
||||||
|
} = &call
|
||||||
|
else {
|
||||||
|
debug_assert!(false, "a tool row holds only tool calls, not {call:?}");
|
||||||
|
return Span::empty(Dir::DOWN).add_strong(rsc).any();
|
||||||
|
};
|
||||||
|
let parsed = parse_tool_input(tool, input);
|
||||||
|
let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above");
|
||||||
|
let open = shared.state.borrow().open.get(id).copied().unwrap_or(false)
|
||||||
|
|| call_state == ToolState::Deciding;
|
||||||
|
|
||||||
|
let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP));
|
||||||
|
header.push(
|
||||||
|
disclosure(if open { icon::OPEN } else { icon::CLOSED }, &shared.theme)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
header.push(
|
||||||
|
text(tool.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
match (open, parsed.title()) {
|
||||||
|
(true, _) | (false, None) => {
|
||||||
|
header.push(Span::empty(Dir::RIGHT).width(rest(1)).add_strong(rsc).any())
|
||||||
|
}
|
||||||
|
(false, Some(title)) => header.push(
|
||||||
|
text(title.to_string(), BODY_SIZE, shared.theme.muted.clone())
|
||||||
|
.wrap(false)
|
||||||
|
.masked()
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if open && let Some(timeout) = &parsed.timeout {
|
||||||
|
header.push(
|
||||||
|
text(
|
||||||
|
format!("timeout {timeout}"),
|
||||||
|
LABEL_SIZE,
|
||||||
|
shared.theme.muted.clone(),
|
||||||
|
)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some((word, colour)) = state_mark(call_state, &shared.theme) {
|
||||||
|
header.push(text(word, LABEL_SIZE, colour).add_strong(rsc).any());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut column = Span::empty(Dir::DOWN).gap(dp(GAP_DP / 2.0));
|
||||||
|
column.push(header.width(rest(1)).add_strong(rsc).any());
|
||||||
|
if open {
|
||||||
|
if let Some(description) = &parsed.description {
|
||||||
|
column.push(
|
||||||
|
text(description.clone(), BODY_SIZE, shared.theme.muted.clone())
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let whole = wants_whole(shared, id, Part::Input);
|
||||||
|
let mut input_lines = 0usize;
|
||||||
|
let mut input_cut = false;
|
||||||
|
if let Some(subject) = &parsed.subject {
|
||||||
|
let (shown, lines, was_cut) = capped(subject, whole);
|
||||||
|
input_lines += lines;
|
||||||
|
input_cut |= was_cut;
|
||||||
|
let spans = match parsed.language {
|
||||||
|
Some(language) => {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
highlight_into(&mut spans, shown, 0..shown.len(), language, &shared.theme);
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
let body = text(shown.to_string(), BODY_SIZE, shared.theme.text.clone()).spans(spans);
|
||||||
|
column.push(raw_block(rsc, body, &shared.theme));
|
||||||
|
}
|
||||||
|
if !parsed.rest.is_empty() {
|
||||||
|
// Never dropped: a field left out would be claiming the tool
|
||||||
|
// had no other input when it might (`ToolInput.kt`). Capped is
|
||||||
|
// not dropped -- the field is still there, with its size said
|
||||||
|
// out loud.
|
||||||
|
let joined = parsed.rest.join("\n");
|
||||||
|
let (shown, lines, was_cut) = capped(&joined, whole);
|
||||||
|
input_lines += lines;
|
||||||
|
input_cut |= was_cut;
|
||||||
|
let body = text(shown.to_string(), BODY_SIZE, shared.theme.muted.clone());
|
||||||
|
column.push(raw_block(rsc, body, &shared.theme));
|
||||||
|
}
|
||||||
|
if input_cut {
|
||||||
|
column.push(show_all(rsc, shared, index, id, Part::Input, input_lines));
|
||||||
|
}
|
||||||
|
column.push(output_block(rsc, shared, index, id, output, call_state));
|
||||||
|
}
|
||||||
|
|
||||||
|
column
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(card_label(tool, &parsed, call_state))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redraw_card<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>, index: usize)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let Some(ptr) = shared.card_ptr(index) else {
|
||||||
|
debug_assert!(false, "card {index} has no widget to redraw");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let content = build_card(rsc, shared, index);
|
||||||
|
let _old = ptr(rsc).replace(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_card_ptr<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
shared: &Rc<Shared>,
|
||||||
|
index: usize,
|
||||||
|
) -> (StrongWidget, WeakWidget<WidgetPtr>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let ptr = strong.weak();
|
||||||
|
shared.cards.borrow_mut().push(ptr);
|
||||||
|
debug_assert_eq!(
|
||||||
|
shared.cards.borrow().len(),
|
||||||
|
index + 1,
|
||||||
|
"a card's index is its position, and both are the call's"
|
||||||
|
);
|
||||||
|
let content = build_card(rsc, shared, index);
|
||||||
|
ptr(rsc).set(content);
|
||||||
|
let for_tap = shared.clone();
|
||||||
|
on_tap(rsc, ptr, shared.list, move |rsc| {
|
||||||
|
hold_edge(rsc, for_tap.list, for_tap.key);
|
||||||
|
let Some(id) = for_tap.call_id(index) else {
|
||||||
|
debug_assert!(false, "tapped card {index} is no longer in the row");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let was = for_tap
|
||||||
|
.state
|
||||||
|
.borrow()
|
||||||
|
.open
|
||||||
|
.get(&id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
for_tap.state.borrow_mut().open.insert(id, !was);
|
||||||
|
redraw_card(rsc, &for_tap, index);
|
||||||
|
});
|
||||||
|
(strong.any(), ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collapse_bar<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let ptr = strong.weak();
|
||||||
|
let mark = disclosure(icon::COLLAPSE, &shared.theme)
|
||||||
|
.center_text()
|
||||||
|
.width(rest(1))
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
// Anything shown only as a mark still needs a name: this is what
|
||||||
|
// a screen reader reads and what a `ui-trace` script taps.
|
||||||
|
.label("Collapse these tool calls")
|
||||||
|
.add_strong(rsc);
|
||||||
|
ptr(rsc).set(mark);
|
||||||
|
let for_tap = shared.clone();
|
||||||
|
on_tap(rsc, ptr, shared.list, move |rsc| {
|
||||||
|
toggle_group(rsc, &for_tap)
|
||||||
|
});
|
||||||
|
strong.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilt whole when the group opens or closes, because that is a change
|
||||||
|
/// of what the row *is* rather than of one card in it. Everything a single
|
||||||
|
/// card's tap does goes through [`redraw_card`] instead.
|
||||||
|
fn build_content<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>) -> StrongWidget
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
shared.cards.borrow_mut().clear();
|
||||||
|
let count = shared.calls.borrow().len();
|
||||||
|
debug_assert!(count > 0, "a tool row with no calls has nothing to draw");
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
return build_card_ptr(rsc, shared, 0).0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shared.state.borrow().group_expanded {
|
||||||
|
let heading = group_label(count);
|
||||||
|
return text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.width(rest(1))
|
||||||
|
.background(rect(shared.theme.card_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(heading)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let heading = group_label(count);
|
||||||
|
let mut group = Span::empty(Dir::DOWN);
|
||||||
|
group.push(
|
||||||
|
text(heading.clone(), NAME_SIZE, shared.theme.text.clone())
|
||||||
|
.pad(dp(CARD_PAD_DP))
|
||||||
|
.width(rest(1))
|
||||||
|
.label(heading)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any(),
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let mut cards = Span::empty(Dir::DOWN);
|
||||||
|
for index in 0..count {
|
||||||
|
cards.push(build_card_ptr(rsc, shared, index).0);
|
||||||
|
}
|
||||||
|
group.push(cards.pad(dp(GROUP_INSET_DP)).add_strong(rsc).any());
|
||||||
|
}
|
||||||
|
group.push(collapse_bar(rsc, shared));
|
||||||
|
group
|
||||||
|
.width(rest(1))
|
||||||
|
.background(rect(shared.theme.group_surface.clone()).radius(dp(CARD_RADIUS_DP)))
|
||||||
|
.width(rest(1))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_group<Rsc: HasEvents>(rsc: &mut Rsc, shared: &Rc<Shared>)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
hold_edge(rsc, shared.list, shared.key);
|
||||||
|
let was = shared.state.borrow().group_expanded;
|
||||||
|
shared.state.borrow_mut().group_expanded = !was;
|
||||||
|
let content = build_content(rsc, shared);
|
||||||
|
shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shared {
|
||||||
|
fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) {
|
||||||
|
let Some(ptr) = *self.content.borrow() else {
|
||||||
|
debug_assert!(
|
||||||
|
false,
|
||||||
|
"the row's content pointer is set before anything can tap it"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _old = ptr(rsc).replace(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_id(&self, index: usize) -> Option<String> {
|
||||||
|
match self.calls.borrow().get(index) {
|
||||||
|
Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card_ptr(&self, index: usize) -> Option<WeakWidget<WidgetPtr>> {
|
||||||
|
self.cards.borrow().get(index).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `working` is the caller's `session_working` **for this row** -- true
|
||||||
|
/// only for the newest row of a session that is still doing something.
|
||||||
|
/// Every row behind it belongs to a turn that has ended, so a call in one
|
||||||
|
/// with no result never came back rather than still running.
|
||||||
|
pub fn build_tool_row<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
list: WeakWidget<LazySpan>,
|
||||||
|
key: RowKey,
|
||||||
|
calls: Vec<TranscriptItem>,
|
||||||
|
working: bool,
|
||||||
|
theme: Rc<Theme>,
|
||||||
|
) -> (StrongWidget, ToolRow)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let shared = Rc::new(Shared {
|
||||||
|
calls: RefCell::new(calls),
|
||||||
|
state: RefCell::new(ToolRowState::default()),
|
||||||
|
cards: RefCell::new(Vec::new()),
|
||||||
|
content: RefCell::new(None),
|
||||||
|
list,
|
||||||
|
key,
|
||||||
|
working: Cell::new(working),
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
let content_strong = WidgetPtr::new().add_strong(rsc);
|
||||||
|
let content = content_strong.weak();
|
||||||
|
*shared.content.borrow_mut() = Some(content);
|
||||||
|
let inner = build_content(rsc, &shared);
|
||||||
|
content(rsc).set(inner);
|
||||||
|
(content_strong.any(), ToolRow { shared })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolRow {
|
||||||
|
/// The calls this row is currently drawing -- what a caller passes
|
||||||
|
/// back to [`Self::apply_calls`] when something other than the calls
|
||||||
|
/// themselves changed (the session's status).
|
||||||
|
pub fn calls(&self) -> Vec<TranscriptItem> {
|
||||||
|
self.shared.calls.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn card_count(&self) -> usize {
|
||||||
|
self.shared.cards.borrow().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exists because the expanded appearance is otherwise unreachable
|
||||||
|
/// from anything that cannot press the screen -- a headless
|
||||||
|
/// screenshot on this displayless machine, and a test. Same path a tap
|
||||||
|
/// takes, including `LazySpan::note_tap`, so what it produces is what a
|
||||||
|
/// reader would have got.
|
||||||
|
pub fn set_group_expanded<Rsc: HasEvents>(&self, rsc: &mut Rsc, expanded: bool)
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if self.shared.state.borrow().group_expanded != expanded {
|
||||||
|
toggle_group(rsc, &self.shared);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring this row up to date with `calls` **without** rebuilding the
|
||||||
|
/// cards that did not change, and say whether that was possible.
|
||||||
|
/// `false` means the caller must rebuild the row the ordinary way.
|
||||||
|
pub fn apply_calls<Rsc: HasEvents>(
|
||||||
|
&mut self,
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
calls: &[TranscriptItem],
|
||||||
|
working: bool,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
if calls.is_empty()
|
||||||
|
|| !calls
|
||||||
|
.iter()
|
||||||
|
.all(|c| matches!(c, TranscriptItem::ToolRun { .. }))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let old = self.shared.calls.borrow().clone();
|
||||||
|
if calls.len() < old.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (old.len() == 1) != (calls.len() == 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let changed: Vec<usize> = (0..old.len()).filter(|&i| old[i] != calls[i]).collect();
|
||||||
|
self.shared.working.set(working);
|
||||||
|
*self.shared.calls.borrow_mut() = calls.to_vec();
|
||||||
|
let ids: HashSet<String> = calls
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| match c {
|
||||||
|
TranscriptItem::ToolRun { id, .. } => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let mut state = self.shared.state.borrow_mut();
|
||||||
|
state.open.retain(|id, _| ids.contains(id));
|
||||||
|
state.whole.retain(|(id, _), _| ids.contains(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.shared.cards.borrow().is_empty() {
|
||||||
|
if calls.len() != old.len() {
|
||||||
|
let content = build_content(rsc, &self.shared);
|
||||||
|
self.shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
debug_assert_eq!(
|
||||||
|
self.shared.cards.borrow().len(),
|
||||||
|
old.len(),
|
||||||
|
"an open row draws exactly one card per call"
|
||||||
|
);
|
||||||
|
|
||||||
|
for index in changed {
|
||||||
|
redraw_card(rsc, &self.shared, index);
|
||||||
|
}
|
||||||
|
if calls.len() > old.len() {
|
||||||
|
let content = build_content(rsc, &self.shared);
|
||||||
|
self.shared.set_content(rsc, content);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchAction, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use iris::sense::DRAG_SLOP;
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
|
||||||
|
let middle = phone_size().y / 2.0;
|
||||||
|
let list = (screen.list)(&mut h.rsc);
|
||||||
|
let key = list.key_at(middle).expect("a row under the viewport");
|
||||||
|
let (top, _) = list.extent(key).expect("that row has an extent");
|
||||||
|
(key, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
|
||||||
|
(screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the tracked row is still loaded")
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP: f32 = 2.0;
|
||||||
|
const SAMPLES: usize = 3;
|
||||||
|
const CATCH_X: f32 = 540.0;
|
||||||
|
|
||||||
|
fn drag_from(
|
||||||
|
h: &mut Harness,
|
||||||
|
screen: &ai_app::ui::TranscriptScreen,
|
||||||
|
key: RowKey,
|
||||||
|
y0: f32,
|
||||||
|
t0: u64,
|
||||||
|
expect_tracking: bool,
|
||||||
|
) -> u64 {
|
||||||
|
let before = row_top(h, screen, key);
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0);
|
||||||
|
assert_eq!(
|
||||||
|
row_top(h, screen, key),
|
||||||
|
before,
|
||||||
|
"the down itself must not move the content, only stop it"
|
||||||
|
);
|
||||||
|
let mut t = t0;
|
||||||
|
for i in 1..=SAMPLES {
|
||||||
|
let moved = STEP * i as f32;
|
||||||
|
t = t0 + 8 * i as u64;
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t);
|
||||||
|
let travelled = row_top(h, screen, key) - before;
|
||||||
|
if expect_tracking {
|
||||||
|
assert!(
|
||||||
|
(travelled - moved).abs() < 0.5,
|
||||||
|
"sample {i}: the finger has moved {moved}px since the down and the content \
|
||||||
|
{travelled:.1}px -- it is not pinned to the finger"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
travelled.abs() < 0.5,
|
||||||
|
"sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \
|
||||||
|
nothing, but the content moved {travelled:.1}px"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t += 8;
|
||||||
|
h.touch(
|
||||||
|
TouchAction::Up,
|
||||||
|
Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
|
||||||
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||||
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||||
|
h.replay(&flick);
|
||||||
|
|
||||||
|
let catch_at = flick.end_ms() + 150;
|
||||||
|
h.frames_until(
|
||||||
|
flick.end_ms() + PHONE_FRAME_MS,
|
||||||
|
catch_at - PHONE_FRAME_MS,
|
||||||
|
PHONE_FRAME_MS,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(screen.list)(&mut h.rsc).is_scrolling(),
|
||||||
|
"the fling must still be running 150ms in, or this test catches nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (key, _) = tracked_row(&mut h, &screen);
|
||||||
|
drag_from(&mut h, &screen, key, 1200.0, catch_at, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
|
||||||
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||||
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||||
|
h.replay(&flick);
|
||||||
|
let catch_at = flick.end_ms() + 150;
|
||||||
|
h.frames_until(
|
||||||
|
flick.end_ms() + PHONE_FRAME_MS,
|
||||||
|
catch_at - PHONE_FRAME_MS,
|
||||||
|
PHONE_FRAME_MS,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(screen.list)(&mut h.rsc).is_scrolling(),
|
||||||
|
"the fling must still be running 150ms in, or this test catches nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (key, _) = tracked_row(&mut h, &screen);
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at);
|
||||||
|
let stopped_at = row_top(&mut h, &screen, key);
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"a press that stopped a fling and moved nothing must not start another"
|
||||||
|
);
|
||||||
|
h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS);
|
||||||
|
assert!(
|
||||||
|
(row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5,
|
||||||
|
"the content moved after a catch was released without moving"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
h.state.opened_urls,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
"a catch is not a tap: nothing under it may be followed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_small_drag_on_a_settled_list_moves_nothing() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
|
||||||
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||||
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||||
|
h.replay(&flick);
|
||||||
|
let settled = h.frames_until(
|
||||||
|
flick.end_ms() + PHONE_FRAME_MS,
|
||||||
|
flick.end_ms() + 4000,
|
||||||
|
PHONE_FRAME_MS,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!(screen.list)(&mut h.rsc).is_scrolling(),
|
||||||
|
"the fling must have stopped, or this is the same case as the test above"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (key, _) = tracked_row(&mut h, &screen);
|
||||||
|
drag_from(
|
||||||
|
&mut h,
|
||||||
|
&screen,
|
||||||
|
key,
|
||||||
|
1200.0,
|
||||||
|
settled + PHONE_FRAME_MS,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchAction};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
||||||
|
let render_handle = h.rsc.ui.render_state();
|
||||||
|
let render_state = render_handle.get();
|
||||||
|
render_state
|
||||||
|
.active
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|&id| {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_dyn(id)
|
||||||
|
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||||
|
.is_some_and(|s| s.axis() == Axis::X)
|
||||||
|
})
|
||||||
|
.find_map(|id| {
|
||||||
|
let r = render_state.window_region(&id, &h.rsc)?;
|
||||||
|
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_dyn(id)
|
||||||
|
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||||
|
.expect("the fence's scroll area is still drawn")
|
||||||
|
.is_scrolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn amt(h: &Harness, id: WidgetId) -> f32 {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.get_dyn(id)
|
||||||
|
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||||
|
.expect("the fence's scroll area is still drawn")
|
||||||
|
.amt()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||||
|
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||||
|
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
let mut screen = opened.screen;
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
|
||||||
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_000,
|
||||||
|
text: "```\none two three four five six seven eight nine ten eleven twelve \
|
||||||
|
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
|
||||||
|
.to_string(),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
screen.push_row(&mut h.rsc, &fence);
|
||||||
|
(screen.list)(&mut h.rsc).jump_to_end();
|
||||||
|
h.frame(100);
|
||||||
|
h.frame(108);
|
||||||
|
|
||||||
|
let key = ai_app::ui::row::row_key(&fence.key());
|
||||||
|
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the fence row is on screen");
|
||||||
|
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||||
|
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||||
|
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
|
||||||
|
|
||||||
|
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||||
|
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||||
|
|
||||||
|
let at_release = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
at_release > 0.0,
|
||||||
|
"the flick itself must have panned the fence, got {at_release}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut t = 240;
|
||||||
|
while t <= 740 {
|
||||||
|
h.frame(t);
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
let coasted = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
coasted > at_release + 1.0,
|
||||||
|
"the fence stopped dead at the release: {at_release} -> {coasted}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let settled = coasted;
|
||||||
|
while t <= 4_000 {
|
||||||
|
h.frame(t);
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
let after = amt(&h, fence_scroll);
|
||||||
|
assert!(
|
||||||
|
after >= settled,
|
||||||
|
"a fling must not run backwards: {settled} -> {after}"
|
||||||
|
);
|
||||||
|
let last = after;
|
||||||
|
h.frame(t);
|
||||||
|
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
|
||||||
|
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||||
|
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
let mut screen = opened.screen;
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
|
||||||
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_000,
|
||||||
|
text: format!(
|
||||||
|
"```\n{}\n```",
|
||||||
|
(1..=200)
|
||||||
|
.map(|i| format!("word{i}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
screen.push_row(&mut h.rsc, &fence);
|
||||||
|
(screen.list)(&mut h.rsc).jump_to_end();
|
||||||
|
h.frame(100);
|
||||||
|
h.frame(108);
|
||||||
|
|
||||||
|
let key = ai_app::ui::row::row_key(&fence.key());
|
||||||
|
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the fence row is on screen");
|
||||||
|
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||||
|
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||||
|
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||||
|
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||||
|
h.frame(248);
|
||||||
|
assert!(
|
||||||
|
is_scrolling(&h, fence_scroll),
|
||||||
|
"the fence has to still be coasting for this to be the reported case",
|
||||||
|
);
|
||||||
|
|
||||||
|
let probe = box_.top_left.y - 500.0;
|
||||||
|
let row = (screen.list)(&mut h.rsc)
|
||||||
|
.key_at(probe)
|
||||||
|
.expect("a row that far up the screen");
|
||||||
|
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
|
||||||
|
let from = (row_top + row_bottom) / 2.0;
|
||||||
|
|
||||||
|
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
|
||||||
|
let fence_before = amt(&h, fence_scroll);
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
|
||||||
|
let mut t = 264;
|
||||||
|
for i in 1..=8 {
|
||||||
|
h.touch(
|
||||||
|
TouchAction::Move,
|
||||||
|
Vec2::new(540.0, from + 20.0 * i as f32),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
t += 8;
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
list_before,
|
||||||
|
(screen.list)(&mut h.rsc).anchor_position_display(),
|
||||||
|
"the drag was nowhere near the fence, so it belongs to the list",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
amt(&h, fence_scroll) > fence_before,
|
||||||
|
"the fence's fling must carry on through a gesture that was never \
|
||||||
|
its own: {fence_before} -> {}",
|
||||||
|
amt(&h, fence_scroll),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchAction, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(name: &str, text: &str) -> TouchScript {
|
||||||
|
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) {
|
||||||
|
let middle = phone_size().y / 2.0;
|
||||||
|
let list = (screen.list)(&mut h.rsc);
|
||||||
|
let key = list.key_at(middle).expect("a row under the viewport");
|
||||||
|
let (top, _) = list.extent(key).expect("that row has an extent");
|
||||||
|
(key, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 {
|
||||||
|
(screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the tracked row is still loaded")
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_flick_does_not_fling() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let flick = script(
|
||||||
|
"flick-cancelled",
|
||||||
|
include_str!("../touch/flick-cancelled.touch"),
|
||||||
|
);
|
||||||
|
h.replay(&flick);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"a gesture the platform took away must not fling"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (key, settled) = tracked_row(&mut h, &screen);
|
||||||
|
let end = flick.end_ms() + 1_000;
|
||||||
|
let mut t = flick.end_ms();
|
||||||
|
while t <= end {
|
||||||
|
h.frame(t);
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
let now = row_top(&mut h, &screen, key);
|
||||||
|
assert!(
|
||||||
|
(now - settled).abs() < 0.5,
|
||||||
|
"the list kept moving after a cancelled gesture: {settled} -> {now}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0);
|
||||||
|
h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8);
|
||||||
|
|
||||||
|
let (key, before) = tracked_row(&mut h, &screen);
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200);
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250);
|
||||||
|
|
||||||
|
let after = row_top(&mut h, &screen, key);
|
||||||
|
assert!(
|
||||||
|
(after - before).abs() < 0.5,
|
||||||
|
"a tap after a cancelled press panned the list by {}px, the distance between them",
|
||||||
|
after - before
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"and it must not have flung either"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
|
||||||
|
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||||
|
|
||||||
|
let (mut h, mut screen) = opened();
|
||||||
|
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_000,
|
||||||
|
text: "```\none two three four five six seven eight nine ten eleven twelve\n\
|
||||||
|
thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```"
|
||||||
|
.to_string(),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
let para = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||||
|
seq: 9_000_001,
|
||||||
|
text: "A plain paragraph with nothing to tap in it, only words, so that a \
|
||||||
|
press here is a press on ordinary text and nothing else."
|
||||||
|
.to_string(),
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
screen.push_row(&mut h.rsc, &fence);
|
||||||
|
screen.push_row(&mut h.rsc, ¶);
|
||||||
|
(screen.list)(&mut h.rsc).jump_to_end();
|
||||||
|
h.frame(100);
|
||||||
|
h.frame(108);
|
||||||
|
|
||||||
|
let key = ai_app::ui::row::row_key(&fence.key());
|
||||||
|
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(key)
|
||||||
|
.expect("the fence row is on screen");
|
||||||
|
let y = (top + bottom) / 2.0;
|
||||||
|
assert!(
|
||||||
|
y > 0.0 && y < phone_size().y,
|
||||||
|
"the fence row has to be on screen to be pressed: {top}..{bottom}"
|
||||||
|
);
|
||||||
|
|
||||||
|
h.touch(TouchAction::Down, Vec2::new(800.0, y), 200);
|
||||||
|
for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() {
|
||||||
|
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||||
|
}
|
||||||
|
h.touch(TouchAction::Up, Vec2::new(540.0, y), 248);
|
||||||
|
|
||||||
|
let (tracked, before) = tracked_row(&mut h, &screen);
|
||||||
|
|
||||||
|
let para_key = ai_app::ui::row::row_key(¶.key());
|
||||||
|
let (ptop, pbottom) = (screen.list)(&mut h.rsc)
|
||||||
|
.extent(para_key)
|
||||||
|
.expect("the paragraph row is on screen");
|
||||||
|
h.touch(
|
||||||
|
TouchAction::Down,
|
||||||
|
Vec2::new(540.0, (ptop + pbottom) / 2.0),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
h.touch(
|
||||||
|
TouchAction::Up,
|
||||||
|
Vec2::new(540.0, (ptop + pbottom) / 2.0),
|
||||||
|
450,
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = row_top(&mut h, &screen, tracked);
|
||||||
|
assert!(
|
||||||
|
(after - before).abs() < 0.5,
|
||||||
|
"a tap after panning a code fence moved the transcript by {}px",
|
||||||
|
after - before
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchScript};
|
||||||
|
|
||||||
|
struct CaptureLogger {
|
||||||
|
lines: Mutex<Vec<(log::Level, String)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static LOGGER: OnceLock<CaptureLogger> = OnceLock::new();
|
||||||
|
|
||||||
|
impl log::Log for CaptureLogger {
|
||||||
|
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn log(&self, record: &log::Record) {
|
||||||
|
self.lines
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((record.level(), record.args().to_string()));
|
||||||
|
}
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logger() -> &'static CaptureLogger {
|
||||||
|
let logger = LOGGER.get_or_init(|| CaptureLogger {
|
||||||
|
lines: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let _ = log::set_logger(logger);
|
||||||
|
log::set_max_level(log::LevelFilter::Debug);
|
||||||
|
logger
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain(logger: &CaptureLogger) -> Vec<(log::Level, String)> {
|
||||||
|
std::mem::take(&mut *logger.lines.lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tracing_is_silent_off_and_round_trips_the_flick_on() {
|
||||||
|
let logger = logger();
|
||||||
|
|
||||||
|
iris::diagnostics::set_trace(false);
|
||||||
|
drain(logger); // whatever `opened()` itself logged while building
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
drain(logger); // and whatever opening logged
|
||||||
|
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||||
|
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||||
|
h.replay(&flick);
|
||||||
|
let _ = (screen.list)(&mut h.rsc); // touch the screen the same way a real caller would
|
||||||
|
let quiet = drain(logger);
|
||||||
|
let debug_lines: Vec<_> = quiet
|
||||||
|
.iter()
|
||||||
|
.filter(|(level, _)| *level == log::Level::Debug)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
debug_lines.is_empty(),
|
||||||
|
"tracing is off, but the ring would still have held these `debug!` lines: {debug_lines:#?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
iris::diagnostics::set_trace(true);
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
drain(logger);
|
||||||
|
h.replay(&flick);
|
||||||
|
let _ = (screen.list)(&mut h.rsc);
|
||||||
|
let traced = drain(logger);
|
||||||
|
iris::diagnostics::set_trace(false); // leave it off for any test after this one
|
||||||
|
|
||||||
|
let input_lines: Vec<&str> = traced
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, msg)| msg.contains("iris input: action="))
|
||||||
|
.map(|(_, msg)| msg.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
input_lines.len(),
|
||||||
|
flick.samples.len(),
|
||||||
|
"expected one `iris::input` line per replayed sample, got:\n{input_lines:#?}"
|
||||||
|
);
|
||||||
|
let frame_lines: Vec<&str> = traced
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, msg)| msg.starts_with("iris frame:"))
|
||||||
|
.map(|(_, msg)| msg.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!frame_lines.is_empty(),
|
||||||
|
"expected at least one `iris::frame` line once tracing was on"
|
||||||
|
);
|
||||||
|
for line in &frame_lines {
|
||||||
|
assert!(
|
||||||
|
!line.contains("layout=0ns"),
|
||||||
|
"a frame that redrew should not report zero layout time: {line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = input_lines.join("\n");
|
||||||
|
let script_path = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../iris/benches/report_to_touch.py"
|
||||||
|
);
|
||||||
|
let mut child = Command::new("python3")
|
||||||
|
.arg(script_path)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("python3 must be on PATH to run report_to_touch.py");
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.unwrap()
|
||||||
|
.write_all(report.as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let output = child.wait_with_output().expect("report_to_touch.py exited");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"report_to_touch.py failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let touch_text = String::from_utf8(output.stdout).expect("report_to_touch.py wrote UTF-8");
|
||||||
|
let round_tripped =
|
||||||
|
TouchScript::parse(&touch_text).unwrap_or_else(|e| panic!("round-tripped script: {e}"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
round_tripped.samples.len(),
|
||||||
|
flick.samples.len(),
|
||||||
|
"round trip produced a different number of samples:\n{touch_text}"
|
||||||
|
);
|
||||||
|
for (original, back) in flick.samples.iter().zip(round_tripped.samples.iter()) {
|
||||||
|
assert_eq!(original.t_ms, back.t_ms);
|
||||||
|
assert_eq!(original.action, back.action);
|
||||||
|
assert_eq!(original.pos, back.pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::{Harness, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(name: &str, text: &str) -> TouchScript {
|
||||||
|
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> String {
|
||||||
|
(screen.list)(&mut h.rsc).anchor_position_display()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch"));
|
||||||
|
h.replay(&flick);
|
||||||
|
|
||||||
|
let velocity = (screen.list)(&mut h.rsc)
|
||||||
|
.fling_velocity()
|
||||||
|
.expect("the flick must release as a pan with a velocity, not a tap");
|
||||||
|
assert!(
|
||||||
|
(velocity - 15_250.0).abs() < 20.0,
|
||||||
|
"expected ~15250px/s from velocity_reference.py, got {velocity}"
|
||||||
|
);
|
||||||
|
|
||||||
|
const REFERENCE_MS: u64 = 2071;
|
||||||
|
const REFERENCE_PX: f32 = 11057.0;
|
||||||
|
let end = flick.end_ms() + REFERENCE_MS * 2;
|
||||||
|
let mut settled_at = None;
|
||||||
|
let mut t = flick.end_ms();
|
||||||
|
let middle = phone_size().y / 2.0;
|
||||||
|
let mut travelled = 0.0f32;
|
||||||
|
let mut tracked: Option<(RowKey, f32)> = None;
|
||||||
|
while t <= end {
|
||||||
|
h.frame(t);
|
||||||
|
let list = (screen.list)(&mut h.rsc);
|
||||||
|
tracked =
|
||||||
|
match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) {
|
||||||
|
Some((key, was, now)) => {
|
||||||
|
travelled += (now - was).abs();
|
||||||
|
Some((key, now))
|
||||||
|
}
|
||||||
|
None => list
|
||||||
|
.key_at(middle)
|
||||||
|
.and_then(|key| list.extent(key).map(|(top, _)| (key, top))),
|
||||||
|
};
|
||||||
|
if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() {
|
||||||
|
settled_at = Some(t);
|
||||||
|
}
|
||||||
|
t += PHONE_FRAME_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = offset(&mut h, &screen);
|
||||||
|
assert_ne!(
|
||||||
|
before, after,
|
||||||
|
"the fling ticks must have moved the list off where the flick left it"
|
||||||
|
);
|
||||||
|
let settled_at = settled_at.expect("the fling must stop on its own, not run forever");
|
||||||
|
let ran_for = settled_at - flick.end_ms();
|
||||||
|
assert!(
|
||||||
|
ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2,
|
||||||
|
"the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2,
|
||||||
|
"the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
travelled >= REFERENCE_PX * 0.8,
|
||||||
|
"the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tap_on_a_row_moves_nothing() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
h.replay(&script("tap", include_str!("../touch/tap.touch")));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(screen.list)(&mut h.rsc).fling_velocity(),
|
||||||
|
None,
|
||||||
|
"a tap must not fling"
|
||||||
|
);
|
||||||
|
h.frames_until(100, 400, PHONE_FRAME_MS);
|
||||||
|
assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing");
|
||||||
|
assert_eq!(
|
||||||
|
h.state.opened_urls,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
"no link was under this tap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_press_and_drag_selects_text() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let before = offset(&mut h, &screen);
|
||||||
|
|
||||||
|
h.replay(&script(
|
||||||
|
"long-press",
|
||||||
|
include_str!("../touch/long-press.touch"),
|
||||||
|
));
|
||||||
|
|
||||||
|
let selected = screen
|
||||||
|
.selected_text(&mut h.rsc)
|
||||||
|
.expect("a long-press then drag must leave text selected");
|
||||||
|
assert!(
|
||||||
|
!selected.trim().is_empty(),
|
||||||
|
"the selection covered no characters: {selected:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
before,
|
||||||
|
offset(&mut h, &screen),
|
||||||
|
"a selection must not also pan the list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tap_after_selection_deselects_text() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
h.replay(&script(
|
||||||
|
"select-then-tap",
|
||||||
|
"0 down 300 1000\n\
|
||||||
|
520 move 300 1000\n\
|
||||||
|
560 move 700 1000\n\
|
||||||
|
600 move 900 1000\n\
|
||||||
|
640 up 900 1000\n\
|
||||||
|
800 down 540 1000\n\
|
||||||
|
880 up 540 1000",
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
screen.selected_text(&mut h.rsc),
|
||||||
|
None,
|
||||||
|
"an ordinary tap after a selection must dismiss it"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
h.state.opened_urls,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
"the deselecting tap must be consumed rather than activating content"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_composer_sits_above_a_simulated_ime_inset() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let height = h.size().y;
|
||||||
|
let field_bottom = |h: &mut Harness| {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.render_state()
|
||||||
|
.get()
|
||||||
|
.window_region(&screen.composer.field, &h.rsc)
|
||||||
|
.expect("the composer field is on screen")
|
||||||
|
.bot_right
|
||||||
|
.y
|
||||||
|
};
|
||||||
|
|
||||||
|
let closed = field_bottom(&mut h);
|
||||||
|
assert!(
|
||||||
|
closed <= height,
|
||||||
|
"the composer is off the bottom of the window even with no keyboard: {closed} > {height}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let ime = 1000.0;
|
||||||
|
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||||
|
h.frame(PHONE_FRAME_MS * 2);
|
||||||
|
|
||||||
|
let open = field_bottom(&mut h);
|
||||||
|
assert!(
|
||||||
|
open <= height - ime,
|
||||||
|
"the keyboard covers the composer: its bottom is at {open}, the IME starts at {}",
|
||||||
|
height - ime
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(closed - open - ime).abs() < 1.0,
|
||||||
|
"the composer moved {} for a {ime}px inset",
|
||||||
|
closed - open
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_space_in_the_composer_finishes_layout() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
screen.composer.set_bottom_inset(&mut h.rsc, 1000.0);
|
||||||
|
h.frame(PHONE_FRAME_MS * 2);
|
||||||
|
h.state.set_focus(Some(screen.composer.field));
|
||||||
|
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
|
||||||
|
|
||||||
|
for text in ["h", "i", " "] {
|
||||||
|
screen.composer.field.edit(&mut h.rsc).insert(text);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(screen.composer.field.edit(&mut h.rsc).text.text(), "hi ");
|
||||||
|
let region = h
|
||||||
|
.rsc
|
||||||
|
.ui
|
||||||
|
.render_state()
|
||||||
|
.get()
|
||||||
|
.window_region(&screen.composer.field, &h.rsc)
|
||||||
|
.expect("the composer field is drawn");
|
||||||
|
let size = region.bot_right - region.top_left;
|
||||||
|
assert!(
|
||||||
|
size.x > phone_size().x / 2.0,
|
||||||
|
"the field shrink-wrapped to the short message: {region:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
size.y < 30.0 * PHONE_SCALE,
|
||||||
|
"the trailing space wrapped onto a second line: {region:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_newline_leaves_the_caret_inside_the_composers_padding() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let height = h.size().y;
|
||||||
|
let ime = 1000.0;
|
||||||
|
screen.composer.set_bottom_inset(&mut h.rsc, ime);
|
||||||
|
h.frame(PHONE_FRAME_MS * 2);
|
||||||
|
|
||||||
|
h.state.set_focus(Some(screen.composer.field));
|
||||||
|
screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0);
|
||||||
|
for _ in 0..12 {
|
||||||
|
screen.composer.field.edit(&mut h.rsc).insert("a\n");
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
}
|
||||||
|
let render_handle = h.rsc.ui.render_state();
|
||||||
|
let render_state = render_handle.get();
|
||||||
|
let message = render_state
|
||||||
|
.debug(h.rsc.widgets(), "Message")
|
||||||
|
.find(|a| !a.primitives.is_empty())
|
||||||
|
.expect("the composer field is drawn");
|
||||||
|
let caret = render_state.primitive_corners(message.primitives.last().unwrap().slot, &h.rsc);
|
||||||
|
let bar_bottom = height - ime;
|
||||||
|
let padding = 12.0 * PHONE_SCALE;
|
||||||
|
assert!(
|
||||||
|
caret.bot_right.y < bar_bottom - padding / 2.0,
|
||||||
|
"the caret is in the bar's bottom padding: it ends at {}, the bar's edge is {bar_bottom} \
|
||||||
|
and its padding is {padding}px",
|
||||||
|
caret.bot_right.y,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mask = h.rsc.ui.masks[message.mask.idx()];
|
||||||
|
let bar = render_state.primitive_corners(mask.primitive, &h.rsc);
|
||||||
|
let visible_content_top = message
|
||||||
|
.primitives
|
||||||
|
.iter()
|
||||||
|
.map(|p| render_state.primitive_corners(p.slot, &h.rsc))
|
||||||
|
.filter(|r| r.bot_right.y > bar.top_left.y)
|
||||||
|
.map(|r| r.top_left.y)
|
||||||
|
.fold(f32::INFINITY, f32::min);
|
||||||
|
assert!(
|
||||||
|
visible_content_top > bar.top_left.y,
|
||||||
|
"the composer's first visible line is clipped above its bar: content starts at {visible_content_top}, bar starts at {}",
|
||||||
|
bar.top_left.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
use iris::harness::Harness;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
const HEADER_H: f32 = 300.0;
|
||||||
|
const HEADER: Srgba8 = Srgba8::new(28, 28, 34, 255);
|
||||||
|
|
||||||
|
fn opened() -> (Harness, ai_app::ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let (opened, tree) = ai_app::ui::fixture::build_screen(&mut h.rsc).expect("the fixture folds");
|
||||||
|
let content = WidgetPtr::new().add(&mut h.rsc);
|
||||||
|
content(&mut h.rsc).set(tree);
|
||||||
|
let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1)))
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(&mut h.rsc)
|
||||||
|
.any();
|
||||||
|
h.state.set_root(&mut h.rsc, root);
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, opened.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_box(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> PixelRegion {
|
||||||
|
h.rsc
|
||||||
|
.ui
|
||||||
|
.render_state()
|
||||||
|
.get()
|
||||||
|
.window_region(&screen.list.id(), &h.rsc)
|
||||||
|
.expect("the list is on screen")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drawn_rows(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> Vec<(f32, f32)> {
|
||||||
|
let render_handle = h.rsc.ui.render_state();
|
||||||
|
let render_state = render_handle.get();
|
||||||
|
let mut rows: Vec<(f32, f32)> = render_state
|
||||||
|
.active
|
||||||
|
.get(&screen.list.id())
|
||||||
|
.expect("the list is drawn")
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| render_state.window_region(id, &h.rsc))
|
||||||
|
.map(|px| (px.top_left.y, px.bot_right.y))
|
||||||
|
.collect();
|
||||||
|
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scrolled(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, amount: f32, t: u64) -> u64 {
|
||||||
|
(screen.list)(&mut h.rsc).scroll(amount);
|
||||||
|
h.frame(t);
|
||||||
|
t + PHONE_FRAME_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_row_across_the_top_edge_is_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let top = list_box(&h, &screen).top_left.y;
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..60 {
|
||||||
|
t = scrolled(&mut h, &screen, 40.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let first = *rows.first().expect("something is on screen");
|
||||||
|
assert!(
|
||||||
|
first.0 <= top + 0.5,
|
||||||
|
"a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \
|
||||||
|
at {top:.1}",
|
||||||
|
first.0 - top,
|
||||||
|
first.0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
first.1 > top,
|
||||||
|
"the row across the top edge was culled: it ends at {:.1}, above the list's own \
|
||||||
|
{top:.1}",
|
||||||
|
first.1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_list_is_clipped_to_its_own_box() {
|
||||||
|
let (h, screen) = opened();
|
||||||
|
let render_handle = h.rsc.ui.render_state();
|
||||||
|
let render_state = render_handle.get();
|
||||||
|
let active = render_state.active.get(&screen.list.id()).expect("drawn");
|
||||||
|
assert!(
|
||||||
|
active.mask != MaskIdx::NONE,
|
||||||
|
"the transcript's list is drawn with nothing clipping it",
|
||||||
|
);
|
||||||
|
let clip = render_state.mask_region(active.mask, &h.rsc);
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
assert!(
|
||||||
|
clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5,
|
||||||
|
"the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \
|
||||||
|
edge still draws past it",
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = render_state
|
||||||
|
.active
|
||||||
|
.get(&screen.list.id())
|
||||||
|
.expect("the list is drawn")
|
||||||
|
.children
|
||||||
|
.clone();
|
||||||
|
let mut checked = 0;
|
||||||
|
for row in rows {
|
||||||
|
for prim in primitives_under(&h, row) {
|
||||||
|
assert!(
|
||||||
|
mask_chain(&h, prim).contains(&active.mask),
|
||||||
|
"a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \
|
||||||
|
own mask {:?}",
|
||||||
|
mask_chain(&h, prim),
|
||||||
|
active.mask,
|
||||||
|
);
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
checked > 0,
|
||||||
|
"no row primitive was checked, so this test asserted nothing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
|
||||||
|
let render_handle = h.rsc.ui.render_state();
|
||||||
|
let render_state = render_handle.get();
|
||||||
|
let Some(active) = render_state.active.get(&id) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out: Vec<MaskIdx> = active
|
||||||
|
.primitives
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.binding != IMAGE_BINDING)
|
||||||
|
.map(|p| render_state.primitives.instance(p.slot).mask_idx)
|
||||||
|
.collect();
|
||||||
|
for child in &active.children {
|
||||||
|
out.extend(primitives_under(h, *child));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
let mut at = mask;
|
||||||
|
while at != MaskIdx::NONE {
|
||||||
|
assert!(
|
||||||
|
!chain.contains(&at),
|
||||||
|
"the mask chain from {mask:?} loops back to {at:?}",
|
||||||
|
);
|
||||||
|
chain.push(at);
|
||||||
|
at = h.rsc.ui.masks[at.idx()].parent;
|
||||||
|
}
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_that_have_left_the_viewport_are_not_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||||
|
assert!(
|
||||||
|
rows.len() <= 24,
|
||||||
|
"{leg} {step}: {} rows drawn for one 2012px viewport",
|
||||||
|
rows.len(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let inside = |rows: &[(f32, f32)], leg: &str, step: usize| {
|
||||||
|
for &(top, bottom) in rows {
|
||||||
|
assert!(
|
||||||
|
bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5,
|
||||||
|
"{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \
|
||||||
|
{list:?} and was drawn anyway",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for step in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, 400.0, t);
|
||||||
|
bounded(&drawn_rows(&h, &screen), "measuring", step);
|
||||||
|
}
|
||||||
|
for step in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, -400.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
bounded(&rows, "forward", step);
|
||||||
|
inside(&rows, "forward", step);
|
||||||
|
}
|
||||||
|
for step in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, 400.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
bounded(&rows, "back", step);
|
||||||
|
inside(&rows, "back", step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_row_across_the_bottom_edge_is_drawn() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..40 {
|
||||||
|
t = scrolled(&mut h, &screen, 37.0, t);
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let last = *rows.last().expect("something is on screen");
|
||||||
|
assert!(
|
||||||
|
last.1 >= list.bot_right.y - 0.5,
|
||||||
|
"a band of {:.1}px above the composer belongs to no row",
|
||||||
|
list.bot_right.y - last.1,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
last.0 < list.bot_right.y,
|
||||||
|
"the row across the bottom edge was culled: it starts at {:.1}, below the list's own \
|
||||||
|
{:.1}",
|
||||||
|
last.0,
|
||||||
|
list.bot_right.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_past_the_first_row_settles_on_it() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..60 {
|
||||||
|
t = scrolled(&mut h, &screen, 100_000.0, t);
|
||||||
|
}
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let first = *rows.first().expect("the first row is on screen");
|
||||||
|
assert!(
|
||||||
|
(first.0 - list.top_left.y).abs() < 0.5,
|
||||||
|
"the transcript is parked {:.1}px past its own first row, so the top of the list is blank",
|
||||||
|
first.0 - list.top_left.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_past_the_last_row_settles_on_it() {
|
||||||
|
let (mut h, screen) = opened();
|
||||||
|
let list = list_box(&h, &screen);
|
||||||
|
let mut t = PHONE_FRAME_MS * 2;
|
||||||
|
|
||||||
|
for _ in 0..20 {
|
||||||
|
t = scrolled(&mut h, &screen, -100_000.0, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = drawn_rows(&h, &screen);
|
||||||
|
let last = *rows.last().expect("the last row is on screen");
|
||||||
|
assert!(
|
||||||
|
(last.1 - list.bot_right.y).abs() < 0.5,
|
||||||
|
"the transcript is parked {:.1}px past its own last row, so the bottom of the list is \
|
||||||
|
blank",
|
||||||
|
list.bot_right.y - last.1,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# A finger flick the shape Iris's phone delivers one, from
|
||||||
|
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
|
||||||
|
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
|
||||||
|
# as DOWN, one or two MOVEs and UP inside a few frames, with the
|
||||||
|
# intermediate positions batched inside those MOVEs as historical
|
||||||
|
# samples (~4ms apart, the touch digitiser's own rate) rather than
|
||||||
|
# arriving as separate events. Each line here is one such sample, which
|
||||||
|
# is exactly what `IrisViewPeer::on_touch_event` replays through the
|
||||||
|
# sensors one at a time -- so the whole gesture is 20ms and five
|
||||||
|
# samples, and the velocity has to come out of *those*.
|
||||||
|
#
|
||||||
|
# Downward (increasing y) on purpose: the screen opens pinned to the
|
||||||
|
# newest end, so a flick the other way has nothing left to scroll to and
|
||||||
|
# the fling clamps on its first tick -- a pass that would prove nothing.
|
||||||
|
# Coordinates are physical pixels on a 1080x2424 surface.
|
||||||
|
0 down 540 1000
|
||||||
|
4 move 540 1040
|
||||||
|
8 move 540 1086
|
||||||
|
12 move 540 1138
|
||||||
|
16 move 540 1196
|
||||||
|
20 up 540 1196
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# `flick-120hz.touch` to the sample, with the platform taking the gesture
|
||||||
|
# away instead of the finger lifting -- Android's `ACTION_CANCEL`, which
|
||||||
|
# is what the swipe up from the bottom edge to leave the app delivers
|
||||||
|
# after its moves. Nothing may follow from it: no tap, no selection and,
|
||||||
|
# the one that showed on Iris's phone, no fling.
|
||||||
|
0 down 540 1000
|
||||||
|
4 move 540 1040
|
||||||
|
8 move 540 1086
|
||||||
|
12 move 540 1138
|
||||||
|
16 move 540 1196
|
||||||
|
20 cancel 540 1196
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# A long-press then a drag across the text: held past LONG_PRESS
|
||||||
|
# (500ms) without moving, which is what starts a selection rather than a
|
||||||
|
# pan, then dragged sideways so the selection actually covers
|
||||||
|
# something. A press alone leaves a collapsed caret and no selected
|
||||||
|
# text (`Selection::begin`), which is why this file does not stop at the
|
||||||
|
# hold.
|
||||||
|
0 down 300 1000
|
||||||
|
520 move 300 1000
|
||||||
|
560 move 700 1000
|
||||||
|
600 move 900 1000
|
||||||
|
640 up 900 1000
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# The case the flick had no reason to touch: a press and release in one
|
||||||
|
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
|
||||||
|
# tap -- no pan, no velocity, nothing moved.
|
||||||
|
0 down 540 1000
|
||||||
|
80 up 540 1000
|
||||||
@@ -280,7 +280,7 @@ fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
|
|||||||
* One row of `GET /sessions/{id}/subagents`, oldest first.
|
* One row of `GET /sessions/{id}/subagents`, oldest first.
|
||||||
*
|
*
|
||||||
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
|
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
|
||||||
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
|
* this carries only what a card needs to draw and to open it; see docs/SUBAGENTS.md. [status] is
|
||||||
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
|
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
|
||||||
* running, and the list says so rather than reporting a state that cannot hold.
|
* running, and the list says so rather than reporting a state that cannot hold.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ private sealed class Screen {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
|
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
|
||||||
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
|
* docs/SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
|
||||||
* is opened from the session list's card rather than from inside the session it belongs to.
|
* is opened from the session list's card rather than from inside the session it belongs to.
|
||||||
*/
|
*/
|
||||||
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
|
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import android.content.Context
|
|||||||
import java.util.concurrent.CopyOnWriteArrayList
|
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
|
* P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process fake of the
|
||||||
* fake of the backend, so the `bench` build type can drive a real session screen -- the real
|
* 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.
|
* [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
|
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
|
||||||
@@ -24,7 +24,7 @@ object BenchFixture {
|
|||||||
const val FIXTURE_PORT = 1
|
const val FIXTURE_PORT = 1
|
||||||
|
|
||||||
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
|
||||||
private const val BACKLOG_COUNT = 3200
|
private const val BACKLOG_COUNT = 3202
|
||||||
|
|
||||||
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
|
||||||
|
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
|
* The subcard's word for a subagent's status -- see docs/SUBAGENTS.md's "Wire shape". Its own function
|
||||||
* rather than a branch inside [StatusText], because a subagent's three states are not that
|
* rather than a branch inside [StatusText], because a subagent's three states are not that
|
||||||
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
|
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
|
||||||
* and never something of its own to have merely stopped.
|
* and never something of its own to have merely stopped.
|
||||||
|
|||||||
@@ -295,6 +295,12 @@ fun SessionScreen(
|
|||||||
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
||||||
// note opened and scrolled past is still open on the way back.
|
// note opened and scrolled past is still open on the way back.
|
||||||
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
||||||
|
// Which capped blocks the reader has asked to see whole: a tool call's input or output
|
||||||
|
// ([Capped]), and long messages, by the row key that identifies them. Held here rather than in
|
||||||
|
// the card or the row for `openMemories`' reason -- something opened and scrolled past is still
|
||||||
|
// open on the way back, and a lazy list drops the composition of anything off screen.
|
||||||
|
var shownWholeCalls by remember { mutableStateOf(setOf<Capped>()) }
|
||||||
|
var shownWholeRows by remember { mutableStateOf(setOf<Any>()) }
|
||||||
// The image being looked at full screen, by ref. Here rather than in the row that drew the
|
// The image being looked at full screen, by ref. Here rather than in the row that drew the
|
||||||
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
|
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
|
||||||
var fullImage by remember { mutableStateOf<String?>(null) }
|
var fullImage by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -382,12 +388,14 @@ fun SessionScreen(
|
|||||||
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
|
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
|
||||||
var warmedTick by remember { mutableIntStateOf(0) }
|
var warmedTick by remember { mutableIntStateOf(0) }
|
||||||
val units =
|
val units =
|
||||||
remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) }
|
remember(rows, expandedNotes, warmedTick, shownWholeRows) {
|
||||||
|
transcriptUnits(rows, replies, expandedNotes, shownWholeRows)
|
||||||
|
}
|
||||||
// The reply that just finished streaming is the one row whose parses nobody has made: pages
|
// The reply that just finished streaming is the one row whose parses nobody has made: pages
|
||||||
// warm before their fold lands, but nothing warms live deltas. Off the composing thread, then
|
// warm before their fold lands, but nothing warms live deltas. Off the composing thread, then
|
||||||
// the tick re-flattens, so settling never costs a whole-message parse in a frame.
|
// the tick re-flattens, so settling never costs a whole-message parse in a frame.
|
||||||
LaunchedEffect(rows) {
|
LaunchedEffect(rows, shownWholeRows) {
|
||||||
val cold = unwarmedReplies(rows, replies)
|
val cold = unwarmedReplies(rows, replies, shownWholeRows)
|
||||||
if (cold.isNotEmpty()) {
|
if (cold.isNotEmpty()) {
|
||||||
warm(replies, cold)
|
warm(replies, cold)
|
||||||
warmedTick++
|
warmedTick++
|
||||||
@@ -550,6 +558,20 @@ fun SessionScreen(
|
|||||||
toggle()
|
toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [toggleAnchored] for a control that sits *below* the row it grows -- a capped message's "Show
|
||||||
|
* all", which is its own list item under the message it reveals.
|
||||||
|
*
|
||||||
|
* Always the top edge, with no reading of which half was touched: the control is at the bottom
|
||||||
|
* of the row by construction, and the whole point of pressing it is that the text just above it
|
||||||
|
* continues. Held from the bottom instead, the revealed lines would push everything the reader
|
||||||
|
* had been reading up off the screen and leave them at the end of the message.
|
||||||
|
*/
|
||||||
|
fun expandAnchored(key: Any, reveal: () -> Unit) = expanding {
|
||||||
|
topEdgeHeld.key = key
|
||||||
|
reveal()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
|
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
|
||||||
*
|
*
|
||||||
@@ -1486,6 +1508,15 @@ fun SessionScreen(
|
|||||||
) { unit ->
|
) { unit ->
|
||||||
when (unit) {
|
when (unit) {
|
||||||
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
|
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
|
||||||
|
is TranscriptUnit.ShowAll ->
|
||||||
|
ShowAllRow(unit.lines) {
|
||||||
|
// Anchored like every other control that changes a row's
|
||||||
|
// height: the reader is looking at the row this belongs to, and
|
||||||
|
// it is about to get much taller.
|
||||||
|
expandAnchored(unit.row) {
|
||||||
|
shownWholeRows = shownWholeRows + unit.row
|
||||||
|
}
|
||||||
|
}
|
||||||
is TranscriptUnit.PeerHead ->
|
is TranscriptUnit.PeerHead ->
|
||||||
PeerHeadRow(
|
PeerHeadRow(
|
||||||
unit.item,
|
unit.item,
|
||||||
@@ -1576,6 +1607,12 @@ fun SessionScreen(
|
|||||||
::openImage,
|
::openImage,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
isWhole = { it in shownWholeCalls },
|
||||||
|
onShowAll = { capped ->
|
||||||
|
toggleAnchored(row) {
|
||||||
|
shownWholeCalls = shownWholeCalls + capped
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
is TranscriptRow.Single ->
|
is TranscriptRow.Single ->
|
||||||
when (val item = row.item) {
|
when (val item = row.item) {
|
||||||
@@ -1621,6 +1658,16 @@ fun SessionScreen(
|
|||||||
::openImage,
|
::openImage,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
isWhole = { part ->
|
||||||
|
Capped(item.id, part) in shownWholeCalls
|
||||||
|
},
|
||||||
|
onShowAll = { part ->
|
||||||
|
toggleAnchored(row) {
|
||||||
|
shownWholeCalls =
|
||||||
|
shownWholeCalls +
|
||||||
|
Capped(item.id, part)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
is TranscriptItem.QuestionCard ->
|
is TranscriptItem.QuestionCard ->
|
||||||
QuestionRow(item, ::answerAll)
|
QuestionRow(item, ::answerAll)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a long thing the transcript draws before offering the rest behind a tap.
|
||||||
|
*
|
||||||
|
* One rule, four surfaces: a tool call's input, its output, and a user or assistant message. Kept
|
||||||
|
* in one file because four copies would eventually disagree about what "too long" is -- and because
|
||||||
|
* the Rust app answers the same question with the same numbers (`client-core`'s `text_cap.rs`, the
|
||||||
|
* other half of this). The two are deliberately identical so that a benchmark comparing the apps is
|
||||||
|
* comparing renderers rather than policies.
|
||||||
|
*
|
||||||
|
* **Lines and bytes both, whichever runs out first**, because they run out on different things: a
|
||||||
|
* diff is thousands of short lines, a minified file or a base64 blob is one enormous one, and a cap
|
||||||
|
* counting only one of them draws the whole of the other.
|
||||||
|
*
|
||||||
|
* **Cut at the head, keeping the beginning.** A tool's output is read from the top and the line
|
||||||
|
* saying what went wrong is nearly always the first; a message is read from the top for the obvious
|
||||||
|
* reason. (A path is identified by its other end -- none of these is a path.)
|
||||||
|
*/
|
||||||
|
object TextCap {
|
||||||
|
/**
|
||||||
|
* The bound on a verbatim block -- a tool call's input or its output. Short, because this text
|
||||||
|
* is a machine's and the reader is looking for one line of it.
|
||||||
|
*/
|
||||||
|
const val VERBATIM_LINES = 80
|
||||||
|
const val VERBATIM_BYTES = 4096
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bound on a message. Larger than a verbatim block's in bytes and smaller in lines: prose
|
||||||
|
* is read whole and wraps, so a screenful of it is far fewer lines than a screenful of a log,
|
||||||
|
* and cutting a reply at 80 lines would cut most long answers that nobody would call long.
|
||||||
|
*/
|
||||||
|
const val MESSAGE_LINES = 200
|
||||||
|
const val MESSAGE_BYTES = 16 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [text] cut down to a bound, with the line count of the whole of it. See [cutText]. */
|
||||||
|
data class CutText(
|
||||||
|
/** What to draw. */
|
||||||
|
val shown: String,
|
||||||
|
/**
|
||||||
|
* The line count of the **whole** text, not of [shown] -- it is what the "Show all N lines"
|
||||||
|
* offer says, and a reader deciding whether to ask for the rest wants to know how much the rest
|
||||||
|
* is.
|
||||||
|
*/
|
||||||
|
val lines: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [text] cut to [maxLines] lines and [maxBytes] bytes, or `null` when the whole of it fits.
|
||||||
|
*
|
||||||
|
* Bytes rather than characters, so that this and the Rust half cut a multi-byte character at the
|
||||||
|
* same place. UTF-8 is what the wire carries and what `client-core` measures.
|
||||||
|
*/
|
||||||
|
fun cutText(text: String, maxLines: Int, maxBytes: Int): CutText? {
|
||||||
|
require(maxLines > 0 && maxBytes > 0) {
|
||||||
|
"a cap of nothing shows an empty block and a 'Show all' for every value there is"
|
||||||
|
}
|
||||||
|
val bytes = text.toByteArray(Charsets.UTF_8)
|
||||||
|
var byLines = -1
|
||||||
|
var seen = 0
|
||||||
|
for (i in text.indices) {
|
||||||
|
if (text[i] == '\n') {
|
||||||
|
seen++
|
||||||
|
if (seen == maxLines) {
|
||||||
|
byLines = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val byBytes =
|
||||||
|
if (bytes.size > maxBytes) {
|
||||||
|
// Back up to a character boundary. A UTF-8 continuation byte is `10xxxxxx`; cutting on
|
||||||
|
// one would split a character in half and `String(bytes)` would draw a replacement mark
|
||||||
|
// where an em dash was.
|
||||||
|
var end = maxBytes
|
||||||
|
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
|
||||||
|
String(bytes, 0, end, Charsets.UTF_8).length
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
val cut =
|
||||||
|
when {
|
||||||
|
byLines >= 0 && byBytes >= 0 -> minOf(byLines, byBytes)
|
||||||
|
byLines >= 0 -> byLines
|
||||||
|
byBytes >= 0 -> byBytes
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
return CutText(text.take(cut), lineCount(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many lines [text] holds, counted the way Rust's `str::lines` counts them -- a trailing
|
||||||
|
* newline ends the last line rather than starting an empty one.
|
||||||
|
*
|
||||||
|
* Said here rather than left to `lineSequence().count()`, which disagrees on exactly that case: the
|
||||||
|
* two apps have to offer "Show all N lines" with the same N for the same message, and a count that
|
||||||
|
* is one out on every text ending in a newline (which is most tool output) would show it.
|
||||||
|
*/
|
||||||
|
fun lineCount(text: String): Int =
|
||||||
|
when {
|
||||||
|
text.isEmpty() -> 0
|
||||||
|
text.endsWith("\n") -> text.count { it == '\n' }
|
||||||
|
else -> text.count { it == '\n' } + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a "Show all" offer says, so the wording is one string rather than one per surface. */
|
||||||
|
fun showAllLabel(lines: Int): String = "Show all $lines lines"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -9,6 +10,8 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -105,33 +108,112 @@ fun parseToolInput(tool: String, input: String): ToolInput {
|
|||||||
*
|
*
|
||||||
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
|
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
|
||||||
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
|
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
|
||||||
|
*
|
||||||
|
* **Capped.** An `Edit`'s `old_string` and `new_string` arrive here whole and are routinely the
|
||||||
|
* largest text on the screen, so the input is cut to [TextCap.VERBATIM_LINES] /
|
||||||
|
* [TextCap. VERBATIM_BYTES] with a "Show all" under it -- one control for both blocks, because the
|
||||||
|
* subject and the leftover fields are two halves of the same answer to "what was this call given",
|
||||||
|
* and two would make the reader ask twice. [whole] is the reader having already asked.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
fun ToolInputView(
|
||||||
|
tool: String,
|
||||||
|
input: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
whole: Boolean = false,
|
||||||
|
onShowAll: () -> Unit = {},
|
||||||
|
) {
|
||||||
val parsed = remember(tool, input) { parseToolInput(tool, input) }
|
val parsed = remember(tool, input) { parseToolInput(tool, input) }
|
||||||
if (parsed.subject == null && parsed.rest.isEmpty()) return
|
if (parsed.subject == null && parsed.rest.isEmpty()) return
|
||||||
|
val subject = remember(parsed.subject, whole) { capped(parsed.subject, whole) }
|
||||||
|
val rest =
|
||||||
|
remember(parsed.rest, whole) {
|
||||||
|
capped(parsed.rest.takeIf { it.isNotEmpty() }?.joinToString("\n"), whole)
|
||||||
|
}
|
||||||
RawBlock(modifier) {
|
RawBlock(modifier) {
|
||||||
parsed.subject?.let { subject ->
|
subject.shown?.let { shown ->
|
||||||
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
|
||||||
// one being read closely.
|
// one being read closely.
|
||||||
Text(
|
Text(
|
||||||
|
// Highlighted over what is *drawn* rather than over the whole subject, so a cut
|
||||||
|
// cannot leave a span pointing past the end of the text it styles.
|
||||||
|
//
|
||||||
// Not cached: a tool's subject is one command line, which lexes in microseconds --
|
// Not cached: a tool's subject is one command line, which lexes in microseconds --
|
||||||
// the cache exists for a fence with two hundred lines in it.
|
// the cache exists for a fence with two hundred lines in it.
|
||||||
remember(subject, parsed.language) { highlight(subject, parsed.language) },
|
remember(shown, parsed.language) { highlight(shown, parsed.language) },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
parsed.rest.forEach {
|
rest.shown?.let { shown ->
|
||||||
|
// Never dropped: a field left out would be claiming the tool has no other input when it
|
||||||
|
// might. Not wrapped, for the subject's reason -- Iris, 2026-09-08: "for 'raw' text
|
||||||
|
// like
|
||||||
|
// tool results I think it should not be wrapped".
|
||||||
Text(
|
Text(
|
||||||
it,
|
shown,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
softWrap = false,
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(top = 2.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// The count is the whole input's, both blocks together, because that is what the one
|
||||||
|
// control reveals.
|
||||||
|
if (subject.cut || rest.cut) {
|
||||||
|
ShowAllRow(lines = subject.lines + rest.lines, onClick = onShowAll)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One verbatim block as it will be drawn; see [capped]. */
|
||||||
|
data class CappedBlock(
|
||||||
|
/** The text to draw, or `null` when there was none to begin with. */
|
||||||
|
val shown: String?,
|
||||||
|
/** The line count of the whole of it. */
|
||||||
|
val lines: Int,
|
||||||
|
/** Whether anything was left out. */
|
||||||
|
val cut: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [text] as an open card draws it: the whole of it when [whole], or [TextCap]'s worth otherwise.
|
||||||
|
*
|
||||||
|
* `null` in, `null` out, so a caller with nothing to draw reads the same three fields as one with
|
||||||
|
* something.
|
||||||
|
*/
|
||||||
|
private fun capped(text: String?, whole: Boolean): CappedBlock {
|
||||||
|
if (text == null) return CappedBlock(null, 0, false)
|
||||||
|
val cut = if (whole) null else cutText(text, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
|
||||||
|
return when (cut) {
|
||||||
|
null -> CappedBlock(text, lineCount(text), false)
|
||||||
|
else -> CappedBlock(cut.shown, cut.lines, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Show all N lines" under a capped block.
|
||||||
|
*
|
||||||
|
* It says the count rather than "more" because the reader is deciding whether to ask for it: "Show
|
||||||
|
* all 4,000 lines" and "Show all 12 lines" are different decisions, and "more" tells them apart not
|
||||||
|
* at all.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShowAllRow(lines: Int, onClick: () -> Unit) {
|
||||||
|
val label = showAllLabel(lines)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(top = 4.dp).clickable(onClick = onClick).semantics {
|
||||||
|
contentDescription = label
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.example.aiapp
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CornerBasedShape
|
import androidx.compose.foundation.shape.CornerBasedShape
|
||||||
import androidx.compose.foundation.shape.CornerSize
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -163,6 +165,9 @@ fun ToolGroup(
|
|||||||
onToolToggle: (String) -> Unit,
|
onToolToggle: (String) -> Unit,
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
image: @Composable (String) -> Unit,
|
image: @Composable (String) -> Unit,
|
||||||
|
/** Which capped blocks the reader has asked to see whole; see [Capped]. */
|
||||||
|
isWhole: (Capped) -> Boolean = { false },
|
||||||
|
onShowAll: (Capped) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val heading = "Called ${group.calls.size} tools"
|
val heading = "Called ${group.calls.size} tools"
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
@@ -202,6 +207,8 @@ fun ToolGroup(
|
|||||||
onToggle = { onToolToggle(call.id) },
|
onToggle = { onToolToggle(call.id) },
|
||||||
onAnswer = onAnswer,
|
onAnswer = onAnswer,
|
||||||
image = image,
|
image = image,
|
||||||
|
isWhole = { part -> isWhole(Capped(call.id, part)) },
|
||||||
|
onShowAll = { part -> onShowAll(Capped(call.id, part)) },
|
||||||
shape = connectedShape(index, group.calls.size),
|
shape = connectedShape(index, group.calls.size),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -272,6 +279,21 @@ private val GROUP_INSET = 4.dp
|
|||||||
/** Enough to read the join as a join rather than as one tall card. */
|
/** Enough to read the join as a join rather than as one tall card. */
|
||||||
private val GROUP_GAP = 2.dp
|
private val GROUP_GAP = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which half of an open card a cap and its "Show all" belong to.
|
||||||
|
*
|
||||||
|
* The two are capped and revealed independently: opening the whole of a call's input says nothing
|
||||||
|
* about wanting the whole of its output, and one control revealing both would make the card jump by
|
||||||
|
* the sum of two things when it was asked about one.
|
||||||
|
*/
|
||||||
|
enum class ToolPart {
|
||||||
|
INPUT,
|
||||||
|
OUTPUT,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One capped thing on the screen that the reader may ask to see whole. */
|
||||||
|
data class Capped(val call: String, val part: ToolPart)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One tool call.
|
* One tool call.
|
||||||
*
|
*
|
||||||
@@ -291,6 +313,11 @@ fun ToolCard(
|
|||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||||
image: @Composable (String) -> Unit = {},
|
image: @Composable (String) -> Unit = {},
|
||||||
|
/**
|
||||||
|
* Whether the reader has asked for the whole of this call's input or output; see [ToolPart].
|
||||||
|
*/
|
||||||
|
isWhole: (ToolPart) -> Boolean = { false },
|
||||||
|
onShowAll: (ToolPart) -> Unit = {},
|
||||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||||
shape: Shape = CardDefaults.shape,
|
shape: Shape = CardDefaults.shape,
|
||||||
) {
|
) {
|
||||||
@@ -353,11 +380,28 @@ fun ToolCard(
|
|||||||
// something answerable; dumping the same JSON above them would be the decision
|
// something answerable; dumping the same JSON above them would be the decision
|
||||||
// stated twice, once unreadably.
|
// stated twice, once unreadably.
|
||||||
if (tool.tool != ASK_USER_QUESTION) {
|
if (tool.tool != ASK_USER_QUESTION) {
|
||||||
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
ToolInputView(
|
||||||
|
tool.tool,
|
||||||
|
tool.input,
|
||||||
|
Modifier.padding(top = 4.dp),
|
||||||
|
whole = isWhole(ToolPart.INPUT),
|
||||||
|
onShowAll = { onShowAll(ToolPart.INPUT) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (tool.output.isNotEmpty()) {
|
if (tool.output.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Output", style = MaterialTheme.typography.labelSmall)
|
Text("Output", style = MaterialTheme.typography.labelSmall)
|
||||||
|
// Capped like the input, and revealed separately from it: a reader who wants
|
||||||
|
// the whole of a 900-line `new_string` rarely also wants the whole of the build
|
||||||
|
// log underneath it.
|
||||||
|
val wholeOutput = isWhole(ToolPart.OUTPUT)
|
||||||
|
val cut =
|
||||||
|
remember(tool.output, wholeOutput) {
|
||||||
|
if (wholeOutput) null
|
||||||
|
else
|
||||||
|
cutText(tool.output, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
|
||||||
|
}
|
||||||
|
val shown = cut?.shown ?: tool.output
|
||||||
// What the tool printed, on the surface everything verbatim gets and in the
|
// What the tool printed, on the surface everything verbatim gets and in the
|
||||||
// face it was written for: this is column-aligned far more often than it is
|
// face it was written for: this is column-aligned far more often than it is
|
||||||
// prose, and a proportional font silently destroys the alignment that carried
|
// prose, and a proportional font silently destroys the alignment that carried
|
||||||
@@ -367,13 +411,23 @@ fun ToolCard(
|
|||||||
// often the whole of what a diff or a test run is saying. Remembered against
|
// often the whole of what a diff or a test run is saying. Remembered against
|
||||||
// the text, so a card that is open through a scroll parses once.
|
// the text, so a card that is open through a scroll parses once.
|
||||||
val palette = remember { ansiPalette() }
|
val palette = remember { ansiPalette() }
|
||||||
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
|
val styled = remember(shown, palette) { ansiStyled(shown, palette) }
|
||||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||||
|
// Not wrapped, and panning sideways instead -- Iris, 2026-09-08: "for 'raw'
|
||||||
|
// text like tool results I think it should not be wrapped". Wrapping a
|
||||||
|
// column-aligned log is what destroys the alignment that carried its
|
||||||
|
// meaning, one line at a time and only on the long lines.
|
||||||
Text(
|
Text(
|
||||||
styled,
|
styled,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
|
softWrap = false,
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
)
|
)
|
||||||
|
if (cut != null) {
|
||||||
|
ShowAllRow(cut.lines) { onShowAll(ToolPart.OUTPUT) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ package com.example.aiapp
|
|||||||
*
|
*
|
||||||
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
|
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
|
||||||
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
|
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
|
||||||
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
|
* a subagent -- see docs/SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
|
||||||
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
||||||
*/
|
*/
|
||||||
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
||||||
|
|||||||
@@ -130,6 +130,26 @@ sealed class TranscriptUnit {
|
|||||||
get() = "u$seq:$ordinal"
|
get() = "u$seq:$ordinal"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES].
|
||||||
|
*
|
||||||
|
* Its own unit rather than something inside the row above it, because the row above it is a
|
||||||
|
* *bounded* item now and this is what says so -- and because a control that lives inside the
|
||||||
|
* thing it reveals moves the moment it is pressed.
|
||||||
|
*/
|
||||||
|
data class ShowAll(
|
||||||
|
override val seq: Long,
|
||||||
|
override val ordinal: Int,
|
||||||
|
/** The row this belongs to; what goes into the set of rows shown whole. */
|
||||||
|
val row: Any,
|
||||||
|
/** The line count of the whole message, which is what the offer says. */
|
||||||
|
val lines: Int,
|
||||||
|
override val gap: Dp,
|
||||||
|
) : TranscriptUnit() {
|
||||||
|
override val key: Any
|
||||||
|
get() = "s$row"
|
||||||
|
}
|
||||||
|
|
||||||
/** One memory note of a settled reply; see [MemoryNote]. */
|
/** One memory note of a settled reply; see [MemoryNote]. */
|
||||||
data class Memory(
|
data class Memory(
|
||||||
override val seq: Long,
|
override val seq: Long,
|
||||||
@@ -142,6 +162,36 @@ sealed class TranscriptUnit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it.
|
||||||
|
*
|
||||||
|
* The cut happens **before** the flatten below decides how to draw the row, so everything after it
|
||||||
|
* -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The
|
||||||
|
* shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are
|
||||||
|
* untouched by a reader opening or closing one.
|
||||||
|
*
|
||||||
|
* A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two
|
||||||
|
* hundred lines while the model was plainly still writing would read as the stream having died.
|
||||||
|
* `iris`'s `row::build_row` states the same rule for the same reason.
|
||||||
|
*/
|
||||||
|
private fun capRow(row: TranscriptRow, shownWhole: Set<Any>): Pair<TranscriptRow, Int?> {
|
||||||
|
val item = (row as? TranscriptRow.Single)?.item ?: return row to null
|
||||||
|
if (row.key in shownWhole) return row to null
|
||||||
|
val cut =
|
||||||
|
when {
|
||||||
|
item is TranscriptItem.UserMsg ->
|
||||||
|
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
|
||||||
|
it to TranscriptRow.Single(item.copy(text = it.shown))
|
||||||
|
}
|
||||||
|
item is TranscriptItem.AssistantMsg && item.settled ->
|
||||||
|
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
|
||||||
|
it to TranscriptRow.Single(item.copy(text = it.shown))
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
} ?: return row to null
|
||||||
|
return cut.second to cut.first.lines
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
|
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
|
||||||
* screen, which is what a reversed lazy list calls the start.
|
* screen, which is what a reversed lazy list calls the start.
|
||||||
@@ -161,11 +211,14 @@ fun transcriptUnits(
|
|||||||
rows: List<TranscriptRow>,
|
rows: List<TranscriptRow>,
|
||||||
replies: ParsedReplies,
|
replies: ParsedReplies,
|
||||||
openNotes: Set<Long>,
|
openNotes: Set<Long>,
|
||||||
|
shownWhole: Set<Any> = emptySet(),
|
||||||
): List<TranscriptUnit> {
|
): List<TranscriptUnit> {
|
||||||
val started = System.nanoTime()
|
val started = System.nanoTime()
|
||||||
val units = ArrayList<TranscriptUnit>(rows.size)
|
val units = ArrayList<TranscriptUnit>(rows.size)
|
||||||
rows.forEachIndexed { index, row ->
|
rows.forEachIndexed { index, whole ->
|
||||||
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
|
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
|
||||||
|
val (row, hidden) = capRow(whole, shownWhole)
|
||||||
|
val rowStart = units.size
|
||||||
val item = (row as? TranscriptRow.Single)?.item
|
val item = (row as? TranscriptRow.Single)?.item
|
||||||
if (item is TranscriptItem.PeerNote) {
|
if (item is TranscriptItem.PeerNote) {
|
||||||
val open = item.seq in openNotes
|
val open = item.seq in openNotes
|
||||||
@@ -240,6 +293,16 @@ fun transcriptUnits(
|
|||||||
} else {
|
} else {
|
||||||
units += TranscriptUnit.Whole(row, rowGap)
|
units += TranscriptUnit.Whole(row, rowGap)
|
||||||
}
|
}
|
||||||
|
if (hidden != null) {
|
||||||
|
units +=
|
||||||
|
TranscriptUnit.ShowAll(
|
||||||
|
row.startSeq,
|
||||||
|
units.size - rowStart,
|
||||||
|
row.key,
|
||||||
|
hidden,
|
||||||
|
BLOCK_SPACING,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
units.reverse()
|
units.reverse()
|
||||||
reportDuplicateKeys(units)
|
reportDuplicateKeys(units)
|
||||||
@@ -266,11 +329,18 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
|
|||||||
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
|
||||||
* ready parses.
|
* ready parses.
|
||||||
*/
|
*/
|
||||||
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
|
fun unwarmedReplies(
|
||||||
rows.mapIndexedNotNull { index, row ->
|
rows: List<TranscriptRow>,
|
||||||
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
replies: ParsedReplies,
|
||||||
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
shownWhole: Set<Any> = emptySet(),
|
||||||
}
|
): List<TranscriptItem> = rows.mapIndexedNotNull { index, whole ->
|
||||||
|
// The *capped* row's text, since that is what the flatten will draw and so what has to be
|
||||||
|
// ready: a capped row draws its head, which is a different string from the message and so a
|
||||||
|
// different cache entry.
|
||||||
|
val row = capRow(whole, shownWhole).first
|
||||||
|
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
|
||||||
|
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Above this many characters, a user message is drawn in slices rather than as one bubble.
|
* Above this many characters, a user message is drawn in slices rather than as one bubble.
|
||||||
@@ -375,6 +445,7 @@ private val TranscriptUnit?.kind: String
|
|||||||
is TranscriptUnit.PeerBlock -> "peer block"
|
is TranscriptUnit.PeerBlock -> "peer block"
|
||||||
is TranscriptUnit.UserChunk -> "user slice"
|
is TranscriptUnit.UserChunk -> "user slice"
|
||||||
is TranscriptUnit.Memory -> "memory note"
|
is TranscriptUnit.Memory -> "memory note"
|
||||||
|
is TranscriptUnit.ShowAll -> "show all"
|
||||||
is TranscriptUnit.Whole ->
|
is TranscriptUnit.Whole ->
|
||||||
when (val row = row) {
|
when (val row = row) {
|
||||||
is TranscriptRow.Tools -> "tool group"
|
is TranscriptRow.Tools -> "tool group"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cap rule, which this app and the Rust one have to answer identically -- these mirror
|
||||||
|
* `client-core/src/text_cap.rs`'s own tests case for case, because "Show all 4,000 lines" appearing
|
||||||
|
* in one app and "Show all 4,001" in the other is exactly the kind of difference a benchmark
|
||||||
|
* comparing the two would report as a rendering difference.
|
||||||
|
*/
|
||||||
|
class TextCapTest {
|
||||||
|
@Test
|
||||||
|
fun textUnderBothBoundsIsNotCut() {
|
||||||
|
assertNull(cutText("one\ntwo\nthree", 80, 4096))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theLineBoundCutsAtALineBoundary() {
|
||||||
|
val cut = cutText("a\nb\nc\nd\n", 2, 4096)!!
|
||||||
|
assertEquals("a\nb", cut.shown)
|
||||||
|
assertEquals(4, cut.lines, "the count is the whole text's, not the shown part's")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half the line bound cannot catch: one enormous line, which is what a minified file is.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theByteBoundCutsOneLongLine() {
|
||||||
|
val cut = cutText("x".repeat(5000), 80, 4096)!!
|
||||||
|
assertEquals(4096, cut.shown.length)
|
||||||
|
assertEquals(1, cut.lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theTighterOfTheTwoBoundsWins() {
|
||||||
|
val text = "aaaa\n".repeat(100)
|
||||||
|
assertEquals(100, cutText(text, 80, 100)!!.shown.length)
|
||||||
|
assertEquals("aaaa\naaaa\naaaa\naaaa", cutText(text, 4, 4096)!!.shown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cut landing inside a multi-byte character has to back up to the boundary. The Rust half
|
||||||
|
* measures in UTF-8 bytes, so this one does too -- counting UTF-16 characters instead would cut
|
||||||
|
* the same text at a different place in every message with an em dash in it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aCutInsideAMultibyteCharacterBacksUpToTheBoundary() {
|
||||||
|
val cut = cutText("é".repeat(100), 80, 11)!!
|
||||||
|
assertEquals("é".repeat(5), cut.shown, "11 bytes lands mid-character; 10 is the cut")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A trailing newline ends the last line rather than starting an empty one; see [lineCount]. */
|
||||||
|
@Test
|
||||||
|
fun lineCountMatchesRustsStrLines() {
|
||||||
|
assertEquals(0, lineCount(""))
|
||||||
|
assertEquals(1, lineCount("a"))
|
||||||
|
assertEquals(1, lineCount("a\n"))
|
||||||
|
assertEquals(2, lineCount("a\nb"))
|
||||||
|
assertEquals(2, lineCount("a\nb\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,37 @@ script and README, because the Compose `bench` build type points its own asset s
|
|||||||
at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python
|
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:
|
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
|
- `transcript.jsonl` -- 3,603 events. The first 3,202 (`BACKLOG_COUNT`) are the scrolled-back
|
||||||
history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output,
|
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
|
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
|
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
|
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
|
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."
|
fold path a real SSE reply arrives on, which is P0's "streaming phase."
|
||||||
|
|
||||||
|
**The streamed reply has a blank line every few deltas** (2026-09-09), so the markdown block a
|
||||||
|
delta lands in stays the size a real reply's blocks are -- 53 blocks, longest 502 characters,
|
||||||
|
against a measured p50 of 147 and a largest-ever 1,580 over 7,706 blocks of real assistant
|
||||||
|
messages. It used to be one run-on 14,888-character block, and since a row re-shapes the block a
|
||||||
|
delta lands in, every delta re-shaped all of it: quadratic in the reply's length, and 9.5ms of
|
||||||
|
frame time on a phone spent on a shape that does not occur. docs/RUST.md's "Incremental text"
|
||||||
|
has the measurements.
|
||||||
|
|
||||||
|
**The run-on message is kept**, as the first two events of the backlog: 14,824 characters in a
|
||||||
|
single block, just under `text_cap`'s 16 KiB `MESSAGE_BYTES` so it draws in full rather than
|
||||||
|
behind a "Show all". It is deliberately *not* streamed -- the repeated-reshape pathology needs a
|
||||||
|
growing block, and that lives in `app-rust/tests/frame_profile.rs` where it can be iterated on
|
||||||
|
in a second rather than in a two-minute phone run. It is emitted with the random state saved and
|
||||||
|
restored around it, so adding it left every other backlog event byte-identical; that is what
|
||||||
|
keeps `phone_screen.rs`'s recorded gestures landing on the content they were recorded against.
|
||||||
- `bench1.png`, `bench2.png` -- tiny (8x8) flat-colour PNGs, base64-free on disk but served the
|
- `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
|
same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two
|
||||||
`"type":"image"` events in the transcript.
|
`"type":"image"` events in the transcript.
|
||||||
|
|
||||||
|
`BACKLOG_COUNT` lives in three places and moves in all of them or none: here, `app-rust/src/ui/
|
||||||
|
fixture.rs` and `BenchFixture.kt`. The split is by line index, so a stale copy makes that app open
|
||||||
|
a different half of the file.
|
||||||
|
|
||||||
Regenerate after changing the shape (a new event type, a different backlog/stream split) with
|
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
|
`./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.
|
both apps' bench builds embed the identical bytes without needing this script at build time.
|
||||||
+3601
-3599
File diff suppressed because it is too large.
Load diff
@@ -26,8 +26,40 @@ import zlib
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SEED = 20260905
|
SEED = 20260905
|
||||||
BACKLOG_COUNT = 3200
|
# The stress message below, two events, added to the 3,200 the backlog used to be. Both apps
|
||||||
|
# hardcode this to split the file by line index (`fixture.rs`'s `BACKLOG_COUNT`,
|
||||||
|
# `BenchFixture.kt`'s), so it moves in three places or none.
|
||||||
|
STRESS_EVENTS = 2
|
||||||
|
BACKLOG_COUNT = 3200 + STRESS_EVENTS
|
||||||
STREAM_COUNT = 400
|
STREAM_COUNT = 400
|
||||||
|
|
||||||
|
# How many deltas fold into one markdown block of the streamed reply.
|
||||||
|
#
|
||||||
|
# **The blank line between them is the point** (2026-09-09). Until this run the streaming tail
|
||||||
|
# appended `paragraph(5) + " "` STREAM_COUNT times with no blank line anywhere, so all 400 deltas
|
||||||
|
# folded into a *single* 14,888-character block -- and a row re-shapes the block a delta lands in
|
||||||
|
# (`RowBlocks::apply_delta`), so every delta re-shaped the whole thing. That is quadratic in the
|
||||||
|
# reply's length, and it put 9.5ms of frame time on Iris's phone into a shape that does not occur:
|
||||||
|
# measured over 7,706 top-level blocks from 3,675 real assistant messages, block length is p50 147
|
||||||
|
# characters, p90 449, p99 836, largest 1,580, nothing above 4,000. `paragraph(5)` is ~35
|
||||||
|
# characters, so 4-12 of them per block lands in that range.
|
||||||
|
#
|
||||||
|
# The run-on version is kept, as STRESS_CHARS below -- Iris, 2026-09-09: "let's switch to new
|
||||||
|
# lines for the test, and also let's keep the single line around for stress".
|
||||||
|
DELTAS_PER_BLOCK = (4, 12)
|
||||||
|
|
||||||
|
# One deliberately pathological message in the backlog: a single markdown block with no blank line
|
||||||
|
# in it, the shape the streaming tail used to have. Sized just under `text_cap`'s MESSAGE_BYTES
|
||||||
|
# (16 KiB) so it is drawn in full rather than behind a "Show all" -- which makes it a genuine
|
||||||
|
# stress for one-shot shaping and puts a row right on the cap boundary, where nothing else is.
|
||||||
|
#
|
||||||
|
# It is *not* streamed: the repeated-reshape pathology needs a growing block, and that lives in
|
||||||
|
# `app-rust/tests/frame_profile.rs`'s `what_reshaping_a_growing_message_costs`, where it can be
|
||||||
|
# iterated on in a second rather than in a two-minute phone run. Note that the cap would not save
|
||||||
|
# a real one anyway -- `row::build_row`'s `cap` is deliberately `false` for the live tail, because
|
||||||
|
# a row that grew while capped would appear to stop growing, so a streamed block's shaping cost
|
||||||
|
# has no ceiling.
|
||||||
|
STRESS_CHARS = 14_800
|
||||||
HERE = Path(__file__).resolve().parent / "assets"
|
HERE = Path(__file__).resolve().parent / "assets"
|
||||||
|
|
||||||
random.seed(SEED)
|
random.seed(SEED)
|
||||||
@@ -122,6 +154,26 @@ def main():
|
|||||||
emit("status", state="running")
|
emit("status", state="running")
|
||||||
emit("settings", model="bench-model", permissionMode="auto")
|
emit("settings", model="bench-model", permissionMode="auto")
|
||||||
|
|
||||||
|
# The stress message, emitted first and with the random state put back afterwards, so that
|
||||||
|
# adding it is **purely additive**: every turn the loop below generates is byte-identical to
|
||||||
|
# what it generated before this existed, and only the streaming tail changed. That matters
|
||||||
|
# because the fixture's opening view is what `phone_screen.rs`'s recorded gestures press --
|
||||||
|
# `a_long_press_and_drag_selects_text` drives a real recording at (300, 1000) and fails the
|
||||||
|
# moment different content lands under it. `ts` still shifts by the two draws, which nothing
|
||||||
|
# reads.
|
||||||
|
rng_state = random.getstate()
|
||||||
|
emit(
|
||||||
|
"userMessage",
|
||||||
|
text="And the pathological one: a reply with no paragraph break in it.",
|
||||||
|
id=None,
|
||||||
|
attachments=[],
|
||||||
|
)
|
||||||
|
run_on = ""
|
||||||
|
while len(run_on) < STRESS_CHARS:
|
||||||
|
run_on += paragraph(5) + " "
|
||||||
|
emit("assistantText", delta=run_on.rstrip())
|
||||||
|
random.setstate(rng_state)
|
||||||
|
|
||||||
image_refs = []
|
image_refs = []
|
||||||
turn = 0
|
turn = 0
|
||||||
while seq <= BACKLOG_COUNT:
|
while seq <= BACKLOG_COUNT:
|
||||||
@@ -167,11 +219,21 @@ def main():
|
|||||||
trigger="auto",
|
trigger="auto",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The streaming-phase tail: one long reply, built entirely from text deltas, the shape a
|
# The streaming-phase tail: one reply built entirely from text deltas, the shape a bench
|
||||||
# bench harness replays at a fixed events/sec through the live fold path.
|
# harness replays at a fixed events/sec through the live fold path -- with a blank line every
|
||||||
|
# few deltas, so the block a delta lands in stays the size a real reply's blocks are. See
|
||||||
|
# DELTAS_PER_BLOCK for what the alternative measured.
|
||||||
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
emit("userMessage", text="One more, streamed live for the benchmark's timing phase.", id=None, attachments=[])
|
||||||
|
until_break = random.randint(*DELTAS_PER_BLOCK)
|
||||||
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
while seq <= BACKLOG_COUNT + STREAM_COUNT:
|
||||||
emit("assistantText", delta=paragraph(5) + " ")
|
until_break -= 1
|
||||||
|
if until_break <= 0:
|
||||||
|
# The break rides on the last delta of the block rather than being an event of its
|
||||||
|
# own, so STREAM_COUNT still counts deltas a reader sees text arrive from.
|
||||||
|
emit("assistantText", delta=paragraph(5) + "\n\n")
|
||||||
|
until_break = random.randint(*DELTAS_PER_BLOCK)
|
||||||
|
else:
|
||||||
|
emit("assistantText", delta=paragraph(5) + " ")
|
||||||
emit("status", state="idle")
|
emit("status", state="idle")
|
||||||
|
|
||||||
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
|
(HERE / "transcript.jsonl").write_text("\n".join(lines) + "\n")
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
plugins { alias(libs.plugins.androidApplication) }
|
plugins { alias(libs.plugins.androidApplication) }
|
||||||
|
|
||||||
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
|
// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge
|
||||||
// into Rust (`../../android-shell`). Deliberately its own module rather
|
// into Rust (`../../app-rust`, the `shell` feature). Deliberately its own module rather
|
||||||
// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines
|
// 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
|
// of working Compose UI this experiment does not touch, and the two can be
|
||||||
// installed side by side on the same development device (see
|
// installed side by side on the same development device (see
|
||||||
// `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme
|
// `settings.SCHEME`'s doc in `app-rust/src/shell` for why the deep-link scheme
|
||||||
// and Keystore alias are not the production app's). No Compose plugin, no
|
// and Keystore alias are not the production app's). No Compose plugin, no
|
||||||
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
|
// Kotlin source of its own: `MainActivity`/`NotificationService` are plain
|
||||||
// Java, and the CA constant below is generated as Java too.
|
// Java, and the CA constant below is generated as Java too.
|
||||||
@@ -13,7 +13,7 @@ plugins { alias(libs.plugins.androidApplication) }
|
|||||||
// The CA this build pins is baked in the same way `androidApp`'s does --
|
// 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
|
// see that module's `build.gradle.kts` comment for the reasoning (the
|
||||||
// trust boundary follows the machine that builds, never a pasted copy).
|
// trust boundary follows the machine that builds, never a pasted copy).
|
||||||
// `PinnedCa.java`'s package must match `android-shell`'s
|
// `PinnedCa.java`'s package must match `app-rust/src/shell`'s
|
||||||
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
|
// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`).
|
||||||
val pinnedCaPath: String =
|
val pinnedCaPath: String =
|
||||||
System.getenv("AI_APP_CA")
|
System.getenv("AI_APP_CA")
|
||||||
@@ -150,12 +150,12 @@ androidComponents {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
|
// The Keystore-sealed enrollment (ServerStore/ServerSettings) --
|
||||||
// android-shell's settings.rs calls into this Kotlin class directly
|
// the shell bridge's settings.rs calls into this Kotlin class directly
|
||||||
// over JNI rather than re-sealing the token in Rust; see that file's
|
// over JNI rather than re-sealing the token in Rust; see that file's
|
||||||
// module doc.
|
// module doc.
|
||||||
implementation(project(":link"))
|
implementation(project(":link"))
|
||||||
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
|
// NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/
|
||||||
// ServiceCompat -- android-shell's notify.rs calls these classes over
|
// ServiceCompat -- the shell bridge's notify.rs calls these classes over
|
||||||
// JNI so the pre-26 fallback behaviour (no channels) lives once, in
|
// 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
|
// the library that already has it, rather than being re-derived as a
|
||||||
// set of Build.VERSION.SDK_INT branches in Rust.
|
// set of Build.VERSION.SDK_INT branches in Rust.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import android.widget.Toast;
|
|||||||
*/
|
*/
|
||||||
public class MainActivity extends Activity {
|
public class MainActivity extends Activity {
|
||||||
static {
|
static {
|
||||||
System.loadLibrary("android_shell");
|
System.loadLibrary("ai_app");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import android.os.IBinder;
|
|||||||
*/
|
*/
|
||||||
public class NotificationService extends Service {
|
public class NotificationService extends Service {
|
||||||
static {
|
static {
|
||||||
System.loadLibrary("android_shell");
|
System.loadLibrary("ai_app");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+16
-1
@@ -404,12 +404,27 @@ done
|
|||||||
# Percent-encoded because the app URL-decodes the deep link's query: a
|
# Percent-encoded because the app URL-decodes the deep link's query: a
|
||||||
# token with '+' in it enrols as one with a space, and nothing reports it.
|
# token with '+' in it enrols as one with a space, and nothing reports it.
|
||||||
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
||||||
|
# The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of
|
||||||
|
# the DER, which needs no percent-encoding). The Compose app ignores it and
|
||||||
|
# pins the copy its APK was built with; the iris app has no baked copy at
|
||||||
|
# all -- it is cross-compiled and could be pointed at any machine -- so
|
||||||
|
# without this it enrols and then trusts nothing. Minted here rather than by
|
||||||
|
# `--enroll-link` because this token is the sandbox's own, carried across
|
||||||
|
# restarts so the emulator stays enrolled (see the top of this file).
|
||||||
|
ca=$(python3 - "$CERTS/ca.pem" <<'CA'
|
||||||
|
import base64, sys
|
||||||
|
pem = open(sys.argv[1]).read()
|
||||||
|
body = pem.split("-----BEGIN CERTIFICATE-----")[1].split("-----END CERTIFICATE-----")[0]
|
||||||
|
der = base64.b64decode("".join(body.split()))
|
||||||
|
print(base64.urlsafe_b64encode(der).decode().rstrip("="))
|
||||||
|
CA
|
||||||
|
)
|
||||||
cat <<INFO
|
cat <<INFO
|
||||||
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
||||||
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
||||||
|
|
||||||
enrol the emulator (once; it survives sandbox restarts):
|
enrol the emulator (once; it survives sandbox restarts):
|
||||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'"
|
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc&ca=$ca'"
|
||||||
|
|
||||||
drive it:
|
drive it:
|
||||||
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
||||||
|
|||||||
Generated
-940
@@ -1,940 +0,0 @@
|
|||||||
# 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",
|
|
||||||
"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 = "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 = "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 = "unicode-ident"
|
|
||||||
version = "1.0.24"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
||||||
|
|
||||||
[[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"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
[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"] }
|
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
|
||||||
# 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"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
//! 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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user