Steps 2 and 3 of the plan in docs/IRIS_TODO.md, together because
deleting the fling before `Scroll` could drive it would leave the app
unable to scroll at all. IRIS.md has the account and the measurements.
`LazySpan` loses its `Flinger`, its `density`, its
`Arc<dyn RequestRedraw>` -- which had no business existing in a
single-threaded frame loop -- its `tick`, and the whole
`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity`
surface. `Scroll` was the only other `Flinger` user, so there is now one
implementation of the physics rather than two, and a transcript is
`list.scrollable_to_end()` like anything else.
Three new `Widget` methods carry the handoff:
fn scrolls_itself(&self) -> bool { false }
fn apply_scroll(&mut self, delta: &mut f32) {}
fn scroll_offset(&self) -> f32 { 0.0 }
`Scroll` asks the first, and a child that says yes is handed deltas
instead of being slid about as a lump -- which a lazy layout cannot be,
since which rows exist at all is a function of where it is scrolled to,
and it has no content length to be clamped against. `scrolls_itself` is
`&self` deliberately: `Widgets::get_dyn_mut` marks a widget dirty, so
asking through `apply_scroll` would dirty every ordinary child on every
tick and cost exactly the O(1) move the scheme exists for.
`Scroll::draw` is measure, apply, place -- the idiom it already used for
its own content length. The measuring draw is free in the common case
(unchanged region, nothing dirty, `draw_inner` returns immediately and
the child's stored walls are still correct) and really walks exactly
when the content changed. Nothing is marked by hand: reaching the child
to hand it the delta is what dirties it, which is why `draw_again` could
stay deleted.
`scroll_offset` was not in the plan and is needed. A lazy span usually
cannot say where its content ends until it has walked there, so it takes
a delta in full whenever the wall is not already in view and the walk
gives part of it back; the remainder is exact only when the wall was
already visible, and `Scroll` adding remainders up would over-count by
every overshoot and never correct. It reads the child's accumulated
movement after the placing draw instead, so `amt` equals what is on
screen. `amt_counts_only_what_the_child_could_take` is the test.
One convention for a scroll delta, the finger's. `Scroll::scroll(+)`
moved toward the start while `LazySpan::scroll(+)` moved toward the end,
with the latter's doc claiming to mirror the former -- so every call site
had to know which it was talking to. `LazySpan::scroll` is private now
and the single negation is inside its `apply_scroll`; call sites that
passed `-dy`/`-v` pass them through, and `phone_screen.rs`'s recorded
velocity flips sign with its magnitude unchanged.
`a_negative_delta_moves_toward_the_end` pins the sign across the whole
handoff, since nothing else can catch a list scrolling backwards.
The transcript builds its `Scroll` by hand rather than through
`.scrollable_to_end()`: that helper registers a finger drag, and
`Selection` is already the arbiter for those frames -- two `DragGesture`s
seeing one gesture is what its own doc rules out. Caught by
`a_long_press_and_drag_selects_text`, which failed when both were live.
Deferred, in DECISIONS.md and IRIS_TODO.md: the *pin* is still each
widget's own. Applying one happens when a row is appended, between
frames with no painter in hand, so moving it to `Scroll` needs a fourth
`Widget` method or a parameter on `apply_scroll`; nothing external edits
a pin today.
Verified: cargo fmt --check, clippy --workspace --all-targets clean,
cargo test --workspace green (21 suites), the arm64 release APK builds,
and the phone-shaped headless window replaying flick-120hz.touch scrolls
back through the transcript in the direction it did before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
819 lines
50 KiB
Markdown
819 lines
50 KiB
Markdown
# Decisions taken for Iris to review
|
|
|
|
Short list of design choices made by the design agent without asking, so
|
|
they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md
|
|
for iris API changes); this file is only the summary. Newest first. Items
|
|
marked **DEFERRED** are ones the agent chose not to decide alone.
|
|
|
|
## 2026-09-08 (last: scrolling moves out of the list)
|
|
|
|
Agreed with Iris in the exchange that followed, so most of this is her
|
|
call rather than mine. IRIS.md has the account. What I decided along the
|
|
way, and would flag for reversal:
|
|
|
|
- **A third `Widget` method, `scroll_offset`**, beyond the two we agreed.
|
|
`apply_scroll`'s remainder is exact only when the wall was already in
|
|
view, and a lazy span usually cannot see its wall until it has walked
|
|
there -- so `Scroll` reads the child's accumulated movement after the
|
|
placing draw instead of adding remainders up, which would drift.
|
|
- **One scroll-delta convention, the finger's.** The two widgets had
|
|
opposite ones under the same name; `LazySpan::scroll` is now private and
|
|
the single negation lives in its `apply_scroll`. Call sites that passed
|
|
`-dy`/`-v` pass them straight through, and one fixture's expected
|
|
velocity flipped sign with its magnitude unchanged.
|
|
- **The transcript builds its `Scroll` by hand rather than through
|
|
`.scrollable_to_end()`**, because that helper registers a finger drag
|
|
and `Selection` is already the arbiter for those frames -- two
|
|
`DragGesture`s seeing one gesture is what `DragGesture`'s own doc rules
|
|
out. The wheel is registered identically; only the drag differs.
|
|
- **DEFERRED: the pin stays in each widget.** Iris asked for `amt` and
|
|
the at-end control to live in `Scroll`; `amt` does, the pin does not,
|
|
because applying a pin happens when a row is appended -- between frames,
|
|
with no painter -- so moving it needs a fourth `Widget` method or a
|
|
parameter on `apply_scroll`. Nothing external edits a pin today.
|
|
docs/IRIS_TODO.md carries it.
|
|
|
|
## 2026-09-08 (later still: the list's overscroll clamp, in frame)
|
|
|
|
Finishes the item the previous entry deferred. IRIS.md has the account.
|
|
|
|
- **`List` lays out a second time within the frame** when its walk lands
|
|
off the end of the content, instead of writing the correction to the
|
|
anchor and asking for another frame. The extra walk is paid only on an
|
|
overscrolled frame, and it is mostly O(1) moves.
|
|
- **`Painter::draw_again` is removed**, `List` having been its only
|
|
caller -- so the framework no longer offers a way to ask for a
|
|
corrective frame at all.
|
|
- **`List::place`'s top-known and bottom-known cases are one path**
|
|
(`Placement::edges`), which is the "write the logic once" rule applied
|
|
to two symmetric directions rather than a behaviour change.
|
|
|
|
## 2026-09-08 (later: a scroll area measures and places in one frame)
|
|
|
|
From Iris's phone report about the composer's padding while typing
|
|
newlines, and the rule she stated when she read the first fix: layout is
|
|
a pure function of the state, nothing self-heals, and two draws to place
|
|
something happen in the same frame. IRIS.md's entry has the account.
|
|
|
|
- **`Scroll::draw` draws its child twice** -- once at last frame's length
|
|
to measure it, once at the measured length to place it -- instead of
|
|
placing against the stale length and leaving a wrong frame on screen.
|
|
The second draw is free unless the content's length changed.
|
|
- **An end-anchored `Scroll` is at its end on its first drawn frame**, a
|
|
consequence of the above. Two layout tests now build their area with
|
|
`at_end: false`, which is what they meant: they scroll down from the
|
|
top.
|
|
- **`List::clamp_to_content`'s next-frame correction is left in place**
|
|
and written down in docs/IRIS_TODO.md instead of fixed here, because
|
|
`List::place` is a larger piece of machinery and deserves its own
|
|
before/after on the phone.
|
|
|
|
## 2026-09-08 (every crate to its latest version, wgpu 28 -> 30)
|
|
|
|
At Iris's request. RUST.md's "Every crate to its latest version" box has
|
|
the full list and the migration.
|
|
|
|
- **wgpu 30 taken now rather than pinned at 28.** Two majors of API
|
|
change, all mechanical (instance descriptor, optional bind-group and
|
|
vertex-buffer slots, `Queue::present`, a `CurrentSurfaceTexture` enum),
|
|
and one that would have been a startup abort on a device rather than a
|
|
compile error: naga now demands `@interpolate(flat)` on the shader's
|
|
integer varyings. Verified on both backends before this was called
|
|
done, since a renderer that compiles proves nothing.
|
|
- **The desktop instance now carries winit's display handle.** wgpu 30
|
|
asks for it when a GLES surface will be presented on Wayland, which is
|
|
what this machine's Vulkan-to-GLES fallback produces. Android passes
|
|
none: its surface comes from a `NativeWindow`.
|
|
- **`syn` 2 -> 3, `pollster` 0.4 -> 1.0** with no source change in
|
|
`iris/macro` or anywhere else.
|
|
|
|
## 2026-09-08 evening (the fling is shared; a cancel is not a release)
|
|
|
|
From Iris's four-item phone report; RUST.md's "2026-09-08 (evening)" box
|
|
has the reasoning and the tests, IRIS.md the summary.
|
|
|
|
- **A `Flinger` that does not know which way the content moves.** Every
|
|
scroll area flings now, on either axis, as Iris asked -- and the
|
|
physics is one type shared by `List` and `Scroll` rather than a copy
|
|
each. The choice worth reviewing is the seam: `Flinger` owns the curve
|
|
and the clock, and the *caller* owns the sign convention and where the
|
|
content ends. Rejected: teaching `Flinger` a direction, which would
|
|
have to be told to it -- and being told is the same thing as not
|
|
knowing, with an extra field to get wrong.
|
|
- **A cancel is a first-class end to a gesture, not an early release.**
|
|
`CursorState::cancelled` is new state on the pointer sample, set by
|
|
Android's `ACTION_CANCEL` and the harness's `TouchAction::Cancel`.
|
|
Rejected: mapping a cancel to `PressEnd` and having each widget decide
|
|
what to suppress, which is what shipped and is why leaving the app
|
|
flung the transcript.
|
|
- **A `DragGesture` ignores a `Cancel` it caused.** One gesture is
|
|
driven by several widgets, so the widget that was pressed can be a
|
|
"loser" on the frame its own gesture won. The test is whether the
|
|
gesture's own capture id is the holder. This is what makes it safe for
|
|
every widget driving a gesture to register the whole `drag_senses()`
|
|
set, which is now the rule without exception.
|
|
- **`List::place` draws a resized row twice in one frame.** The old
|
|
comment accepted a one-frame lag by analogy with `Scroll`'s content
|
|
length. That analogy was wrong: a stale *length* only misplaces the
|
|
next thing, while a stale *box* is drawn, because a background fills
|
|
whatever box it is handed. The extra draw is bounded to frames where a
|
|
row's height actually changed.
|
|
|
|
## 2026-09-08 (iris ships an icon font, and the drawn mark is deleted)
|
|
|
|
- **Directed by Iris.** Her question on seeing `widget::mark`: "why does
|
|
mark exist? The font should be working if it's working for compose and
|
|
nerd fonts are bundled." It was not: the Compose app draws its icons
|
|
from **its own committed Nerd Fonts subset**, while iris was setting
|
|
the disclosure mark with bare Unicode geometric codepoints
|
|
(U+25B8/25BE/25B4) out of whatever face the platform resolved -- an
|
|
empty box on her phone, a dot on this VM. The 2026-09-07 entry below,
|
|
which said "iris had no equivalent icon font to keep", is what left
|
|
that gap: iris had no icon font because it had never had one, not
|
|
because it needed none.
|
|
- **So iris now bundles the same kind of subset**:
|
|
`iris/core/build-icon-font.sh` writes
|
|
`iris/core/assets/fonts/nerd_icons.ttf` (992 bytes, three Material
|
|
Design glyphs today), `iris::icon` names the codepoints, and
|
|
`Family::Icons` draws them. This does **not** reopen the platform-fonts
|
|
decision: body and monospace text still come from the platform, and an
|
|
icon is the opposite case -- a small, closed, known set of codepoints,
|
|
which is exactly the division the Compose app already makes.
|
|
- **`iris::widget::mark` is deleted** (added earlier the same day). It
|
|
drew a correct triangle, but only a triangle, and every further icon
|
|
would have been another bespoke rasteriser. An icon as text also takes
|
|
the size, colour and baseline of the line it sits in for free.
|
|
|
|
## 2026-09-08 (the emulator is a GLES machine, and Vulkan is verified elsewhere)
|
|
|
|
- **Directed by Iris, carried out here**: "make sure the setup uses GL for
|
|
the android emulator and remove any vulkan requirements. That'll be
|
|
tested through both the desktop version as well as my phone." So the
|
|
emulator is settled as a GLES rig and nothing chases hardware Vulkan in
|
|
it any more; the Vulkan path is covered by the desktop build and by her
|
|
phone.
|
|
- **Nothing had to be forced to make that true.** Measured in the guest
|
|
the same day: the emulator has no hardware Vulkan at all (its only
|
|
Vulkan is SwiftShader, in software) and its GLES is the host's real RX
|
|
7900 XT through virgl at ES 3.1. iris's existing runtime fallback --
|
|
`Backends::PRIMARY`, no adapter, rebuild on `Backends::GL` -- already
|
|
lands there, verified end to end with an ordinary (no `force-gles`)
|
|
debug APK.
|
|
- **The emulator and the phone therefore run the same binary**, differing
|
|
only in what that binary finds. That is deliberate and worth not
|
|
undoing: a build flag that changed the backend would mean the thing
|
|
measured on the emulator is not the thing shipped. `force-gles` stays,
|
|
but only for pinning the backend on a machine that *does* have Vulkan
|
|
(the desktop), and never for a phone build.
|
|
- **Every run now says which adapter drew it.** The Android renderer logs
|
|
the full adapter line at startup the way the desktop already did -- only
|
|
the backend enum was logged before, which cannot separate `Gl` on the
|
|
host's GPU from `Gl` on SwiftShader, or a phone's real Vulkan from a
|
|
software one. `run-bench.sh` prints that line before any number.
|
|
- **No Vulkan requirement was found in iris to remove.** `device_limits()`
|
|
asks for nothing beyond wgpu's defaults (and zeroes the compute fields),
|
|
neither backend requires a feature, and both probe rather than
|
|
`.expect()` an adapter. What was removed was the *documentation* telling
|
|
people to boot the emulator with SwiftShader Vulkan.
|
|
|
|
## 2026-09-07 (platform fonts, not bundled ones)
|
|
|
|
- **Iris's own decision, carried out as directed**: removed the 3.6 MB of
|
|
bundled Noto Sans/Noto Sans Mono TTFs from `iris-core` and load text
|
|
from the platform's own font collection instead (`fontique`'s system
|
|
discovery, already on by default). Matches what the Compose app does --
|
|
it takes body text from `FontFamily.Default` and code text from
|
|
`FontFamily.Monospace`, both platform-resolved, and ships no text font
|
|
of its own. Rejected alternative (the one this pass had left open
|
|
2026-09-06): subsetting the bundled Noto Sans to Latin/common
|
|
punctuation instead of removing it outright, which would have kept
|
|
identical rendering across devices for a smaller (not zero) size cost;
|
|
Iris chose to match Compose instead.
|
|
- `.so` **-3,748,136 bytes** (11,193,608 -> 7,445,472), matching the
|
|
original 3.6 MB estimate. Fallback still lands on the platform's own
|
|
tofu for a codepoint no resolved face has (checked with CJK + emoji on
|
|
desktop) rather than blank space, so the UI_RULES unknown-glyph rule
|
|
still holds.
|
|
- **Gap found, then closed same day**: this fontique version's Android
|
|
backend never resolved the `Monospace` generic family at all (confirmed
|
|
on this checkout's emulator, `mono=None` in the startup diagnostic) --
|
|
two pre-existing bugs in fontique's own `fonts.xml` parsing stacked (an
|
|
ordering bug, and a `<family name="monospace">` declaration whose
|
|
`<font>` children the backend's parser never reads), not something this
|
|
change introduced, but this change is what stopped masking it (the
|
|
bundled mono font used to be registered ahead of the broken platform
|
|
lookup, so it always won). Checked `linebender/parley`'s `main` branch
|
|
on GitHub: neither bug is fixed there, so there was no newer release to
|
|
bump to. Fixed instead in `iris-core` itself
|
|
(`TextData::patch_android_monospace`, Android-only): reads
|
|
`/system/etc/fonts.xml`'s own `"monospace"` declaration for the font
|
|
filename it names, then registers whichever of fontique's actually-
|
|
scanned families owns that file as the `Monospace` generic -- the same
|
|
authority Compose's `Typeface.MONOSPACE` resolves through, without
|
|
pinning an OEM-specific family name. Verified on this checkout's
|
|
emulator: `mono=Some("Droid Sans Mono")`, and a screenshot showing the
|
|
bench-fixture's code block and tool-card values in a visibly monospaced
|
|
face beside sans body text; the desktop `fontconfig` backend is
|
|
unaffected (still resolves monospace correctly, confirmed unchanged).
|
|
docs/RUST.md's "Platform fonts (2026-09-07)" has the full account.
|
|
|
|
## 2026-09-07 (a phone log reaches Iris through Dev Updater's own tab)
|
|
|
|
**Supersedes the "how a phone log reaches Iris" entry below, same day.**
|
|
Iris's call once the route was working: put it in Dev Updater properly
|
|
rather than smuggling the lines through `ai-server`'s log.
|
|
|
|
- **The app exposes its own log on the device, and Dev Updater reads it
|
|
there.** A `ContentProvider` at `<applicationId>.devlog`, one table of
|
|
lines queried with `?since=<seq>` so a poll is incremental, plus a
|
|
`status` row (`held`, `dropped`, `newest_seq`). Dev Updater's phone app
|
|
polls it while the component's **Runtime** tab is open and forwards what
|
|
is new to its own build machine, into that APK component's runtime log
|
|
-- so the same tab renders both kinds and the history outlives the
|
|
phone. No tunnel, no token, no second enrolment: the two apps are on the
|
|
same phone.
|
|
|
|
**It is a contract, not a feature for iris.** Written down in
|
|
dev-updater's `README.md` ("An app's own log"), so any app that server
|
|
delivers gets the tab by implementing it; the Compose app in `app/` can
|
|
do the same later. That is the reason it beat the route below on its
|
|
second look -- the earlier one only ever worked for the one project that
|
|
had a server, and put a phone's lines under a *different component* than
|
|
the one they came from.
|
|
|
|
- **Read access is `protectionLevel="normal"`, and that is a real trade.**
|
|
`signature` is what this wants and is not available: Dev Updater and the
|
|
apps it delivers are built on one machine but signed with different
|
|
locally generated keys, so a signature permission would be held by
|
|
nothing at all. What `normal` costs is that any app on that phone which
|
|
requests `dev.updater.permission.READ_DEVLOG` by name can read another
|
|
app's dev log. Accepted because these are development builds on a
|
|
development phone and the alternative was no log; stated in the manifest
|
|
beside the declaration and in dev-updater's README so it is not
|
|
rediscovered as a surprise.
|
|
|
|
- **The provider polls rather than notifying.** `notifyChange` was not
|
|
implemented: the ring is filled by a `log::Log` backend on whatever
|
|
thread logged, and giving that a route to a `ContentProvider` means
|
|
plumbing a callback through `client-core` for every platform. Dev
|
|
Updater's contract therefore says it polls (about a second, only while
|
|
the tab is open), which is what keeps implementing the contract cheap --
|
|
a provider that does notify loses nothing.
|
|
|
|
- **What was deleted, so there is one mechanism**: `client-core`'s
|
|
`log_upload` module, `POST /client-log` on `ai-server`, the
|
|
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` baking in `iris/android-app/build.rs`
|
|
(which left that file with nothing to do, so it is gone too), and the
|
|
uploader fields on both Android clients. Kept: the ring, `RingLogger`,
|
|
`install_process_logger`, and the Diagnostics line counting what is
|
|
held. The upload-status line there is now **"devlog provider:
|
|
content://<authority>"** -- named from what the provider registered
|
|
rather than composed from the package here, so a screenshot of that pane
|
|
is evidence the contract is live and says which package's log it is.
|
|
|
|
## 2026-09-07 (how a phone log reaches Iris) -- superseded, see above
|
|
|
|
- **The app sends its own log to `ai-server`, and Dev Updater shows it as
|
|
`ai-server`'s runtime log.** Iris has no `adb`/`logcat` on her phone, and
|
|
Android forbids one app reading another's logcat, so the app has to carry
|
|
its own copy and post it somewhere. `POST /client-log` on `ai-server`
|
|
re-emits each line into that server's own `tracing` output; Dev Updater
|
|
already runs `ai-server` as a `Managed` component, whose stdout its own
|
|
service script redirects to a file and reports through
|
|
`GET /apps/{key}/components/{name}/logs?kind=runtime`, which the phone
|
|
app's log dialog already offers as a **Runtime** tab for a `server`
|
|
component. So **no change to Dev Updater at all** -- one route on
|
|
`ai-server`, and the client in `client-core`.
|
|
|
|
**Rejected: posting to Dev Updater's own server** (the first candidate,
|
|
and what the entry above went on to build -- the estimate below was
|
|
right about the work and wrong about it being too much).
|
|
It would need a new authenticated *write* route on a TLS surface whose
|
|
module doc says every route on it "is, or decides, the bytes that get
|
|
handed to `REQUEST_INSTALL_PACKAGES` next"; a per-app device-log store;
|
|
a change to `component_logs` so an APK component can have a runtime log;
|
|
a change to the phone app's `hasBothKinds = component.kind == "server"`
|
|
gate and to what `hasRuntimeLogs` means on the wire; and -- the real
|
|
cost -- a **second** enrollment for the iris app, since it has no CA or
|
|
token for Dev Updater and Dev Updater mints tokens per device by QR.
|
|
Five changes across two repos against one route, for the same line
|
|
landing in the same viewer.
|
|
|
|
**Rejected: a share intent from a debug button** (a log file in the app's
|
|
external files dir, shared by hand). It works today and needs no server,
|
|
but every line costs Iris a manual export and a message, which is the
|
|
round trip through a person this was meant to remove. It is still the
|
|
fallback when the tunnel is down, and GrapheneOS's own per-app log export
|
|
already covers the crash case (that is how the `ToolInput.highlighted`
|
|
crash was reported).
|
|
|
|
- **The ring is in `client-core`, not in the Android crate.** A bounded
|
|
in-memory ring (2000 lines or 256 KiB, whichever bites first) behind a
|
|
`log::Log` backend that *forwards* to whichever logger the platform
|
|
already installed, so `logcat` and a desktop terminal see exactly what
|
|
they saw before. The platform supplies only its own logger and its
|
|
destination. `Copy report` appends the ring to what goes on the
|
|
clipboard, and flushes the uploader first.
|
|
|
|
- **The destination is baked in at build time, from the build machine's
|
|
own files** (`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA) --
|
|
*gone; the provider above replaced it.* What is worth keeping from it is
|
|
the reason it went: an APK good only for the server that built it cannot
|
|
be built in this VM for Iris's phone, which is the case that mattered.
|
|
all three or none, never two. The same trust boundary the transcript
|
|
config and the Compose APK's CA already use: nothing secret is
|
|
committed, and an APK is good for the server that built it. A build told
|
|
nothing still keeps its ring and still copies it; the diagnostics pane
|
|
says which of "not tried yet", "failing -- <why>" and "no server
|
|
configured" it is, because otherwise all three look like silence.
|
|
|
|
## 2026-09-06 (how a tool call looks, P1b)
|
|
|
|
- **A card that never got a result says "no result", in yellow, and it is
|
|
a state Compose cannot say.** A call that finished having printed
|
|
nothing and a call whose turn was interrupted before anything came back
|
|
both leave an empty output. Compose draws both as an ordinary finished
|
|
call, which reads as a fact somebody established. There are five states
|
|
now, each with a word and a colour: nothing at all for a call that
|
|
worked, "running" (grey), "your turn" (peach, Compose's own wording and
|
|
colour), "failed" (red), "no result" (yellow).
|
|
|
|
- **A failed call is drawn as failed, which needed a field on the wire.**
|
|
`is_error` is on the CLI's `tool_result` and was being dropped; the
|
|
server now carries it to the phone. Reversible, but the alternative is a
|
|
card that says a call succeeded because it cannot tell.
|
|
|
|
- **A group's cards do not each carry their own surface.** Compose gives
|
|
each card a fill and squares the corners where it faces a neighbour, so
|
|
a run reads as one object broken into parts. iris has no per-corner
|
|
radius, and -- more to the point -- a group built the way Compose builds
|
|
it hit a framework layout defect that drew every card's text a card
|
|
below its own box. So a group is one surface with its cards on it,
|
|
separated by a small gap, and the 4dp inset Compose holds them off the
|
|
edge by is gone. Worth revisiting once the layout defect is fixed
|
|
(docs/IRIS_TODO.md).
|
|
|
|
- **A long tool output is capped at 80 lines or 4 kB with a "Show all N
|
|
lines".** Compose draws the whole thing, and gets away with it because
|
|
its `Text` inside a `LazyColumn` lays out lazily; here the output is one
|
|
text widget and shaping a hundred kilobytes of it costs what the file
|
|
editor's 32 kB limit was measured against. If iris's text gets cheaper,
|
|
this is the number to move.
|
|
|
|
- **A card's command is clipped, not pannable, and its summary line is
|
|
clipped rather than ellipsised.** Both are framework gaps rather than
|
|
choices (`scrollable_on` on a non-editable text draws nothing; there is
|
|
no overflow ellipsis), and both are worse than Compose today. Named here
|
|
because they are visible.
|
|
|
|
## 2026-09-06 (how a markdown block looks, P1a)
|
|
|
|
- **A table is drawn as padded monospace columns, not as a grid.** Your
|
|
call to reverse. Compose draws a real grid: cells on a tint, each
|
|
column with a 136dp floor, scrolling sideways when there are too many.
|
|
iris has no grid widget, and building one would be a widget per
|
|
markdown feature -- which is the thing the block model exists to avoid.
|
|
In a monospace face a character count *is* a pixel width, so padding
|
|
each cell to its column's width is alignment, the widths are still
|
|
measured from the cells, and a table that is too wide pans sideways
|
|
through the same mechanism a code fence already uses. The header is
|
|
bold with a rule under it, and a long cell wraps inside its column
|
|
(capped at 28 characters, which is what fits three columns across a
|
|
phone). **What it trades:** no cell borders, and a table looks like
|
|
code rather than like a table. If you want the grid, it is a new widget
|
|
and it is a day's work.
|
|
- **Three block frames, and only three.** A heading, paragraph and list
|
|
are plain text with spans; a fence and a table are a rounded panel that
|
|
does not wrap; a quote is a bar with the text padded past it.
|
|
Everything else markdown says is expressed in span styles, which cost
|
|
no widgets and no layout nodes. So a new markdown feature is a span,
|
|
not a widget.
|
|
- **A list's marker is part of the text, so a wrapped item's second line
|
|
returns to the left margin.** Compose keeps it indented by giving the
|
|
marker its own column. Doing the same here needs per-line indent in
|
|
iris's text attributes; it is written down rather than done, because
|
|
the list items in a real reply are usually one line.
|
|
- **A link opens on a tap and not on the end of a drag.** A press that
|
|
panned the transcript past a link, or that held long enough to start a
|
|
selection, does not follow it -- decided by the same gesture machine
|
|
that decides pan-versus-select, so there is one rule rather than two
|
|
that can disagree.
|
|
|
|
## 2026-09-06 (composer scroll and the streaming block model)
|
|
|
|
- **A streamed message becomes a column of per-block widgets.** Decided by
|
|
the design agent; recorded here because it is the shape of every message
|
|
on screen. A transcript row is one `TextEdit` today, so a streamed delta
|
|
re-shapes the entire message through parley on every event -- the stream
|
|
phase is the one place iris is behind Compose on your phone (p50 18.2ms
|
|
vs 13.4ms). A row becomes a column of one widget per markdown block
|
|
(paragraph, heading, fence, list, table) and a delta replaces only the
|
|
last block, keeping every earlier block's layout. **Rejected:** splitting
|
|
parley's layout at block boundaries inside one text widget (couples
|
|
iris's text widget to markdown structure, and parley has no incremental
|
|
API), and caching shaped runs per paragraph inside `TextEdit` (a second
|
|
cache with its own invalidation beside the glyph cache). Chosen because
|
|
P1's markdown block model is needed anyway, so the split happens once, in
|
|
`client-core`, and iris stays a text renderer. **Status: designed, not
|
|
built** -- this pass spent its budget on the composer's three layout
|
|
defects; docs/RUST.md has the design and the pass conditions.
|
|
- **The composer's overflowing text now scrolls on a finger**, capped at
|
|
six lines and clipped to the bar. Reverses the "still does not scroll"
|
|
item below.
|
|
- **A widget may not report a `dp` length** (see IRIS.md). A rule for
|
|
widget authors, enforced by a `debug_assert!`; nothing changes for app
|
|
code.
|
|
|
|
## 2026-09-06 (stale-primitives and touch-scroll pass)
|
|
|
|
- **A vertical drag inside a focused composer now scrolls rather than
|
|
selects.** Android's own `EditText` does this -- a vertical drag scrolls
|
|
the field, and only a long press starts a selection -- so the platform
|
|
decided it. What it costs: you can no longer drag straight down inside
|
|
the composer to select several lines of what you typed; use a long press
|
|
and then drag, or drag sideways. Say if that trade is wrong for you.
|
|
- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area
|
|
does not, because it has no per-frame tick to animate one and the areas
|
|
it wraps are at most a screenful (Android does not fling a six-line text
|
|
box either). Easy to add later if a scroll area ever wraps something long.
|
|
- **The composer still does not scroll its overflowed text**, though the
|
|
mechanism it needs is now in place. Wrapping the field in `.scrollable()`
|
|
was tried and reverted the same day: `Scroll` measures its content and
|
|
container against the *window*, so inside the `MaxSize` that caps the
|
|
composer at six lines the two are in different spaces and the field pans
|
|
itself entirely out of the bar (measured on the emulator with 474
|
|
characters in it -- the bar collapsed to its padding). Fixing that means
|
|
`Scroll` measuring against its own offered box, which is a change to a
|
|
widget the transcript and the bench shell both use, so it is its own
|
|
piece of work rather than a rider on this one.
|
|
|
|
## 2026-09-06 (defect pass)
|
|
|
|
- **The keyboard-open diagnostics overlay is gone; the capture only
|
|
logs now.** It was added when `on_insets_changed` was not firing at all
|
|
and there was no way to get a report off the phone. It fires reliably
|
|
since the activity went edge-to-edge -- and what that looks like in
|
|
use is a full-screen report covering the app **every time the keyboard
|
|
opens**, with its own Copy/Close buttons sitting underneath the
|
|
keyboard, so it cannot be dismissed (reproduced on the emulator this
|
|
pass: two `tap 'CLOSE'` runs left it up). An interruption for something
|
|
nobody asked for, over the app you are trying to type into. The named
|
|
`Diagnostics` button still shows the same text on demand, and the new
|
|
`iris surface:`/`iris insets:` log lines carry the lifecycle a `logcat`
|
|
pull needs. Reversible: `capture_keyboard_diagnostics` is still the one
|
|
place this is decided, and `PlatformHandle::show_diagnostics_overlay`
|
|
is still there.
|
|
|
|
- **The bench shell's report pane is sized to its report, not to a share
|
|
of the window.** It held `.height(rest(1))` beside the transcript's
|
|
`rest(2)`, so an *empty* `TextEdit` reserved a third of every screen --
|
|
which is what Iris's "the app does not start with keyboard spacing
|
|
correct" screenshot was showing, with the composer two thirds down and
|
|
black below it. It is `.max_height(dp(260))` now and sits above the
|
|
transcript rather than under the composer, where it was eating the
|
|
navigation-bar clearance. Cost: a filled report is clipped at 260dp
|
|
rather than scrolling (a `Scroll` there drew itself off the top of the
|
|
screen, since `Scroll` pins to the end of its content and reports its
|
|
content's full length to the parent -- worth fixing in `Scroll`, not
|
|
worked around here). "Copy report" and `logcat` still have the whole
|
|
thing.
|
|
|
|
## 2026-09-05
|
|
|
|
- **iris no longer asks every device for compute-shader limits it never
|
|
uses.** `adapter.request_device` (both `iris/src/android/render.rs` and
|
|
`iris/src/default/render.rs`) used `Limits::default()` plus an override
|
|
for `max_buffer_size`, and `Limits::default()` unconditionally requests
|
|
desktop-tier compute limits (`max_compute_workgroups_per_dimension:
|
|
65535`, per `wgpu_types`) even though nothing in `iris`/`iris-core`
|
|
creates a `ComputePipeline` or writes a `@compute` shader stage —
|
|
confirmed by grepping the whole tree, not assumed. That crashed
|
|
`request_device` outright on the Android emulator's software GL path
|
|
(`EMU_GPU=software`, `--features force-gles`): SwiftShader's GL reports
|
|
itself as OpenGL ES 3.0, which has no compute shaders at all, so the
|
|
adapter's real limit is 0 against the unconditional request for 65535 —
|
|
`RUST.md`'s "Software mode ... crashes for a third, different reason,"
|
|
2026-09-05, earlier today. The same would happen on any real
|
|
GLES-3.0-only Android device, not just the emulator. Fixed by a new
|
|
`iris_core::device_limits()` (`iris/core/src/render/mod.rs`), shared by
|
|
both platform backends so the two requests cannot drift, that zeros the
|
|
six `max_compute_*` fields explicitly rather than switching to a
|
|
downlevel `Limits` preset — `Limits::downlevel_webgl2_defaults()` was
|
|
considered and rejected: it also zeros
|
|
`max_storage_buffers_per_shader_stage`, and `shader.wgsl`'s vertex stage
|
|
reads four `var<storage>` buffers (rects, glyphs, masks, move_offsets),
|
|
so that preset would trade the compute crash for a bind-group-layout
|
|
one on the same downlevel hardware this is meant to support. No
|
|
capability check or fallback path was needed since nothing is being
|
|
disabled — the request is simply narrowed to what the pipeline actually
|
|
uses. `rigs/gpu-probe`'s own mirrored limits (it is deliberately its own
|
|
crate, not a workspace member, so it cannot call `device_limits()`
|
|
directly) were updated to match, and confirm `IRIS DEVICE: ok` against
|
|
this VM's own Vulkan and GL adapters. **Not verified this pass**: the
|
|
specific SwiftShader-ES-3.0 crash this fixes, on-device — the
|
|
`EMU_GPU=software` cold boot this needs would have force-restarted this
|
|
checkout's emulator while another session was actively running its own
|
|
app on it (`com.example.aiapp` had window focus at the time), so it was
|
|
left for a pass when the emulator is free rather than disrupting that
|
|
session. Everything reachable without the emulator is clean: `cargo
|
|
fmt`/`clippy --workspace --all-targets`/`test --workspace`, `cargo ndk
|
|
build`/`clippy` for `iris-android-app` with `force-gles`, and
|
|
`gpu-probe` against this VM's own Vulkan and GL(ES 3.2, which still has
|
|
compute and so would not have reproduced the crash even before this
|
|
fix — not a substitute for the real ES-3.0 test).
|
|
|
|
- **P0's Compose half is built and smoke-tested on the emulator** — the
|
|
`bench` build type, the shared `app/bench-fixture/` transcript, and an
|
|
in-process fake backend (`BenchFixture.kt`/`BenchNetwork.kt`) that
|
|
answers `TranscriptSource`/`EventStream` from an in-memory event log
|
|
instead of a real server, so the fold and paging under test are the real
|
|
ones. Full account, the smoke run's report, and what is deliberately
|
|
left (the iris half, the real on-phone runs) are in RUST.md's P0 box.
|
|
Not a decision to review so much as the gate itself now being runnable —
|
|
flagged here because it is the first half of something Iris explicitly
|
|
asked to see before P1.
|
|
|
|
- **P0's iris half is also built and smoke-tested on the emulator,
|
|
2026-09-05.** A new `bench` Cargo feature on `iris-android-app`, on top
|
|
of `transcript-screen`: the same checked-in fixture (`include_str!`, no
|
|
asset pipeline needed), the same 24-swipe scroll loop animated through
|
|
`List::scroll` and the same 400-event/20s streaming phase through
|
|
`fold_event`, "Run benchmark"/"Copy report" as named accessible
|
|
controls, and the same three added report fields (process CPU time,
|
|
peak RSS, battery current) via direct JNI calls
|
|
(`bench_jni.rs::PlatformHandle`) since `android_view` has no
|
|
`BatteryManager`/`ClipboardManager` wrapper of its own. One small public
|
|
API addition to get there: `AndroidAppState::platform_ready` (`IRIS.md`),
|
|
a default-no-op lifecycle hook handing an implementor a `JavaVM` +
|
|
`GlobalRef` it can call Java through from any thread. Packaged with a
|
|
new `release` build type on `iris-android-app`'s own Gradle project
|
|
(there was previously only `debug`), signed with the same key
|
|
`app/build-apk.sh` generates. Smoke run and the full report are in
|
|
RUST.md's P0 box; not attempted this pass: the real on-phone runs and
|
|
Iris's pass/fail call, which is the actual gate.
|
|
|
|
- **The intermittent touch-scroll dropout is root-caused and fixed: a
|
|
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
|
|
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
|
|
touch event, `DragArbiter` state transition and `Selection::drag`
|
|
dispatch (removed once confirmed), reproduced on this checkout's own
|
|
emulator against a real sandbox session. The trace showed the actual
|
|
mechanism: a gesture's `ACTION_DOWN` lands wherever the finger actually
|
|
is, which is not guaranteed to fall inside the same row-local sensor
|
|
region a later `ACTION_MOVE` in the same gesture lands in (a row's own
|
|
padding/gap, or its non-selectable sender-name header, is
|
|
pointer-transparent to `iris::sense::CursorSense`). When that happens,
|
|
the widget that ends up handling the gesture never saw `PressStart`, so
|
|
`DragArbiter` sits in `Idle` — which answers every subsequent frame with
|
|
`Undecided` and has no way to tell "no press is happening" from "a press
|
|
is happening but I missed its start," so it never recovers on its own
|
|
for the rest of that gesture. One real trace showed exactly this: touch
|
|
`Down`/`Move`/`Up` all delivered correctly, but zero `PressStart`
|
|
reaching the arbiter, `state=Idle` unchanged from first frame to last.
|
|
Fixed at the call site that has the context to recover
|
|
(`iris::transcript_ui::selection::Selection::drag`,
|
|
`iris/transcript-ui/src/selection.rs`): a new `DragArbiter::is_idle()`
|
|
(`iris/src/sense.rs`) lets it notice a `Pressing` frame arriving with the
|
|
arbiter still `Idle` — which can only mean a missed `PressStart`, since a
|
|
`Pressing` sense requires the button to genuinely be down — and start the
|
|
press there instead of where it was missed. Three new unit tests in
|
|
`sense.rs`'s `drag_arbiter_tests` and one in `transcript-ui`'s
|
|
`selection::tests` (the latter fails on the code before this fix).
|
|
Commit follows. Not the same failure the earlier pass's `DECISIONS.md`
|
|
DEFERRED item speculated about (a coalesced first `ACTION_MOVE` skipping
|
|
slop detection) — that hypothesis is now ruled out; the arbiter's own
|
|
slop/long-press logic was never wrong. RUST.md's I5 box,
|
|
"Touch-scroll dropout root-caused, 2026-09-05" has the full trace.
|
|
- **P0, a phone benchmark gate before any porting, asked for by Iris
|
|
2026-09-05**: "before P1 I'd like to see benchmarks & also maybe stress
|
|
test on my own phone ... If it doesn't match compose reasonably well then
|
|
I don't think I'd wanna continue." Design (RUST.md's P0 box has the
|
|
detail): the same embedded synthetic fixture in both apps with no server
|
|
needed; the same scripted scroll loop then a streaming phase, run
|
|
programmatically since the phone has no usable system tracing and no
|
|
agent can drive it; the same report from both (frames, janky %, p50/p90/
|
|
p99, process CPU time, peak RSS, battery current where readable) with a
|
|
copy button; the iris app under its own id and the Compose one as a new
|
|
`bench` build type with an id suffix, so neither replaces her production
|
|
install; two arm64 APKs plus instructions delivered under `~/host/bench/`.
|
|
The gate is hers: iris within a reasonable margin of Compose release on
|
|
p50, p99 and CPU time, no crashes, no visible stutter. If it fails, the
|
|
port stops.
|
|
- **The rest of the port is one UI crate, `iris/app-ui`, grown out of
|
|
`iris/transcript-ui` rather than started beside it.** It holds a
|
|
`Screen` enum plus a back stack — the Rust equivalent of `AppRoot.kt`'s
|
|
`when` — and `iris/desktop-app`/`iris/android-app` become thin entry
|
|
points over it. Chosen over a fresh crate because `transcript-ui`
|
|
already has the right generic shape (`Rsc: HasEvents` +
|
|
`Rsc::State: FocusHost`) and the `client-core`/`event-model` path
|
|
dependencies every later screen needs, so growing it in place is the
|
|
smaller diff. Platform-only code (notification service, share target,
|
|
QR scanner, Keystore token, deep-link enrolment) stays in the E3/E5
|
|
Java shell (`android-shell/` + `app/shellApp`) rather than moving into
|
|
this crate, since none of it is a screen. The Android APK is built by
|
|
`cargo xtask apk` (E5), merging the app-ui cdylib into the E3 shell so
|
|
there is one app rather than a demo shell plus a service shell.
|
|
`app/androidApp` (the Compose app) stays untouched and is the baseline
|
|
every step is measured against, until parity is reached (P7 decides
|
|
the switch, and is itself a load-bearing decision left to Iris). Order
|
|
is by risk to the daily-use path: session screen first (P1, where
|
|
every hard behaviour already lives), then the shell merge and a real
|
|
phone install (P2), then root tabs (P3), the explorer (P4),
|
|
settings/enrolment (P5), desktop parity (P6), and the cutover itself
|
|
(P7). Full plan: RUST.md's "The port, in order (decided 2026-09-05)".
|
|
- **iris gets its own measured frame report, rather than waiting on a
|
|
`dumpsys`/`gfxinfo` answer that cannot see a `SurfaceView`'s GPU-drawn
|
|
frames.** `iris_core::FrameReport` (`iris/core/src/render/frame_report.rs`)
|
|
times each frame's wall clock from the same point `render()`'s redraw
|
|
starts to just after `queue.submit` + `present()` — the span Compose's
|
|
own render report and `gfxinfo` both count — into a fixed 4096-entry
|
|
ring (no allocation per frame; `report()` is the only place that
|
|
allocates, and only on a button tap). The report gives total frames,
|
|
janky % over the same 16.7ms budget `gfxinfo` uses, P50/P90/P99 and the
|
|
worst, plus a reset. Exposed the way the Compose app's copy-button
|
|
report already is: two named controls ("Frame report", "Reset frame
|
|
report") on the transcript screen, tappable by accessibility name via
|
|
`ui-trace`, logging under this crate's fixed `android_logger` tag
|
|
(`iris-android-app`) so a script can grep `"iris frame report"` the way
|
|
`transcript-bench.sh` greps `"ai-app render report"`. The report's own
|
|
`Display` line says plainly that it measures up to the `present()` call
|
|
returning, not GPU/compositor completion — wgpu's `present()` is not
|
|
fenced against either, so presenting that span as "time to reach the
|
|
screen" would be a measured-looking number that is actually inferred,
|
|
which the standing UI rule forbids.
|
|
- **`ui-trace` gains a hold-then-drag gesture, additive, in
|
|
`emulator-tools`.** Neither of its two existing actions can produce
|
|
"hold stationary for `LONG_PRESS`, then move without lifting" — `tap`
|
|
has no hold and `swipe X1 Y1 X2 Y2 MS` interpolates motion across its
|
|
whole duration from t=0. A new action presses, waits, then moves to a
|
|
second point and releases as one continuous touch (raw
|
|
`sendevent`/`MotionEvent` injection, extending whatever mechanism the
|
|
existing `swipe` already uses), so `DragArbiter`'s pan-vs-select rule
|
|
(`iris/src/sense.rs`, already covered by 8 unit tests against a
|
|
synthetic clock) can finally be driven on a real device instead of only
|
|
in a test harness.
|
|
- **Touch drag on a transcript row follows Android's own rule**: a vertical
|
|
drag pans the list immediately; a stationary press held 500 ms starts a
|
|
text selection which further dragging extends; a horizontal drag while
|
|
something is already selected extends that selection without the wait.
|
|
One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a
|
|
"text layer always wins" or "list always wins" rule because either loses
|
|
one of the two gestures a reader expects.
|
|
- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window
|
|
holding `transcript-ui`'s screen beside a session list, talking to a real
|
|
`ai-server` through `client-core`. It enrols by pasting the same
|
|
`aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`)
|
|
and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The
|
|
pinned CA is a path given on the command line, not baked in. Chosen so
|
|
the phone and desktop share one enrolment format and no second one is
|
|
invented.
|
|
- **I5's Android integration extends `iris-android-app` (I2's shell)
|
|
behind a Cargo feature (`transcript-screen`), rather than a third
|
|
shell crate.** That project already has the Gradle module, the
|
|
`IrisView`/`MainActivity` Java, and the JNI registration; the only
|
|
thing a second screen needs on top is a different `AndroidAppState`,
|
|
the same axis `tabs_ui::build`/`transcript_ui::build` already vary
|
|
along on the winit side. `tabs-screen`/`transcript-screen` are
|
|
mutually exclusive and each pulls in only its own deps, so the plain
|
|
tabs build (I2/I4) is untouched.
|
|
- **Order of remaining work, updated 2026-09-05**: the two in-flight
|
|
pieces and I5's Android integration are all done; next is giving iris
|
|
its own frame-timing report so item 3 below can be decided by a number.
|
|
- **DECIDED by Iris, 2026-09-05: iris is the app's framework; Masonry was
|
|
the calibration.** Her words: "I think iris definitely makes more sense
|
|
based on the limitations we've found." The limitations: Masonry has no
|
|
touch scroll on Android (E2), no per-span rich text and no cross-row
|
|
selection on the pinned commit (E2), and its keyboard bridge is a TODO
|
|
(E1); iris carries the same screen under the Compose baseline on the
|
|
host GPU (p50 15.0 ms against Compose's 20.0 ms, RUST.md's I5 box). What
|
|
follows: the E-steps are closed as calibration, and the port proceeds
|
|
on iris — screens, the shell (E3/E5), and `client-core` underneath.
|
|
The item below is kept as the record of what she decided from.
|
|
- **Was DEFERRED — whether to commit to iris over Masonry for `ai-app`.**
|
|
Updated 2026-09-05 with the clean comparison the recommendation wanted:
|
|
same sandbox session content, same emulator, `EMU_GPU=software`, one
|
|
session. Headline numbers (RUST.md's I5 box, "Clean scroll comparison,
|
|
2026-09-05," has the full table and every caveat):
|
|
|
|
| app | build | frames | janky % | p50 | p90 | p99 | worst |
|
|
|---|---|---|---|---|---|---|---|
|
|
| Compose (in-app report) | debug | 1102 | 99.0% late | 33.8ms | 50.6ms | 79.5ms | -- |
|
|
| Compose (`dumpsys gfxinfo`) | debug | 1499 | 21.15% (95.66% legacy) | 32ms | 48ms | 150ms (p99) | -- |
|
|
| iris (`FrameReport`) | **release** | 299 | 94.65% | 79.1ms | 98.6ms | 117.8ms | 212.6ms |
|
|
| iris (`FrameReport`, repeat) | **release** | 233 | 94.42% | 109.3ms | 130.8ms | 147.1ms | 150.5ms |
|
|
|
|
**Not a clean apples-to-apples reading, stated plainly rather than
|
|
smoothed over**: iris had to be built **release** (debug `SIGSEGV`s on
|
|
this emulator's Vulkan loader, I4's finding) against Compose's mandated
|
|
**debug** build, so this asymmetry likely *understates* iris's gap
|
|
rather than the reverse; the three frame-time sources measure different
|
|
things (Compose's own phase accounting vs. Android's HWUI deadline-miss
|
|
definition vs. iris's redraw-start-to-present window, the last of which
|
|
`dumpsys gfxinfo` cannot see at all for iris's `SurfaceView`); and both
|
|
figures are emulator numbers under software rasterisation, which
|
|
Compose's *own* in-app report shows already costs 20-34ms/frame in
|
|
`swap`+`gpu` alone under this GPU mode, so a same-mode iris number well
|
|
above 16.7ms was expected going in for either app. A second pair under
|
|
`-gpu host` was not taken this pass. The earlier session's suspected
|
|
intermittent touch-delivery dropout was **not reproduced** this pass —
|
|
the zero-frame results this time traced to this pass's own script bug
|
|
(a `cd` that changed which emulator `ui-trace` targeted), not the
|
|
emulator; a CPU-load rise during the gesture was observed by a sampler
|
|
running throughout, but did not correlate with any failure, so the
|
|
original candidate is neither confirmed nor ruled out.
|
|
The choice in front of Iris, updated: decide now on the
|
|
structural-plus-functional case already made (iris works end-to-end
|
|
where Masonry's scroll gesture doesn't exist at all on Android) plus
|
|
this table — reading the two build profiles and three jank definitions
|
|
with the caveats above rather than as a single number — or ask for a
|
|
same-profile, same-GPU-mode rerun first. RUST.md's I5 box has the full
|
|
account.
|
|
|
|
**Updated 2026-09-05, the `-gpu host` pair taken.** Real GPU rendering
|
|
(`force-gles` -- the default Vulkan backend has no adapter at all under
|
|
plain host-GPU boot, confirmed by the exact `wgpu` error) reverses the
|
|
software-mode shape:
|
|
|
|
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|
|
|---|---|---|---|---|---|---|---|---|---|---|
|
|
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
|
|
| iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms |
|
|
|
|
Under real GPU rendering iris's median frame is *faster* than
|
|
Compose's, not the 2-3x-slower shape the software-mode table shows. A
|
|
new split inside `FrameReport` (redraw-to-submit vs. submit-to-present,
|
|
commit `e2a1fad`) says why: iris's own CPU work per frame is a median
|
|
~1ms -- almost the entire frame is time spent handing the frame to the
|
|
driver, not in iris's layout/text/primitive code. This is consistent
|
|
with the earlier software-mode gap being mostly SwiftShader's CPU
|
|
rasterisation cost rather than an iris-specific slowness. **Still not
|
|
proof, and now closed as unanswerable rather than merely untaken**: a
|
|
same-mode software `force-gles` run to isolate the backend was retried
|
|
2026-09-05 after fixing the compute-limit crash the first attempt hit,
|
|
and hit a second, structural wall instead — SwiftShader's ES 3.0 GL
|
|
path has no storage-buffer capacity at all, and `shader.wgsl` reads
|
|
`var<storage>` buffers unconditionally, so reaching that path needs a
|
|
shader rewrite, not a limits fix (RUST.md's I5 box, "The three
|
|
remaining I5 verifications, closed 2026-09-05," item 2). The
|
|
intermittent touch-scroll dropout this pass also reproduced is
|
|
root-caused and fixed as of the same date (a missed `ACTION_DOWN` on a
|
|
row's padding/header left `DragArbiter` stuck in `Idle`); three clean
|
|
`iris-scroll.sh` runs post-fix each scrolled all 24/24 swipes, replacing
|
|
the single-attempt 62-frame reading this table used to carry. RUST.md's
|
|
I5 box, "Where iris's frame time goes, 2026-09-05, the `-gpu host`
|
|
pass," and "The three remaining I5 verifications, closed 2026-09-05,"
|
|
have the full account. The iris-vs-Masonry choice itself is still
|
|
Iris's to make.
|
|
|
|
## 2026-09-07: the enrolment link carries the CA, so an APK need not be built where its server runs
|
|
|
|
**Problem.** Every phone build pinned the CA of the machine that compiled
|
|
it -- the Compose app from `GeneratePinnedCert`, the iris app from
|
|
`build.rs` reading `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. That is fine
|
|
while the two are the same machine and impossible when they are not, which
|
|
is exactly the iris client's situation: cross-compiled in this VM,
|
|
delivered to a phone, run against `ai-server` on the host. Baking the
|
|
host/port/token as well made it worse -- a token in a built artifact.
|
|
|
|
**Decided: the CA rides in the enrolment link**, as `&ca=<base64url of the
|
|
DER>` (`wg_app_link::enroll::ca_param`), optional and per mint. The app
|
|
that opens the link pins what the link said, and an APK built anywhere
|
|
works against whatever server it is pointed at.
|
|
|
|
Two alternatives were worked out and rejected.
|
|
|
|
- **A CA *fingerprint* in the link, pinned at the TLS handshake.** The
|
|
smallest link (43 more characters) and the strongest shape, but `ureq`
|
|
3.4 exposes no hook for a custom `rustls` `ServerCertVerifier`: its
|
|
`TlsConfig` builds the `ClientConfig` itself, so this needs a hand-written
|
|
`Connector` on the `unversioned` API and `rustls` as a direct dependency
|
|
of `client-core`. A lot of machinery in the one crate that must stay
|
|
light.
|
|
- **A fingerprint in the link plus an unauthenticated `GET /ca.pem`.**
|
|
Small code, but it needs a first connection with verification disabled,
|
|
and it breaks a documented, tested posture -- `auth.rs`'s "gates every
|
|
route with zero unauthenticated endpoints", which is a load-bearing
|
|
decision rather than an implementation detail. Not something to change
|
|
silently for this.
|
|
|
|
**What it costs**, measured rather than guessed: on this project's P-256
|
|
CA the link goes from 89 bytes to 652, and `print_enrollment`'s terminal
|
|
QR from 45x23 to 93x47 characters. That is why the parameter is the
|
|
minter's choice per call: `ai-server` passes it (its iris client needs it),
|
|
`dev-updater` passes `None` (its app is built on the machine it talks to,
|
|
and its QR stays scannable in an 80-column terminal). The URI printed under
|
|
the QR is the fallback either way, and is the path Dev Updater's Enroll
|
|
button already uses -- it opens the link with `ACTION_VIEW`, so Android
|
|
offers whichever apps registered the scheme, which needed no change here.
|
|
|
|
The CA is a public certificate, so putting it in the QR leaks nothing the
|
|
token did not already: photographing the terminal still costs exactly the
|
|
token, which is rotatable.
|
|
|
|
**The log upload's destination is moot**, so it is not wired to this. On
|
|
the same day Iris decided Dev Updater will read an APK's runtime log from
|
|
an on-device ContentProvider instead, which removes `log_upload`,
|
|
`POST /client-log` and the `AI_APP_LOG_*` baking altogether -- so the
|
|
enrolment landed without touching any of them, for that change to delete
|
|
whole.
|