Iced migration — history & status¶
Status: complete for Linux, under evaluation for macOS. Linux ships the iced UI; macOS ships the Swift app with an experimental iced build beside it. This page is the record of how that happened and what is still open.
It replaces four planning documents that governed the migration while it was in flight — the POC plan, the POC development notes, the parity inventory, and the migration roadmap. They were accurate to a world with three UI implementations in the tree, and that world is gone; git history preserves them.
Why iced¶
The short version — the decision-log entries are the canonical form:
- DL-15
— the renderer was always ours (we walk libghostty-vt's render state
and draw cell-aligned rects + text either way), so Cairo + Pango
bought the terminal grid nothing that iced + wgpu doesn't buy on the
GPU; iced runs on Linux and macOS, so one Rust codebase covers both
host OSes; and both Rust UIs already sat on the toolkit-neutral
roost-engine, so retiring an adapter cost the core nothing. - DL-16 — the macOS build exists to test the convergence hypothesis (one codebase, both platforms) against the Swift app in daily use. It records a hypothesis, not a decision.
What shipped, by milestone¶
The migration ran as numbered milestones, each executed as topic
branches off main in slices sized for one pass.
| Milestone | What it was | Outcome |
|---|---|---|
| M0 | Color-regression investigation | Closed, not reproducible |
| M1 | Pre-merge hardening on poc/iced |
Complete (#277) |
| M2 | Merge poc/iced → main |
Complete (#278, 2026-08-02) |
| M3 | Functional parity with the GTK UI | Complete |
| E | Engine track — renderer + robustness | Complete except a deferred Ghostty pin bump |
| M4 | Ship iced to Linux users | Shipped in v0.0.18 |
| M5 | Rust under Swift (FFI exploration) | Frozen |
| M6 | macOS iced (evaluation → parity) | Experimental build shipped in v0.0.18 |
M1–M2 — the POC was merge-quality¶
crates/roost-iced started on the poc/iced branch alongside an
extraction of the toolkit-neutral roost-engine and roost-ui-model
crates, which both Rust UIs then consumed. The 2026-08-02 decision was
that the branch was not a throwaway: the engine extraction was
behavior-preserving, and the iced walking skeleton had proven terminal
integration, custom chrome, and a compile-enforced parity mechanism (the
exhaustive UiRequest match — never a wildcard arm). It merged with
every gate green and retired immediately, because most of the GTK crate
was re-export shims on that branch and every main hotfix diverged it
further.
M3 — functional parity¶
Slices, each a plan of its own: app.rs decomposition into app/
submodules (plan 009), project lifecycle UI (010), sidebar resize (011),
tick → push subscriptions (012), native desktop notifications over
org.freedesktop.Notifications (015), and two chrome-polish batches
against the Mac app's visual language (016, 026) — Inter for chrome,
Mac-matched colors and metrics, bell dots, drag-collapse, dynamic
titles.
The engine track¶
The work that made iced viable rather than merely complete: render baseline measurement, render-state dirty coverage, dirty-row snapshot rebuild, sprite and box-drawing parity (plan 020), IME and dead-key input (021), and crash robustness — a panic hook that writes a crash report instead of exiting silently (019). One measured NO-GO is recorded here rather than quietly dropped: run coalescing (018) did not pay for itself.
M4 — the Linux swap¶
The .deb ships crates/roost-iced as /usr/bin/roost, built with the
linux-package Cargo feature. That feature is off by default and flips
only the compiled-in default profile — ROOST_BUNDLE_PROFILE still
wins, so every dev harness stays correct regardless of how the binary
was built. The packaged build therefore adopts the production profile
the GTK package already owned: same socket, same state.json, same log
directory, same window class and desktop entry. Existing installs
upgrade in place with no migration step, and roostctl plus the
Claude hooks keep working unchanged. GTK4 and libadwaita left the
runtime dependency set.
M5 — frozen¶
roost-engine's facade module (Engine / EngineCommand /
EngineSnapshot / EngineEventStream) is the Swift-facing boundary. It
is feature-gated (roost-engine/facade, off by default, compiled and
tested in CI) and has no production consumer, so the seam is
unproven — tracked as
#286, held at "don't
invest, don't delete" until the Mac-shell question resolves. If a full
iced replacement happens, a Swift-facing FFI boundary is wasted
investment; if it doesn't, this is where the (b) branch starts. See
Direction.
M6 — macOS iced¶
Roost-Iced-<version>.dmg, opt-in and installed beside Roost.app:
- Bundling + parallel install (plan 027) —
mac/scripts/bundle-lib.shcarries the toolkit-agnostic stages out ofbundle.sh;bundle-iced.shassemblesRoost-Iced.appfromcargo build -p roost-icedwith the same signing path. Distinct bundle id, distinct socket/state/log paths, distinct single-instance locks. Claude hooks need no per-app configuration —ROOST_SOCKETis injected into every PTY child and outranks--target, so a session in an iced tab dials the iced socket automatically. - The native seam is
objc2(decided 2026-08-16, after building and running both candidates live):crates/roost-iced/src/macos/,cfg(target_os = "macos"), on theobjc20.6 generation already in the lockfile viaarboardandsoftbuffer. The rejected alternative was a Swift static library behind a C ABI, which would have made Swift a hardcargo checkrequirement on every dev Mac and CI cell — in the crate whose premise is replacing Swift. - Sparkle, menu bar, notifications (plans 028, 030) — user-invoked
update checks against
docs/appcast-iced.xmlwith its own signing key (SUEnableAutomaticChecksisfalse; the app does not self-update), a realNSMenu, a Dock badge, andUNUserNotificationCenterbanners that focus the originating tab on click. - Developer-ID signed and notarized, published as the fourth release asset. The two macOS apps carry separate feeds and separate keys, so neither can ever offer the other's update.
Two things were decided, not discovered: accessibility regresses
(an iced canvas gives essentially no VoiceOver support where AppKit
gives it for free — see DL-16), and dock-menu / reopen-from-Dock /
open-file-URL handling is winit-blocked, with no Swift parity bar to
chase since App.swift implements none of them either.
Current status¶
| macOS | Linux | |
|---|---|---|
| Product | Roost.app — Swift + AppKit |
roost — Rust + iced (.deb) |
| Also shipped | Roost-Iced.dmg — experimental, opt-in |
— |
| CI gates | swift-mac, e2e-mac, and the macOS half of iced-build-e2e |
iced-build-e2e (X11 + Wayland), iced-release |
What remains¶
- The Mac-shell question. Everything above is prologue to one open decision, gated on whether iced visuals on macOS reach parity with the Swift app. Both branches and what each implies are in Direction (under evaluation).
- macOS parity items deferred rather than dropped: window vibrancy
(there is no
NSVisualEffectViewin the Swift source to port — the sidebar's translucency is AppKit's implicit source-list material, so iced has to build it explicitly), and Secure Keyboard Entry (feasible via CarbonEnableSecureEventInput, deferred pending a product decision on the toggle surface, since always-on breaks text expanders). - A macOS real-input tier. The uinput harness is Linux-only; a
CGEvent sibling (#285)
is what would make a macOS parity claim mechanically honest rather
than hand-verified. See
tools/README.mdfor the layer map and test-automation.md for the CI lanes. - The Ghostty pin + Zig bump, deliberately sequenced after the direction resolves rather than before it.