diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b372910..23f38768 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: tests: ${{ steps.filter.outputs.tests }} ci: ${{ steps.filter.outputs.ci }} deb: ${{ steps.filter.outputs.deb }} + macbundle: ${{ steps.filter.outputs.macbundle }} steps: - uses: actions/checkout@v6 with: @@ -97,6 +98,23 @@ jobs: - 'crates/*/Cargo.toml' - '.github/workflows/ci.yml' - '.github/workflows/release.yml' + # Narrow on purpose: gates the `iced-build-e2e` macOS cells' + # bundle-assembly + bundle-smoke steps. The broad `mac` output + # folds in *rustcore, so using it here would make every + # Swift-only PR pay the 2x2 iced matrix. `Makefile` is + # deliberately not repeated — it's already in `tests`, which + # is already OR'd into iced-build-e2e's `if`. + macbundle: + - 'mac/scripts/bundle-lib.sh' + - 'mac/scripts/bundle-iced.sh' + - 'mac/Resources/Info-iced.plist.template' + # Direct bundle-iced.sh inputs shared with the Swift bundle — + # without them an icon- or helper-entitlements-only PR would + # skip the job that assembles Roost-Iced.app. + - 'mac/Resources/roostctl.entitlements' + - 'mac/Resources/AppIcon.icns' + - 'mac/AppIcon.icon/**' + - 'mac/Resources/Roost-Iced.entitlements' rust-lint: needs: changes @@ -479,14 +497,21 @@ jobs: # software Vulkan implementation when the runner exposes no physical GPU. iced-build-e2e: needs: changes - if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true' + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tests == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.macbundle == 'true' strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] renderer: [wgpu, tiny-skia] runs-on: ${{ matrix.os }} - timeout-minutes: 30 + # Was 30. The macOS cells now run a second e2e pass (bundle assembly + + # assert + a 2-module smoke) after their existing full functional-E2E + + # exit-on-empty passes; the Linux cells are unchanged (four lanes: + # X11 functional, X11 exit, X11 real-input clipboard, Wayland + # functional + exit). Bumped rather than risk the macOS cells timing + # out under load — the plan explicitly prefers a timeout bump over + # silently dropping the walking-skeleton module from the smoke subset. + timeout-minutes: 40 steps: - uses: actions/checkout@v6 with: @@ -591,6 +616,7 @@ jobs: tools/roosttest/test_ime.py tools/roosttest/test_selection.py tools/roosttest/test_mouse_tracking.py + tools/roosttest/test_dock_badge.py tools/roosttest/test_osc52.py --roost-target iced --roost-fresh -v @@ -660,6 +686,7 @@ jobs: tools/roosttest/test_ime.py tools/roosttest/test_selection.py tools/roosttest/test_mouse_tracking.py + tools/roosttest/test_dock_badge.py --roost-target iced --roost-fresh -v - name: Run Iced exit-on-empty E2E (Linux Wayland) @@ -704,6 +731,7 @@ jobs: tools/roosttest/test_ime.py tools/roosttest/test_selection.py tools/roosttest/test_mouse_tracking.py + tools/roosttest/test_dock_badge.py tools/roosttest/test_osc52.py --roost-target iced --roost-fresh -v @@ -721,6 +749,116 @@ jobs: tools/roosttest/test_exit_on_empty.py --roost-target iced --roost-fresh -v + # M6 6a (plan 027 W5): the two steps above only ever exercise the bare + # `roost-iced` cargo binary. This assembles + smoke-tests the actual + # macOS deliverable (Roost-Iced.app) so a bundling regression (wrong + # bundle id, missing entitlements, an accidental Frameworks embed) is + # caught here rather than only by a human running `make bundle-iced` + # locally. Debug profile only — never "upgrade" this to release; the + # release-profile lane is `iced-release`, not this job. + - name: Assemble Roost-Iced.app + if: runner.os == 'macOS' + run: ./mac/scripts/bundle-iced.sh debug + + - name: Assert bundle contents + if: runner.os == 'macOS' + run: | + set -euo pipefail + APP="mac/build/Roost-Iced.app" + BIN="$APP/Contents/MacOS/Roost-Iced" + INFO="$APP/Contents/Info.plist" + + plist_value() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" 2>/dev/null || true; } + has_key() { /usr/libexec/PlistBuddy -c "Print :$2" "$1" >/dev/null 2>&1; } + + [ "$(plist_value "$INFO" CFBundleIdentifier)" = "ai.stridelabs.Roost.iced" ] \ + || { echo "FAIL: CFBundleIdentifier != ai.stridelabs.Roost.iced"; exit 1; } + echo "OK: CFBundleIdentifier is ai.stridelabs.Roost.iced" + + [ "$(plist_value "$INFO" CFBundleExecutable)" = "Roost-Iced" ] \ + || { echo "FAIL: CFBundleExecutable != Roost-Iced"; exit 1; } + echo "OK: CFBundleExecutable is Roost-Iced" + + version="$(plist_value "$INFO" CFBundleShortVersionString)" + [ -n "$(echo "$version" | tr -d '[:space:]')" ] \ + || { echo "FAIL: CFBundleShortVersionString is empty"; exit 1; } + [ "$version" != "@VERSION@" ] \ + || { echo "FAIL: CFBundleShortVersionString still has the unsubstituted @VERSION@ placeholder"; exit 1; } + echo "OK: CFBundleShortVersionString is '$version'" + + # No Sparkle parity for the iced bundle (6c decision, not made + # here) — assert none of the keys leaked in from the Swift plist. + for key in SUFeedURL SUPublicEDKey SUEnableAutomaticChecks; do + if has_key "$INFO" "$key"; then + echo "FAIL: Info.plist unexpectedly carries $key"; exit 1 + fi + done + echo "OK: no Sparkle (SU*) keys present" + + [ ! -d "$APP/Contents/Frameworks" ] \ + || { echo "FAIL: $APP/Contents/Frameworks exists (this bundle embeds no frameworks)"; exit 1; } + echo "OK: no Contents/Frameworks dir" + + codesign --verify --deep --strict "$APP" + echo "OK: codesign --verify --deep --strict passed" + + # Extract entitlements as a real plist (codesign's text dump + # format is unstable across macOS versions) and parse with + # PlistBuddy for exact, substring-proof key lookup — same + # technique as the swift-mac job's TCC-entitlements check. + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + ent="$work/entitlements.plist" + codesign -d --entitlements - --xml "$APP" 2>/dev/null > "$ent" + for key in \ + com.apple.security.device.audio-input \ + com.apple.security.device.camera \ + com.apple.security.automation.apple-events; do + # Present is not enough — a `false` value would pass a key + # check while leaving TCC capture ineffective. + val=$(/usr/libexec/PlistBuddy -c "Print :$key" "$ent" 2>/dev/null) \ + || { echo "FAIL: entitlements missing $key"; exit 1; } + [ "$val" = "true" ] \ + || { echo "FAIL: entitlement $key is '$val', want true"; exit 1; } + done + echo "OK: entitlements carry the three capture keys (all true)" + if has_key "$ent" com.apple.security.cs.disable-library-validation; then + echo "FAIL: entitlements unexpectedly carry com.apple.security.cs.disable-library-validation"; exit 1 + fi + echo "OK: entitlements do not carry cs.disable-library-validation" + + codesign -dv "$APP" 2>&1 | grep -q 'flags=.*runtime' \ + || { echo "FAIL: hardened runtime flag not present"; exit 1; } + echo "OK: hardened runtime flag present" + + # Capture otool's output first WITHOUT masking: a missing or + # non-Mach-O binary must fail here, not read as "no deps". + deps=$(otool -L "$BIN" | tail -n +2 | awk '{print $1}') + [ -n "$deps" ] || { echo "FAIL: otool -L returned no dependencies for $BIN"; exit 1; } + bad=$(printf '%s\n' "$deps" | grep -vE '^(/usr/lib/|/System/)' || true) + if [ -n "$bad" ]; then + echo "FAIL: otool -L closure contains non-system paths:" + echo "$bad" + exit 1 + fi + echo "OK: otool -L closure contains only /usr/lib and /System paths" + + - name: Run Iced bundle smoke (macOS) + if: runner.os == 'macOS' + env: + ICED_BACKEND: ${{ matrix.renderer }} + RUST_LOG: warn + ROOST_TEST_MODE: "1" + ROOST_TEST_TIMEOUT_SCALE: "3" + ROOST_ICED_APP: mac/build/Roost-Iced.app + ROOST_E2E_ARTIFACT_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-bundle-artifacts + ROOST_E2E_LOG_DIR: ${{ runner.temp }}/roost-iced-e2e-mac-bundle-logs + run: > + uv run --group test pytest + tools/roosttest/test_smoke.py + tools/roosttest/test_iced_walking_skeleton.py + --roost-target iced --roost-fresh -v + - name: Collect Iced diagnostics if: always() shell: bash @@ -739,7 +877,17 @@ jobs: cp "${screenshot}" "diagnostics/${suite}-$(basename "${screenshot}")" done < <(find "${RUNNER_TEMP}" -path '*/roost-iced-e2e-*-artifacts/*.png' 2>/dev/null) if [ "${RUNNER_OS}" = "macOS" ]; then + # Bundle-mode launches write the persistent profile log (and any + # crash reports) under ~/Library/Logs/Roost-iced, not the + # harness's ROOST_E2E_LOG_DIR — collect them explicitly or a + # bundle boot failure leaves no log artifact. + cp "$HOME"/Library/Logs/Roost-iced/roost.log diagnostics/roost-iced-bundle-persistent.log 2>/dev/null || true + cp "$HOME"/Library/Logs/Roost-iced/crash-*.txt diagnostics/ 2>/dev/null || true cp "$HOME"/Library/Logs/DiagnosticReports/roost-iced*.ips diagnostics/ 2>/dev/null || true + # fnmatch is case-sensitive; the bundled process is named + # `Roost-Iced` (CFBundleExecutable), not `roost-iced`, so its + # crash reports need their own glob. + cp "$HOME"/Library/Logs/DiagnosticReports/Roost-Iced*.ips diagnostics/ 2>/dev/null || true fi ls -la diagnostics || true diff --git a/Cargo.lock b/Cargo.lock index 148d5aa8..8e8db080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3139,6 +3139,10 @@ dependencies = [ "arboard", "iced", "notify-rust", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "png", "roost-engine", "roost-ipc", diff --git a/Makefile b/Makefile index 2e39ac84..bd4227fc 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ $(GHOSTTY_LIB): # ---- build ------------------------------------------------------------ -.PHONY: build build-iced build-mac bundle build-all +.PHONY: build build-iced build-mac bundle bundle-iced build-all build: $(GHOSTTY_LIB) ## cargo build the workspace (GTK UI + roostctl) cargo build @@ -52,6 +52,9 @@ build-mac: $(GHOSTTY_LIB) ## swift build the Mac app bundle: $(GHOSTTY_LIB) ## Build + assemble Roost.app (debug) cd $(MAC_DIR) && ./scripts/bundle.sh debug +bundle-iced: $(GHOSTTY_LIB) ## Build + assemble Roost-Iced.app (debug) + cd $(MAC_DIR) && ./scripts/bundle-iced.sh debug + build-all: build bundle ## Build both UIs + the Mac bundle # ---- run -------------------------------------------------------------- @@ -68,9 +71,12 @@ run-mac: bundle ## Launch the bundled Mac app # ---- test ------------------------------------------------------------- -.PHONY: test test-rust test-iced test-mac test-harness e2e e2e-gtk e2e-iced e2e-iced-exit e2e-iced-clipboard e2e-mac e2e-gtk-ci e2e-iced-ci e2e-iced-release-ci e2e-mac-ci smoke-gtk smoke-iced smoke-mac visual-parity smoke-mac-launch test-real-input test-iced-real-input test-iced-wayland-input check-iced perf-refresh perf-render-stats +.PHONY: test test-rust test-iced test-mac test-harness e2e e2e-gtk e2e-iced e2e-iced-exit e2e-iced-clipboard e2e-mac e2e-gtk-ci e2e-iced-ci e2e-iced-release-ci e2e-mac-ci e2e-iced-bundle smoke-gtk smoke-iced smoke-mac visual-parity smoke-mac-launch test-real-input test-iced-real-input test-iced-wayland-input check-iced perf-refresh perf-render-stats -ICED_E2E_TESTS := tools/roosttest/test_smoke.py tools/roosttest/test_iced_walking_skeleton.py tools/roosttest/test_notifications.py tools/roosttest/test_provider.py tools/roosttest/test_sidebar_pixels.py tools/roosttest/test_tab_strip_pixels.py tools/roosttest/test_focus.py tools/roosttest/test_palette.py tools/roosttest/test_z_typography.py tools/roosttest/test_project_lifecycle.py tools/roosttest/test_sidebar_resize.py tools/roosttest/test_osc_pipeline.py tools/roosttest/test_sprite_pixels.py tools/roosttest/test_ime.py tools/roosttest/test_selection.py tools/roosttest/test_mouse_tracking.py +ICED_E2E_TESTS := tools/roosttest/test_smoke.py tools/roosttest/test_iced_walking_skeleton.py tools/roosttest/test_notifications.py tools/roosttest/test_provider.py tools/roosttest/test_sidebar_pixels.py tools/roosttest/test_tab_strip_pixels.py tools/roosttest/test_focus.py tools/roosttest/test_palette.py tools/roosttest/test_z_typography.py tools/roosttest/test_project_lifecycle.py tools/roosttest/test_sidebar_resize.py tools/roosttest/test_osc_pipeline.py tools/roosttest/test_sprite_pixels.py tools/roosttest/test_ime.py tools/roosttest/test_selection.py tools/roosttest/test_mouse_tracking.py tools/roosttest/test_dock_badge.py +# `test_dock_badge.py` self-skips unless the host is macOS AND the target is +# iced (the Dock badge is the M6 6b seam's consumer), so it costs a skip line +# on the Linux lanes and runs for real on the macOS ones. # `selection.*` reads UI state over IPC and never touches the host # pasteboard, so `test_selection.py` belongs in the list above and runs # under headless Wayland too. Only files that read/write the real @@ -146,6 +152,11 @@ e2e-iced-release-ci: ## Release-profile Iced E2E gate: curated subset against a e2e-mac-ci: ## Mac E2E at CI parity. DESTRUCTIVE: force-quits any running Roost.app ROOST_TEST_MODE=1 uv run --group test pytest tools/roosttest --roost-target mac --roost-fresh +e2e-iced-bundle: ## macOS-only: assemble Roost-Iced.app + run the curated bundle smoke against it (ROOST_ICED_APP) + @[ "$$(uname -s)" = "Darwin" ] || { echo "e2e-iced-bundle is macOS-only: it launches Roost-Iced.app via LaunchServices (open)"; exit 1; } + $(MAKE) bundle-iced + ROOST_ICED_APP=mac/build/Roost-Iced.app ROOST_TEST_MODE=1 uv run --group test pytest tools/roosttest/test_smoke.py tools/roosttest/test_iced_walking_skeleton.py --roost-target iced --roost-fresh + smoke-gtk: ## Screenshot-driven UI smoke against a running GTK UI tools/screenshot/smoke.sh gtk diff --git a/crates/roost-engine/src/ipc.rs b/crates/roost-engine/src/ipc.rs index 42bbb2ab..70057e43 100644 --- a/crates/roost-engine/src/ipc.rs +++ b/crates/roost-engine/src/ipc.rs @@ -22,16 +22,17 @@ use std::sync::Arc; use roost_ipc::agent::{self, TabAgentReportParams}; use roost_ipc::messages::{ ops, AppActivateParams, AppActiveTerminalFocusedParams, AppActiveTerminalFocusedResult, - AppCursorShapeParams, AppCursorShapeResult, AppRenderStatsParams, AppRenderStatsResult, - AppSelectedTabIdParams, AppSelectedTabIdResult, AppSetWindowFocusParams, ClipboardDumpParams, - ClipboardDumpResult, ClipboardWriteParams, IdentifyParams, IdentifyResult, - NotificationCreateParams, PaletteActivateParams, PaletteDismissParams, PaletteOpenParams, - PalettePresentParams, PalettePresentResult, PaletteQueryParams, PaletteStateParams, - PaletteStateResult, ProjectCreateParams, ProjectCreateResult, ProjectDeleteParams, - ProjectRenameParams, ProjectReorderParams, ResolvedCell, ScreenshotParams, ScreenshotResult, - SelectionClearParams, SelectionDumpParams, SelectionDumpResult, SelectionSetParams, - SidebarDumpParams, SidebarDumpResult, SidebarSetWidthParams, TabAgentReportResult, - TabCapturePtyInputParams, TabCapturePtyInputResult, TabClearNotificationParams, TabCloseParams, + AppCursorShapeParams, AppCursorShapeResult, AppDockBadgeParams, AppDockBadgeResult, + AppRenderStatsParams, AppRenderStatsResult, AppSelectedTabIdParams, AppSelectedTabIdResult, + AppSetWindowFocusParams, ClipboardDumpParams, ClipboardDumpResult, ClipboardWriteParams, + IdentifyParams, IdentifyResult, NotificationCreateParams, PaletteActivateParams, + PaletteDismissParams, PaletteOpenParams, PalettePresentParams, PalettePresentResult, + PaletteQueryParams, PaletteStateParams, PaletteStateResult, ProjectCreateParams, + ProjectCreateResult, ProjectDeleteParams, ProjectRenameParams, ProjectReorderParams, + ResolvedCell, ScreenshotParams, ScreenshotResult, SelectionClearParams, SelectionDumpParams, + SelectionDumpResult, SelectionSetParams, SidebarDumpParams, SidebarDumpResult, + SidebarSetWidthParams, TabAgentReportResult, TabCapturePtyInputParams, + TabCapturePtyInputResult, TabClearNotificationParams, TabCloseParams, TabDispatchMouseEventParams, TabDumpCursor, TabDumpParams, TabDumpResolvedParams, TabDumpResolvedResult, TabDumpResult, TabExpandSelectionAtParams, TabExpandSelectionAtResult, TabFeedImeParams, TabFeedPtyBytesParams, TabFocusParams, TabFocusResult, TabListResult, @@ -351,6 +352,14 @@ pub enum UiRequest { AppSelectedTabId { reply: tokio::sync::oneshot::Sender>, }, + /// `app.dock_badge` — read the macOS Dock tile's live badge label + /// (`None` when cleared). The UI reads AppKit rather than + /// recomputing from its notification inbox, so the op proves the + /// badge write actually landed. Gated like `TabFeedPtyBytes` + /// (ROOST_TEST_MODE=1); macOS iced only — the other UIs reject. + AppDockBadge { + reply: tokio::sync::oneshot::Sender, String>>, + }, } /// Resolved clipboard target for the `clipboard.*` ops. Lives in this @@ -1091,6 +1100,14 @@ async fn dispatch( .map_err(|e| HandlerError::new("internal", e))?; encode(&AppSelectedTabIdResult { tab_id }) } + ops::APP_DOCK_BADGE => { + let _: AppDockBadgeParams = decode(params)?; + let label = h + .ui_call(|reply| UiRequest::AppDockBadge { reply }) + .await? + .map_err(map_test_op_err)?; + encode(&AppDockBadgeResult { label }) + } ops::EVENTS_SUBSCRIBE => { // Honest failure rather than a false ACK: the server never // pushes events on the connection yet, so a client that @@ -1146,7 +1163,9 @@ fn rgb_hex(c: (u8, u8, u8)) -> String { /// holds the keyboard route → `invalid-param` — the caller asked /// to feed the wrong tab, not a server failure. /// * an op a UI hasn't wired up yet (`tab.feed_ime` on GTK, still -/// iced-only) → `not-implemented`, mirroring `events.subscribe`. +/// iced-only), or one that is structurally unavailable there +/// (`app.dock_badge` off macOS — there is no Dock) → +/// `not-implemented`, mirroring `events.subscribe`. /// * anything else (capture buffer poisoned, feed channel closed) /// → `internal`, so a real failure surfaces clearly rather than /// being mistaken for a missing tab. diff --git a/crates/roost-engine/tests/ipc_dispatch.rs b/crates/roost-engine/tests/ipc_dispatch.rs index d2896b21..0c1e413b 100644 --- a/crates/roost-engine/tests/ipc_dispatch.rs +++ b/crates/roost-engine/tests/ipc_dispatch.rs @@ -251,6 +251,42 @@ async fn tab_feed_ime_rejects_unknown_action() { } } +/// `app.dock_badge` takes no params, and the empty param struct denies +/// unknown fields. Asserting `unknown-field` (rather than the +/// `internal` / "no UI attached" this handler would give — it has no +/// `ui_tx`) proves the decode happens at the dispatcher, ahead of the +/// UI round trip. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn app_dock_badge_rejects_unknown_params() { + let dir = tempdir().unwrap(); + let socket_path = dir.path().join("roost.sock"); + + let workspace = Arc::new(Workspace::new()); + let supervisor = Arc::new(PtySupervisor::new()); + let handler = IpcHandler::new( + workspace, + supervisor, + socket_path.clone(), + "Roost-test", + "ai.stridelabs.Roost.test", + ); + + let server = IpcServer::bind(&socket_path, handler).await.expect("bind"); + let server_socket = server.socket_path().to_path_buf(); + tokio::spawn(async move { + let _ = server.run().await; + }); + let mut client = connect_with_retry(&server_socket).await; + let err = client + .call_raw(ops::APP_DOCK_BADGE, serde_json::json!({"label": "3"})) + .await + .expect_err("expected error"); + match err { + roost_ipc::ClientError::Server { code, .. } => assert_eq!(code, "unknown-field"), + other => panic!("expected Server error, got {other:?}"), + } +} + /// Connect to a freshly-bound server with bounded retries instead of /// a flat sleep. CI runners under load can take more than 50ms to /// schedule the accept loop; a bounded retry is robust without diff --git a/crates/roost-iced/Cargo.toml b/crates/roost-iced/Cargo.toml index 71ca4814..c8ef5817 100644 --- a/crates/roost-iced/Cargo.toml +++ b/crates/roost-iced/Cargo.toml @@ -66,6 +66,39 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } unicode-width = { workspace = true } +# Main-bundle-identity probe only (`CFBundle::main_bundle().identifier()`), +# not AppKit surface. The version rides the objc2 0.6 generation that +# arboard + softbuffer already pull in (winit still rides objc2 0.5) and +# must move with THAT pair: a bump that shifts objc2-core-foundation's +# major would otherwise resolve a second copy of the crate into the lock. +[target.'cfg(target_os = "macos")'.dependencies] +objc2-core-foundation = { version = "0.3", default-features = false, features = [ + "std", + "CFBundle", + "CFString", +] } + +# The macOS native seam (`src/macos/`, roadmap M6 § 6b — decided by the +# plan-027 spike). Same coupling policy as `objc2-core-foundation` above: +# these ride the objc2 0.6 generation arboard + softbuffer already pull in +# (winit still rides objc2 0.5) and must move with THAT pair — a bump that +# shifts the major would resolve a second copy of the ecosystem into the +# lock. Features are the Dock-badge consumer's minimum, not the crates' +# defaults: `objc2-app-kit`'s `default` turns on ~250 of them. +objc2 = "0.6" +objc2-foundation = { version = "0.3", default-features = false, features = [ + "std", + "NSObject", + "NSString", +] } +objc2-app-kit = { version = "0.3", default-features = false, features = [ + "std", + "NSApplication", + "NSDockTile", + # `NSApplication`'s superclass; the class item does not exist without it. + "NSResponder", +] } + # The desktop-notification backend is per-OS: this one talks # `org.freedesktop.Notifications` over D-Bus. `z-with-tokio` keeps zbus on # the app's tokio runtime instead of pulling in async-io/smol. diff --git a/crates/roost-iced/src/app.rs b/crates/roost-iced/src/app.rs index 97408a51..d1834615 100644 --- a/crates/roost-iced/src/app.rs +++ b/crates/roost-iced/src/app.rs @@ -33,7 +33,7 @@ use roost_ipc::messages::{ AppRenderStatsResult, PaletteItemView, PalettePresentResult, PaletteStateResult, Project, SidebarDumpAgentRow, SidebarDumpProject, SidebarDumpResult, WindowMetricsResult, }; -use roost_ipc::paths::BundleProfile; +use roost_ipc::paths::{BundleProfile, BundleProfileKind}; use roost_ipc::IpcServer; use roost_ui_model::theme::Theme; use roost_ui_model::typography::{self, FamilyApply, TerminalTypography}; @@ -968,6 +968,30 @@ fn prepare_window_opened( } } +/// App-identity name the window title falls back to when no project supplies +/// one. +/// +/// Keyed off the resolved profile, not the OS: the macOS Iced bundle and the +/// Linux *dev* iced profile both announce `Roost-Iced` (matching the bundle's +/// `CFBundleName`), while the packaged Linux build resolves `Gtk` and keeps +/// the production `Roost` users already see. +/// Pure half of [`App::window_title`]: `project` is the active project's +/// `(name, effective cwd)`, `None` when no project is active. Split out so +/// tests can pin that BOTH branches thread the profile-chosen fallback. +fn compose_window_title(fallback: &str, project: Option<(&str, &str)>, home: &str) -> String { + match project { + None => fallback.to_string(), + Some((name, cwd)) => window_title::window_title_with_fallback(fallback, name, cwd, home), + } +} + +fn title_fallback(kind: BundleProfileKind) -> &'static str { + match kind { + BundleProfileKind::Iced => "Roost-Iced", + BundleProfileKind::Mac | BundleProfileKind::Gtk => window_title::DEFAULT_WINDOW_TITLE, + } +} + pub struct App { workspace: Arc, supervisor: Arc, @@ -985,6 +1009,9 @@ pub struct App { /// `state.json` on every frame. sidebar_drag_width: Option, window_focused: bool, + /// App-identity name the window title falls back to, fixed at bootstrap + /// from the resolved profile — see [`title_fallback`]. + title_fallback: &'static str, ime_discard: ImeDiscard, modifiers: keyboard::Modifiers, test_mode: bool, @@ -1161,6 +1188,7 @@ impl App { window_size: Size::new(1100.0, 720.0), sidebar_drag_width: None, window_focused: true, + title_fallback: title_fallback(profile.kind), ime_discard: ImeDiscard::default(), modifiers: keyboard::Modifiers::default(), test_mode: std::env::var("ROOST_TEST_MODE").as_deref() == Ok("1"), @@ -1229,6 +1257,11 @@ impl App { } pub fn window_opened(&mut self, id: window::Id) -> UiTask { + // The initial Dock-badge sync: `App::new`'s reconcile runs before + // iced has a window, i.e. before there is an app to badge. From + // here on the reconcile owns it, and a repeat from a later + // `WindowFocus` is an idempotent rewrite of the same label. + self.sync_dock_badge(); prepare_window_opened( &mut self.window_id, &mut self.pending_window_resize, @@ -2822,17 +2855,17 @@ impl App { /// OSC 7 only. pub fn window_title(&self, home: &str) -> String { let (project_id, tab_id) = self.workspace.active(); - let Some(project) = self.projects.iter().find(|p| p.id == project_id) else { - return window_title::DEFAULT_WINDOW_TITLE.to_string(); - }; - let cwd = project - .tabs - .iter() - .find(|tab| tab.id == tab_id) - .map(|tab| tab.cwd.as_str()) - .filter(|cwd| !cwd.is_empty()) - .unwrap_or(project.cwd.as_str()); - window_title::window_title(&project.name, cwd, home) + let project = self.projects.iter().find(|p| p.id == project_id).map(|p| { + let cwd = p + .tabs + .iter() + .find(|tab| tab.id == tab_id) + .map(|tab| tab.cwd.as_str()) + .filter(|cwd| !cwd.is_empty()) + .unwrap_or(p.cwd.as_str()); + (p.name.as_str(), cwd) + }); + compose_window_title(self.title_fallback, project, home) } fn launch_cwd(&self, project_id: i64) -> String { @@ -3025,6 +3058,38 @@ mod tests { assert_eq!(state, ExitState::Running); } + #[test] + fn the_iced_profile_titles_itself_roost_iced() { + assert_eq!(title_fallback(BundleProfileKind::Iced), "Roost-Iced"); + assert_eq!(title_fallback(BundleProfileKind::Mac), "Roost"); + assert_eq!( + title_fallback(BundleProfileKind::Gtk), + "Roost", + "the packaged Linux build resolves Gtk and keeps the production name" + ); + } + + /// The fallback has to reach BOTH `App::window_title` branches — the + /// no-project early return and the composed title — through the same + /// `compose_window_title` the method calls, so neither branch can + /// quietly revert to a hardcoded "Roost". + #[test] + fn the_iced_fallback_composes_into_the_full_title() { + let fallback = title_fallback(BundleProfileKind::Iced); + assert_eq!( + compose_window_title(fallback, None, "/Users/me"), + "Roost-Iced" + ); + assert_eq!( + compose_window_title(fallback, Some(("", "/tmp")), "/Users/me"), + "Roost-Iced – /tmp" + ); + assert_eq!( + compose_window_title(fallback, Some(("strix", "/Users/me/w")), "/Users/me"), + "strix – ~/w" + ); + } + #[test] fn terminal_geometry_never_produces_zero_grid() { let size = Size::new(1.0, 1.0); diff --git a/crates/roost-iced/src/app/servicing.rs b/crates/roost-iced/src/app/servicing.rs index 8c02a8cb..3886db3c 100644 --- a/crates/roost-iced/src/app/servicing.rs +++ b/crates/roost-iced/src/app/servicing.rs @@ -194,6 +194,25 @@ fn notification_activation( Some(window_id.map_or(UiTask::None, UiTask::Focus)) } +/// The `app.dock_badge` read, on the main thread. The IPC drain runs in +/// the iced update loop, so the marker is obtainable; `None` would be an +/// invariant break, and surfacing it as an error is what makes the e2e +/// fail loudly instead of reading a plausible "badge cleared". +#[cfg(target_os = "macos")] +fn read_dock_badge() -> Result, String> { + let mtm = objc2::MainThreadMarker::new() + .ok_or("app.dock_badge serviced off the main thread (AppKit is main-thread-only)")?; + Ok(crate::macos::dock_badge::read(mtm)) +} + +/// The iced UI also builds for Linux, where there is no Dock. Same +/// verdict as the GTK arm: reject, so the op can never report a cleared +/// badge on a platform that has none. +#[cfg(not(target_os = "macos"))] +fn read_dock_badge() -> Result, String> { + Err("app.dock_badge is not supported on this UI (macOS iced only)".into()) +} + impl App { pub(super) fn reconcile(&mut self) { // A full authoritative snapshot on every reconcile is the recovery @@ -207,6 +226,12 @@ impl App { self.reconcile_project_drag_preview(); self.reconcile_rename_editor(); self.reconcile_notification_inbox(); + // Immediately after the inbox reconcile, not on the fire/clear + // edges: this is the authoritative resync, so hanging the badge + // off it covers fire, clear, tab close and project delete by + // construction — the same reason the palette refresh below sits + // here. + self.sync_dock_badge(); self.refresh_notification_palette(); self.refresh_sidebar_agents(); self.refresh_agent_palette(); @@ -611,6 +636,30 @@ impl App { } } + /// Mirror the notification-inbox count onto the macOS Dock tile — + /// the parity port of `mac/Sources/Roost/App.swift`'s + /// `refreshDockBadge()`. A no-op on every other host. + /// + /// Both callers (this reconcile and `window_opened`) run in the iced + /// update loop, which is the main thread — the seam's + /// `MainThreadMarker` acquisition is what enforces that, per + /// CLAUDE.md's threading table. Nothing off the update loop may call + /// this. + pub(super) fn sync_dock_badge(&self) { + #[cfg(target_os = "macos")] + { + // Bootstrap's initial reconcile() runs before iced constructs + // the winit event loop, and winit documents + // NSApplication::sharedApplication before EventLoop::new as + // unsupported — so no AppKit until the window exists. The + // window_opened initial sync covers boot. + if self.window_id.is_none() { + return; + } + crate::macos::dock_badge::sync(self.notification_inbox.count()); + } + } + fn refresh_sidebar_agents(&mut self) { let active_tab = self.workspace.active().1; let now = agent_palette::now_unix(); @@ -946,6 +995,20 @@ impl App { UiRequest::AppSelectedTabId { reply } => { let _ = reply.send(Ok(self.workspace.active().1)); } + UiRequest::AppDockBadge { reply } => { + // Reads AppKit, deliberately without re-deriving the + // label from the inbox first: the op exists to prove the + // badge write reached the Dock, and a resync here would + // make it prove only the mapping. Platform rejection + // outranks the test-mode gate so non-macOS iced answers + // not-implemented like GTK does, not not-enabled. + let result = if cfg!(target_os = "macos") && !self.test_mode { + Err("ROOST_TEST_MODE=1 is required".into()) + } else { + read_dock_badge() + }; + let _ = reply.send(result); + } UiRequest::Screenshot { scale, reply } => { self.screenshots.enqueue(scale, reply); } diff --git a/crates/roost-iced/src/macos/dock_badge.rs b/crates/roost-iced/src/macos/dock_badge.rs new file mode 100644 index 00000000..bf747ede --- /dev/null +++ b/crates/roost-iced/src/macos/dock_badge.rs @@ -0,0 +1,74 @@ +//! Dock-tile badge — parity port of `mac/Sources/Roost/App.swift`'s +//! `refreshDockBadge()`: +//! +//! ```swift +//! NSApp.dockTile.badgeLabel = count > 0 ? String(count) : nil +//! ``` +//! +//! The whole path is safe API in the 0.3 bindings — no `unsafe` block in +//! this file. + +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; +use objc2_foundation::NSString; + +/// The badge text for a notification-inbox count. `None` at zero: AppKit +/// draws nothing for an absent label, so `None` is how the badge +/// disappears rather than showing a "0". +/// +/// Pure, so the mapping is pinned by unit tests instead of by an +/// AppKit round trip. +pub(crate) fn label(count: usize) -> Option { + (count > 0).then(|| count.to_string()) +} + +/// Read the badge back off the live Dock tile. +/// +/// Backs the `app.dock_badge` test-mode op, and deliberately reads AppKit +/// rather than recomputing from the inbox — recomputing would assert the +/// mapping (which [`label`]'s unit tests already do) while proving nothing +/// about whether the write ever reached the Dock. +pub(crate) fn read(mtm: MainThreadMarker) -> Option { + NSApplication::sharedApplication(mtm) + .dockTile() + .badgeLabel() + .map(|text| text.to_string()) +} + +/// Acquire the main-thread marker and write [`label`] onto the Dock tile. +/// +/// `MainThreadMarker::new()` cannot fail for the callers this has — the +/// iced update loop is the main thread — so `None` means a real invariant +/// break. It logs and skips instead of panicking: the badge is cosmetic, +/// and taking the whole UI down over it would be the worse failure. +pub(crate) fn sync(count: usize) { + let Some(mtm) = MainThreadMarker::new() else { + tracing::error!( + count, + "dock badge sync ran off the main thread; skipping (AppKit is main-thread-only)" + ); + return; + }; + let text = label(count).map(|text| NSString::from_str(&text)); + NSApplication::sharedApplication(mtm) + .dockTile() + .setBadgeLabel(text.as_deref()); +} + +#[cfg(test)] +mod tests { + use super::label; + + #[test] + fn zero_clears_the_badge() { + assert_eq!(label(0), None); + } + + #[test] + fn a_pending_count_is_its_decimal_string() { + assert_eq!(label(1).as_deref(), Some("1")); + assert_eq!(label(9).as_deref(), Some("9")); + // `notification_inbox::CAP` is 10, so double digits are reachable. + assert_eq!(label(10).as_deref(), Some("10")); + } +} diff --git a/crates/roost-iced/src/macos/mod.rs b/crates/roost-iced/src/macos/mod.rs new file mode 100644 index 00000000..143f1361 --- /dev/null +++ b/crates/roost-iced/src/macos/mod.rs @@ -0,0 +1,25 @@ +//! The macOS native seam — the one place in this crate that talks to +//! AppKit. +//! +//! Roadmap M6 § 6b picked `objc2` over a Swift static-lib shim on spike +//! evidence (plan 027 C6): zero new build machinery, one ObjC ecosystem +//! covering the known consumers, and the generation it rides +//! (`objc2 0.6` / `objc2-app-kit 0.3` / `objc2-foundation 0.3`) is +//! already compiled into every macOS build via `arboard` + `softbuffer`. +//! `Cargo.toml` carries the version-coupling policy that follows from +//! that. +//! +//! Two rules hold for everything under here: +//! +//! * **Main thread only.** AppKit is main-thread-only (CLAUDE.md's +//! threading table), so every entry point either takes a +//! [`objc2::MainThreadMarker`] or acquires one and refuses to proceed +//! without it. The iced update loop *is* the main thread, which is why +//! every caller lives there. +//! * **Nothing retained escapes.** Callers hand in plain data and get +//! plain data back; no `Retained<_>` crosses out of this module. +//! +//! First consumer: [`dock_badge`], the parity port of `App.swift`'s +//! `refreshDockBadge()`. + +pub(crate) mod dock_badge; diff --git a/crates/roost-iced/src/main.rs b/crates/roost-iced/src/main.rs index 3b79c565..9196e222 100644 --- a/crates/roost-iced/src/main.rs +++ b/crates/roost-iced/src/main.rs @@ -3,6 +3,11 @@ mod chrome; mod engine_feed; mod font_registry; mod input; +/// The AppKit seam. `cfg`'d whole rather than stubbed per-function: every +/// call site pairs with a `not(macos)` no-op of its own, so nothing outside +/// macOS ever names an AppKit type. +#[cfg(target_os = "macos")] +mod macos; mod notifications; mod palette_scroll; mod paste_image; @@ -151,15 +156,83 @@ fn default_profile_kind(packaged: bool, linux: bool) -> BundleProfileKind { } } -/// The two `cfg!`s `main` actually resolves its profile from, hoisted to +/// `CFBundleIdentifier` of the macOS Iced bundle (`mac/Resources/ +/// Info-iced.plist.template`), the only id this mapping recognizes. +const ICED_BUNDLE_ID: &str = "ai.stridelabs.Roost.iced"; + +/// The macOS default profile, decided from the app bundle the binary is +/// running out of rather than from compile-time flags. +/// +/// Behaviorally a no-op today — every input maps to `Iced`, which is already +/// what `default_profile_kind` yields on macOS. What it buys is the seam plus +/// the logged identity: 6c's cutover has to map an id onto a kind, and the id +/// it will map (`ai.stridelabs.Roost`, the Swift app's) is deliberately NOT +/// recognized here, because deciding what the production id resolves to is +/// 6c's call, not this slice's. +/// +/// `None` is the ordinary case for a bare `target/debug/roost-iced`: an +/// unbundled process still has a main `CFBundle`, but its identifier is nil. +fn mac_bundle_default_kind(bundle_id: Option<&str>) -> BundleProfileKind { + match bundle_id { + Some(ICED_BUNDLE_ID) => BundleProfileKind::Iced, + _ => BundleProfileKind::Iced, + } +} + +/// The compiled-in default `main` starts from, per host OS. macOS defers to +/// the bundle probe; every other host keeps the `linux-package` logic above +/// untouched, so a packaged Linux build still resolves `Gtk`. +fn host_default_kind( + packaged: bool, + linux: bool, + macos: bool, + bundle_id: Option<&str>, +) -> BundleProfileKind { + if macos { + mac_bundle_default_kind(bundle_id) + } else { + default_profile_kind(packaged, linux) + } +} + +/// Read the running process's main-bundle identifier. `None` when the binary +/// is not running out of a `.app` (or the bundle declares no identifier). +/// +/// A `CFBundle` identifier read is not AppKit surface, so this stays correct +/// whatever the M6 6b native-seam decision turns out to be. +#[cfg(target_os = "macos")] +fn main_bundle_identifier() -> Option { + objc2_core_foundation::CFBundle::main_bundle()? + .identifier() + .map(|id| id.to_string()) +} + +#[cfg(not(target_os = "macos"))] +fn main_bundle_identifier() -> Option { + None +} + +/// The three `cfg!`s `main` actually resolves its profile from, hoisted to /// consts so a test can assert against the same values rather than /// re-evaluating its own copy (which would pass even if these drifted). const PACKAGED: bool = cfg!(feature = "linux-package"); const PACKAGED_PLATFORM: bool = cfg!(target_os = "linux"); +const HOST_MACOS: bool = cfg!(target_os = "macos"); fn main() -> anyhow::Result<()> { - let profile = BundleProfile::resolve(default_profile_kind(PACKAGED, PACKAGED_PLATFORM))?; + let bundle_id = main_bundle_identifier(); + let profile = BundleProfile::resolve(host_default_kind( + PACKAGED, + PACKAGED_PLATFORM, + HOST_MACOS, + bundle_id.as_deref(), + ))?; init_logging(&profile)?; + tracing::info!( + bundle_id = bundle_id.as_deref().unwrap_or("unbundled"), + profile = profile.kind.as_str(), + "resolved bundle identity" + ); roost_engine::crash::install_panic_hook( profile.log_dir.clone(), profile.app_label, @@ -872,6 +945,66 @@ mod tests { assert_eq!(default_profile_kind(false, false), BundleProfileKind::Iced); } + /// Every cell is `Iced` on purpose — see `mac_bundle_default_kind`. The + /// table exists so a later change to any one of these rows (6c mapping + /// the production id) is a visible, deliberate edit rather than a silent + /// behavior shift. + #[test] + fn the_mac_bundle_probe_resolves_iced_for_every_identity() { + assert_eq!( + mac_bundle_default_kind(Some(ICED_BUNDLE_ID)), + BundleProfileKind::Iced + ); + assert_eq!( + mac_bundle_default_kind(Some("ai.stridelabs.Roost")), + BundleProfileKind::Iced, + "the Swift app's id is not mapped here — that cutover is 6c's" + ); + assert_eq!( + mac_bundle_default_kind(Some("com.example.Other")), + BundleProfileKind::Iced + ); + assert_eq!( + mac_bundle_default_kind(None), + BundleProfileKind::Iced, + "a bare binary has no main-bundle identifier" + ); + } + + /// The probe must not reach the non-macOS default: a packaged Linux + /// build still resolves `Gtk` no matter what the (always-`None`) bundle + /// id says. + #[test] + fn only_macos_defers_to_the_bundle_probe() { + assert_eq!( + host_default_kind(true, true, false, None), + BundleProfileKind::Gtk + ); + assert_eq!( + host_default_kind(true, true, false, Some(ICED_BUNDLE_ID)), + BundleProfileKind::Gtk + ); + assert_eq!( + host_default_kind(true, true, true, None), + BundleProfileKind::Iced, + "on macOS the bundle probe decides, not the packaging feature" + ); + assert_eq!( + host_default_kind(false, false, false, None), + BundleProfileKind::Iced + ); + } + + /// A bare `cargo test` binary is never inside a `.app`, so the probe must + /// report `None` rather than picking up some ambient bundle. On Linux + /// this is the stub; on macOS it is the real CFBundle read, which is the + /// case worth pinning (an unbundled process HAS a main bundle — only its + /// identifier is nil). + #[test] + fn an_unbundled_process_reports_no_identifier() { + assert_eq!(main_bundle_identifier(), None); + } + /// The four-cell test above never compiles the feature, so it passes /// whether or not the gate reaches the call site. This one asserts on /// `PACKAGED` — the same const `main` resolves from — so it fails if the diff --git a/crates/roost-ipc/src/messages.rs b/crates/roost-ipc/src/messages.rs index 51d77583..f63783b7 100644 --- a/crates/roost-ipc/src/messages.rs +++ b/crates/roost-ipc/src/messages.rs @@ -765,6 +765,23 @@ pub struct AppSelectedTabIdResult { pub tab_id: i64, } +/// `app.dock_badge` request: read the macOS Dock tile's live badge +/// label. Gated like `tab.feed_pty_bytes` (ROOST_TEST_MODE=1), and +/// implemented only by the macOS iced UI — every other UI answers +/// `not-implemented`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AppDockBadgeParams {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppDockBadgeResult { + /// The badge text AppKit currently holds — the notification-inbox + /// count as a decimal string, or `null` when the badge is cleared + /// (the UI writes `nil` at zero, matching `App.swift`). Read back + /// off the Dock tile, never recomputed from the inbox. + pub label: Option, +} + /// `tab.expand_selection_at` response: the committed selection's /// bounds, mirroring `WordSpan`. `text` is the extracted selection /// content (same path `selection.dump` uses), or `None` when the @@ -1355,6 +1372,13 @@ pub mod ops { /// compositor focus and becomes false while an in-app overlay owns input. pub const APP_ACTIVE_TERMINAL_FOCUSED: &str = "app.active_terminal_focused"; + /// Test-only read of the macOS Dock tile's badge label. Same gate + /// as `tab.feed_pty_bytes`; the e2e suite drives a notification and + /// asserts the badge AppKit actually holds, which no unit test can + /// observe. macOS iced only — every other UI answers + /// `not-implemented`. + pub const APP_DOCK_BADGE: &str = "app.dock_badge"; + /// `app.selected_tab_id` — the active project's on-screen selected /// tab id (UI truth), for asserting the core and the displayed tab /// agree. Implemented by the Rust UI adapters; read-only, not gated. @@ -2145,6 +2169,21 @@ mod tests { assert!(serde_json::from_str::(bad).is_err()); } + #[test] + fn app_dock_badge_round_trips() { + round_trip(&AppDockBadgeParams {}); + round_trip(&AppDockBadgeResult { + label: Some("3".into()), + }); + round_trip(&AppDockBadgeResult { label: None }); + // The cleared badge is `null`, not an omitted or empty field — + // the e2e distinguishes "no badge" from "a badge reading ''". + let cleared = serde_json::to_string(&AppDockBadgeResult { label: None }).unwrap(); + assert_eq!(cleared, r#"{"label":null}"#); + let bad = r#"{"extra":"x"}"#; + assert!(serde_json::from_str::(bad).is_err()); + } + #[test] fn app_selected_tab_id_round_trips() { round_trip(&AppSelectedTabIdParams {}); diff --git a/crates/roost-linux/src/app.rs b/crates/roost-linux/src/app.rs index c0a41c97..e92ccc8f 100644 --- a/crates/roost-linux/src/app.rs +++ b/crates/roost-linux/src/app.rs @@ -1111,6 +1111,17 @@ impl App { "tab.feed_ime is not supported on this UI (iced only)".into(), )); } + // No Dock on Linux, and no AppKit wiring in the + // macOS dev build of this UI either. Reject + // rather than answer a plausible `null`, which + // would read as "the badge is cleared" and pass + // a badge test that never ran. + UiRequest::AppDockBadge { reply } => { + let _ = reply.send(Err( + "app.dock_badge is not supported on this UI (macOS iced only)" + .into(), + )); + } UiRequest::WindowMetrics { reply } => { let _ = reply.send(app.ipc_window_metrics()); } diff --git a/crates/roost-ui-model/src/window_title.rs b/crates/roost-ui-model/src/window_title.rs index 76bfaf07..082e3139 100644 --- a/crates/roost-ui-model/src/window_title.rs +++ b/crates/roost-ui-model/src/window_title.rs @@ -75,8 +75,23 @@ fn collapse_home(path: &str, home: &str) -> String { /// Emptiness follows the Swift check exactly (no trimming), so a /// whitespace-only project name renders as-is rather than falling back. pub fn window_title(project_name: &str, cwd: &str, home: &str) -> String { + window_title_with_fallback(DEFAULT_WINDOW_TITLE, project_name, cwd, home) +} + +/// [`window_title`] with the app-identity fallback supplied by the caller. +/// +/// Roost ships under more than one display name — the macOS Iced bundle is +/// `Roost-Iced` — and the titlebar should read as the app the user launched +/// when no project name is available. Composition is otherwise identical, so +/// the fallback is the only thing an adapter has to decide. +pub fn window_title_with_fallback( + fallback: &str, + project_name: &str, + cwd: &str, + home: &str, +) -> String { let name = if project_name.is_empty() { - DEFAULT_WINDOW_TITLE + fallback } else { project_name }; @@ -182,6 +197,35 @@ mod tests { assert_eq!(window_title("", "/tmp", HOME), "Roost – /tmp"); } + #[test] + fn a_caller_supplied_fallback_replaces_the_default() { + assert_eq!( + window_title_with_fallback("Roost-Iced", "", "", HOME), + "Roost-Iced" + ); + assert_eq!( + window_title_with_fallback("Roost-Iced", "", "/tmp", HOME), + "Roost-Iced – /tmp" + ); + } + + /// The fallback is exactly that: a named project always wins. + #[test] + fn a_project_name_wins_over_the_fallback() { + assert_eq!( + window_title_with_fallback("Roost-Iced", "roost", "/tmp", HOME), + "roost – /tmp" + ); + } + + #[test] + fn window_title_delegates_with_the_default_fallback() { + assert_eq!( + window_title("", "/tmp", HOME), + window_title_with_fallback(DEFAULT_WINDOW_TITLE, "", "/tmp", HOME) + ); + } + #[test] fn title_abbreviates_a_long_cwd() { let deep = format!("{HOME}/{}", "segment/".repeat(12)); diff --git a/docs/development/iced-migration-roadmap.md b/docs/development/iced-migration-roadmap.md index ce356433..c5845b50 100644 --- a/docs/development/iced-migration-roadmap.md +++ b/docs/development/iced-migration-roadmap.md @@ -827,9 +827,12 @@ macOS side-by-side evaluation" note. Two standing consequences: from M5 (Iced replaces Swift vs. Rust under Swift); keep them separate so the frozen thing stays frozen. -**Entry gate:** E5 (plan 020) and E7 (plan 019) are both complete; -remaining: release-profile CI coverage for Iced, and the -parity-inventory audit clean. Note the reference bar here is the **Swift +**Entry gate: met.** E5 (plan 020) and E7 (plan 019) are both complete; +release-profile CI coverage for Iced shipped as the `iced-release` job +(plan 022 C2, ci.yml), and the parity-inventory audit is clean (refresh +audit 2026-08-07, plan 021, main@166d2d6 — see the M4 section above: open +P0 none, remaining P1s are Charlie-directed 3h polish + #302-blocked file +drops, not inventory gaps). Note the reference bar here is the **Swift app**, which is consistently stricter than M4's GTK bar — the inventory tracks both, and M6 is the superset. @@ -879,12 +882,166 @@ Slices: output, not fix in code: `claude install` writes `self_exe()`, so with two bundles the hook file points at whichever `roostctl` ran it last. Two bundle ids also mean two entries in System Settings › Notifications. + +**Shipped (plan 027, 2026-08-16).** `mac/scripts/bundle-lib.sh` (new) +carries the toolkit-agnostic stages — version derivation, libghostty-vt +precondition, icon pipeline, roostctl build+embed, signing — +out of `bundle.sh`, verified behavior-preserving (byte-identical file +set/plist/entitlements before/after). `mac/scripts/bundle-iced.sh` (new) +sources the same lib and assembles `mac/build/Roost-Iced.app` from +`cargo build -p roost-iced` (`make bundle-iced`): ad-hoc/dev-id signing +(same `ROOST_DEVELOPER_ID_IDENTITY`/`ROOST_ALLOW_UNSIGNED` defaults as +the Swift bundle), no Sparkle keys and no `Contents/Frameworks/`, all +three TCC purpose strings kept (Roost-Iced is the TCC-responsible app +for its own tab children), `cs.disable-library-validation` omitted +(no embedded frameworks to need it), roostctl embedded, and the +existing AppIcon art reused as-is — a **distinct icon is recorded +future work**, not shipped; display name disambiguates the Dock for +now. Live parallel-install was verified on-machine: the bundle +launched via `open`, answered `identify`/`screenshot` on its own +`Roost-iced` socket while the production `Roost.app` kept answering on +its own, and quit was confirmed by pid. + +The bundle-id-aware default profile (W3) lands as a macOS-only +`CFBundleGetMainBundle` probe via `objc2-core-foundation` (already +resolved in the lock through winit — no new toolchain), feeding a +pure, table-tested mapping: `ai.stridelabs.Roost.iced` → `Iced`, and +everything else — including the production id — also resolves `Iced` +today (the production-id cutover mapping is deliberately **not** +taken; that's 6c). `ROOST_BUNDLE_PROFILE` still wins over the probe, +and the detected identity is logged at startup regardless of which +arm fires, so the probe is behaviorally a no-op now but proven wired +for 6c. + +Window title: **the earlier "window title should match" decision is +narrowed** to the *fallback* title only — the composed title stays +`project – cwd`; app identity comes from `CFBundleName`/ +`CFBundleDisplayName`, not the titlebar. The fallback itself is keyed +off resolved profile kind, not OS: `Iced` → `"Roost-Iced"`, else +`"Roost"` — which means the Linux **dev** iced profile now also +titles `"Roost-Iced"` (a consistent dev identity; no harness asserts +on it). Packaged Linux is unaffected: it resolves the `Gtk` kind → +`"Roost"`, unchanged. + +The harness gained a bundle-launch path: `ROOST_ICED_APP` in +`tools/roosttest/ui.py` drives the iced target through +LaunchServices (`open --env`) with an enumerated env allowlist +(deliberately **not** forwarding `ROOST_BUNDLE_PROFILE` — the +bundle-id path above is the thing under test), pid-based +teardown-with-proof-of-death, and a test-mode canary so a dropped +`ROOST_TEST_MODE` fails loudly. `make e2e-iced-bundle` assembles the +bundle and runs the curated smoke + walking-skeleton modules against +it. CI: a narrow `macbundle` path filter (bundle scripts, the iced +plist/entitlements, the shared icon + roostctl-entitlements inputs) +OR'd into `iced-build-e2e`'s condition, so Swift-only PRs never pay +the 2×2 iced matrix; the macOS cells gained assemble + mechanical +bundle-identity assertion (bundle id, executable name, stamped +version, absence of all `SU*` keys and `Contents/Frameworks/`, deep +codesign verify, entitlements present-and-true minus +disable-library-validation, hardened runtime, system-only `otool -L` +closure) + bundle-smoke steps, `ICED_BACKEND` forwarded from the +renderer matrix. + +**Caveat carried forward, not fixed here**: ad-hoc signatures change +CDHash every rebuild, so any TCC grants made to `ai.stridelabs.Roost.iced` +reset on the next `make bundle-iced`. Not a 6a blocker (nothing here +exercises mic/camera), but it will bite dev-bundle TCC testing at 6e/6g. + * **6b. Native shim seam.** One decision with three consumers (6c/6d/6e): call AppKit via `objc2` (already in the lockfile through winit) or - build a small Swift static library behind a C ABI. Leaning Swift lib — - it amortizes across Sparkle, menus, and notifications, and the Swift - toolchain is already a build dependency. Decide with a spike, not in - the abstract. + build a small Swift static library behind a C ABI. Decide with a + spike, not in the abstract. (The earlier "leaning Swift lib" framing + below was the pre-spike prior — the spike below reversed it.) + +**6b decision — native macOS seam: objc2** (decided 2026-08-16) + +The seam is `crates/roost-iced/src/macos/`, `cfg(target_os = "macos")`, +built on the `objc2` 0.6 generation (`objc2 0.6.4`, `objc2-app-kit 0.3.2`, +`objc2-foundation 0.3.2`) with **minimal feature sets** rather than the +crates' broad defaults. No Swift shim, no `build.rs`, no second toolchain. + +Both routes were **built and run live** on macOS 26 / Xcode 26 (Apple +Swift 6.3.2), not one built and one argued. Full evidence — commands, +outputs, diffs — in the plan artifact folder (`c6-spike-evidence.md`, +`~/.claude/plans/roost/027-mac-iced-bundle/`). + +| Criterion | objc2 | Swift static lib | +|---|---|---| +| Build/CI complexity | +3 `Cargo.lock` lines, **0 new packages**; no build.rs; no new toolchain | new `build.rs` invoking `swiftc`; Swift becomes a hard `cargo check` requirement on `rust-build` (macOS cell), `iced-build-e2e` ×2 and every dev Mac; new build-order dependency inside the cargo graph | +| Consumer coverage (6c–6g) | 6d ✔, 6e ✔ (`objc2-user-notifications 0.3.2`, same generation), 6f ✔, 6g ✔ proven; 6c Sparkle hand-written `msg_send!` | all ✔, but every callback needs a hand-matched `@_cdecl` + `@convention(c)` pair — cost scales with menu items / notification actions; 6c needs a vendored `Sparkle.framework` (SwiftPM cannot be consumed by a bare `swiftc` from build.rs) | +| Maintenance surface | 5 `unsafe` in a 163-line probe; **0 `unsafe` in the dock-badge consumer**; clippy clean under workspace lints | no Rust `unsafe`, but an unchecked C ABI wall maintained by hand in two languages, plus a toolchain to pin | +| Dependency-bar fit | pure Rust; the 0.6 generation is **already** in the graph via `arboard` + `softbuffer` | a second language toolchain in the crate whose premise is replacing Swift | +| Compile friction to first clean build | 2 iterations (1 error; the compiler named the fix) | 2 iterations (1 `rustc-check-cfg` placement warning) | + +**objc2 probe results (8/8 pass, screen locked throughout):** +`iced::window::run` → `RawWindowHandle::AppKit` (non-null `ns_view`) → +`MainThreadMarker::new()` returns `Some` **inside** the callback → +retained `NSView` (class `WinitView`) → `view.window()` non-nil, title +read back. `NSApp.dockTile.badgeLabel` round-trips (`"3"` → `"3"`; +`None` → nil). A Rust class declared with `define_class!`, installed as +an `NSMenuItem` target/action and set as `NSApp.mainMenu`, had its +**Rust method body execute** under both `performActionForItemAtIndex:` +and `NSApp.sendAction:to:from:` — the retained-delegate-calls-back-into- +Rust mechanism 6d and 6e both depend on. No disqualifier was hit +(marker obtainable, handle lifetime scoped to the callback with +nothing retained escaping, no version conflict, callback proof passed +twice). + +**Swift probe results:** `swiftc -emit-library -static` produced a +3.7 KB archive in 0.5 s (a realistic 57-line AppKit + UserNotifications +shim: 35.9 KB, 3.3 s); linked positionally via `cargo:rustc-link-arg` +following `roost-vt/build.rs`; `roost_shim_probe()` returned **42** at +runtime and a Swift `NSMenuItem` action called back into a Rust +`extern "C"` fn. No dyld issues — Swift's autolink records name +`/usr/lib/swift/*.dylib` absolutely, so **neither `-L /usr/lib/swift` +nor an rpath is needed**; `-static-stdlib` is a hard error on Apple +platforms now. Linux inertness was proven by running the build script +with `CARGO_CFG_TARGET_OS=linux` and `PATH=/nonexistent`: it exits 0 +without spawning `swiftc` and without emitting `rustc-cfg`, so the +`extern "C"` block and its call sites vanish. The route is viable — it +just costs a toolchain to buy ergonomics that stop at the C ABI wall. + +**Decision rationale.** Criterion (a) decides it: objc2 adds three +lockfile edge lines and nothing else, while the Swift route adds a +build script, a compiler, a CI requirement and a build-order dependency +— to the crate whose purpose in M6 is to stop depending on Swift. +Criterion (b) reinforces it: 6d and 6e are fine-grained callback APIs, +exactly the shape where objc2's one `define_class!` beats N hand- +matched C-ABI trampolines. The (c) worry that motivated the spike — +`msg_send!` unsafety — did not materialize: the first shipped consumer +needs **zero** `unsafe`. And (d) is settled by the graph itself: +`arboard` and `softbuffer` already compile this exact objc2 generation +into every macOS build. + +**What 6c/6d/6e inherit.** The seam is a flat `macos` module with +`MainThreadMarker` acquired on the iced update loop (no background- +thread AppKit anywhere) — `window::run` is only needed where a real +`NSView`/`NSWindow` is (6f vibrancy), not for the badge or the menu +bar. 6d and 6e get a proven `define_class!` delegate pattern with typed +`Retained<_>` payloads. 6e adds `objc2-user-notifications 0.3.2` and +must stay on the 0.6 generation — the coupling policy is pinned in a +`Cargo.toml` comment: these versions move with `arboard`/`softbuffer`, +never independently, or the lock resolves a second copy. 6c (Sparkle) +remains an open question on either route: no generated bindings exist, +so it will be a small hand-written `extern_class!` + `msg_send!` +wrapper over `SPUStandardUpdaterController`. + +**Shipped as the seam's first consumer: the dock badge**, pulled +forward from 6g (see 6g below — this piece of it is now done, the +rest is still open). `crates/roost-iced/src/macos/dock_badge.rs` +mirrors the notification-inbox count onto `NSApp.dockTile.badgeLabel` +exactly as `App.swift:1961-1968`'s `refreshDockBadge()` does (`nil` at +zero), synced after `WindowOpened` and after every +`reconcile_notification_inbox()` — all on the iced update loop via +`MainThreadMarker`, zero `unsafe` in the consumer itself. A test-mode +iced-only IPC op, `app.dock_badge` (`{"label": string|null}`, pinned +wire schema, documented in `docs/reference/ipc.md`), reads the live +AppKit badge without re-deriving it from the inbox; GTK rejects in its +exhaustive match, non-macOS iced rejects not-implemented. A new +`tools/roosttest/test_dock_badge.py` (darwin+iced only) drives +notification → badge count → clear → nil against a bundle launch and +is wired into `ICED_E2E_TESTS` and all three `ci.yml` iced lane lists. + * **6c. Sparkle auto-update.** `mac/Package.swift` + `App.swift` use ~two API calls (`SPUStandardUpdaterController`, `checkForUpdates(_:)`); `bundle.sh` already embeds and inside-out-signs the framework, and @@ -934,11 +1091,13 @@ Slices: `device.audio-input` / `device.camera` / `automation.apple-events` a `/voice` or `osascript` in a tab fails **silently** — no prompt, no error (see the rationale comment in `mac/Resources/Roost.entitlements`, - including which entitlements we deliberately omit). Then: Dock badge + - dock menu, `NSApplicationDelegate` lifecycle (reopen-from-Dock, - open-file/URL, graceful terminate), activation policy, and Secure - Keyboard Entry (`EnableSecureEventInput` — a terminal convention - neither app has today). + including which entitlements we deliberately omit). **Dock badge: + done** — pulled forward as 6b's seam proof-consumer (2026-08-16, see + 6b above for the implementation + test coverage). Still open: dock + menu, `NSApplicationDelegate` lifecycle (reopen-from-Dock, open-file/ + URL, graceful terminate), activation policy, and Secure Keyboard Entry + (`EnableSecureEventInput` — a terminal convention neither app has + today). * **6h. macOS verification tier.** What makes the parity claim honest instead of hand-verified: a CGEvent real-input harness ([#285] — the uinput tier is Linux-only, a gap that matters far more once Mac is a diff --git a/docs/reference/ipc.md b/docs/reference/ipc.md index 70e98470..310851fa 100644 --- a/docs/reference/ipc.md +++ b/docs/reference/ipc.md @@ -679,6 +679,35 @@ folds one refresh and one draw into a single pass, so `refresh_*` and `draw_*` always advance together there, unlike iced where the two are scheduled independently. CLI: `roostctl render-stats [--reset]`. +### `app.dock_badge` *(test-only — gated, macOS iced only)* + +**Requires `ROOST_TEST_MODE=1` set in the UI's launch environment.** +Without it the server returns `not-enabled`. Reads the macOS Dock +tile's live badge label — the parity port of `App.swift`'s +`refreshDockBadge()`, which mirrors the notification-inbox count onto +`NSApp.dockTile.badgeLabel` and writes `nil` at zero. + +Request: `{"params": {}}`. Response: + +```json +{"label": "3"} +``` + +`label` is `null` when the badge is cleared. The handler reads AppKit +on the main thread and deliberately does **not** re-derive the label +from the inbox first: recomputing would assert the count→label mapping +(which unit tests already pin) while proving nothing about whether the +write reached the Dock. Because the badge write rides the update loop +asynchronously, callers poll rather than reading once — +`tools/roosttest/test_dock_badge.py` is the reference use. + +Implemented only by the iced UI on macOS. The GTK UI, the iced UI on +Linux, and the Swift Mac app (which has no case for it, so its +dispatcher answers `unknown-op`) do not implement it; the first two +answer `not-implemented`. There is no Dock off macOS, and answering a +plausible `null` there would read as "the badge is cleared" and pass a +test that never ran. + ### `sidebar.set_width` *(test-only — gated)* **Requires `ROOST_TEST_MODE=1` set in the UI's launch environment.** diff --git a/mac/Resources/Info-iced.plist.template b/mac/Resources/Info-iced.plist.template new file mode 100644 index 00000000..b20735d0 --- /dev/null +++ b/mac/Resources/Info-iced.plist.template @@ -0,0 +1,88 @@ + + + + + + CFBundleDevelopmentRegion + en + CFBundleIdentifier + ai.stridelabs.Roost.iced + CFBundleName + Roost-Iced + CFBundleDisplayName + Roost-Iced + CFBundleExecutable + Roost-Iced + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleVersion + @VERSION@ + CFBundleShortVersionString + @VERSION@ + CFBundlePackageType + APPL + CFBundleSignature + ???? + LSMinimumSystemVersion + 15.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + NSSupportsAutomaticGraphicsSwitching + + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026 Charlie Knudsen. MIT-licensed. + NSMicrophoneUsageDescription + A program running in Roost-Iced would like to use the microphone. + NSCameraUsageDescription + A program running in Roost-Iced would like to use the camera. + NSAppleEventsUsageDescription + A program running in Roost-Iced would like to use AppleScript. + + diff --git a/mac/Resources/Roost-Iced.entitlements b/mac/Resources/Roost-Iced.entitlements new file mode 100644 index 00000000..f2fecfd5 --- /dev/null +++ b/mac/Resources/Roost-Iced.entitlements @@ -0,0 +1,69 @@ + + + + + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.automation.apple-events + + + diff --git a/mac/scripts/bundle-iced.sh b/mac/scripts/bundle-iced.sh new file mode 100755 index 00000000..0f88be84 --- /dev/null +++ b/mac/scripts/bundle-iced.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Roost-Iced.app bundling — M6 6a (plan 027). +# +# Wraps the `roost-iced` cargo binary into a proper macOS .app bundle +# so it can be Finder-launched / Dock-pinned / referenced by its own +# bundle identifier, side by side with the Swift Roost.app, so M6 can +# evaluate the iced UI with real macOS bundle identity: Sparkle (6c), +# UNUserNotificationCenter (6e), and TCC testing all require a +# bundled, signed .app. +# +# What this script does: +# 1. Builds `roost-iced` in the requested configuration +# (default: release) via `cargo build -p roost-iced`. +# 2. Assembles `mac/build/Roost-Iced.app` with the standard macOS +# bundle layout — Contents/MacOS/Roost-Iced, Contents/Info.plist, +# Contents/Resources/. +# 3. Substitutes @VERSION@ in +# `mac/Resources/Info-iced.plist.template` with the workspace +# version (or $ROOST_VERSION). +# 4. Installs the app icon, reusing the same art as Roost.app +# (shared art is a recorded decision — plan 027 § W2; a distinct +# Roost-Iced icon is future work). +# 5. Embeds `roostctl` under Contents/Resources/bin/ (same as +# bundle.sh). +# 6. Code-signs (ad-hoc by default, Developer ID when +# ROOST_DEVELOPER_ID_IDENTITY is set) — no framework signing +# stage: this bundle embeds no frameworks (no Sparkle). +# +# What this script deliberately does NOT do (unlike bundle.sh): +# * No SwiftPM build, no SwiftPM resource-bundle copy — the iced +# chrome fonts are `include_bytes!`'d and the themes ship +# compiled into `roost-ui-model`; nothing is loaded from a +# resource bundle at runtime. +# * No Sparkle.framework embed/sign — no auto-update parity between +# the two bundles (6c decision, not made here); see the header +# comment in Info-iced.plist.template for why the Sparkle plist +# keys are absent too. +# +# Deferred the same way bundle.sh defers them (shared posture, not a +# difference): +# * Notarize via `notarytool`. +# * Build a DMG (out of scope — plan 027 scope brief: local build +# only, no release.yml wiring). +# +# The toolkit-agnostic stages (version derivation, icon pipeline, +# roostctl embed, signing machinery, the libghostty-vt precondition) +# live in bundle-lib.sh, shared with bundle.sh. +# +# Note (plan 027 § 4, roadmap 6a decision, not fixed here): with both +# Roost.app and Roost-Iced.app installed, `claude install`'s +# self_exe-derived hook path points at whichever bundle's roostctl +# ran it last — running it from Roost-Iced.app points Claude hooks at +# this bundle's embedded roostctl, and vice versa for Roost.app. Not +# a 6a blocker; noted for later slices. +# +# Usage: +# ./mac/scripts/bundle-iced.sh # release build +# ./mac/scripts/bundle-iced.sh debug # debug build +# ROOST_VERSION=0.2.0 ./mac/scripts/bundle-iced.sh +# +# open mac/build/Roost-Iced.app # launch the bundle + +set -euo pipefail + +CONFIG="${1:-release}" +case "${CONFIG}" in + release|debug) ;; + *) + echo "error: configuration must be 'release' or 'debug', got '${CONFIG}'" >&2 + exit 1 + ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MAC_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${MAC_DIR}/.." && pwd)" + +# shellcheck source=mac/scripts/bundle-lib.sh +source "${SCRIPT_DIR}/bundle-lib.sh" + +# Default the marketing version to the workspace's single source of +# truth — see roost_workspace_version in bundle-lib.sh — so a local +# `bundle-iced.sh debug` reports the same version the Swift app, the +# GTK UI, the .deb, and `roostctl identify` do. +VERSION="${ROOST_VERSION:-$(roost_workspace_version "${REPO_ROOT}")}" +APP_NAME="Roost-Iced" +BUNDLE_ID="ai.stridelabs.Roost.iced" +TEMPLATE_PLIST="${MAC_DIR}/Resources/Info-iced.plist.template" +# Shared icon art with Roost.app (plan 027 § W2 — recorded decision; +# a distinct Roost-Iced icon is future work). +ICON_SRC="${MAC_DIR}/Resources/AppIcon.icns" +ICON_COMPOSER_SRC="${MAC_DIR}/AppIcon.icon" + +OUT_DIR="${MAC_DIR}/build" +APP_DIR="${OUT_DIR}/${APP_NAME}.app" + +roost_check_libghostty_archive "${REPO_ROOT}" + +CARGO_BIN="$(roost_find_cargo)" +roost_setup_cargo_profile "${CONFIG}" + +echo "==> Building roost-iced (cargo build -p roost-iced --${ROOST_CARGO_PROFILE_DIR})" +( + cd "${REPO_ROOT}" + # shellcheck disable=SC2086 # ROOST_CARGO_PROFILE_FLAG must word-split (empty => no flag) + "${CARGO_BIN}" build -p roost-iced ${ROOST_CARGO_PROFILE_FLAG} +) + +CARGO_TARGET="$(roost_cargo_target_dir "${REPO_ROOT}")" +ICED_BUILD_BIN="${CARGO_TARGET}/${ROOST_CARGO_PROFILE_DIR}/roost-iced" +if [ ! -x "${ICED_BUILD_BIN}" ]; then + echo "error: cargo build did not produce ${ICED_BUILD_BIN}" >&2 + exit 1 +fi + +roost_assemble_skeleton "${APP_DIR}" + +cp "${ICED_BUILD_BIN}" "${APP_DIR}/Contents/MacOS/${APP_NAME}" +chmod +x "${APP_DIR}/Contents/MacOS/${APP_NAME}" + +roost_stamp_plist "${TEMPLATE_PLIST}" "${APP_DIR}" "${VERSION}" +roost_write_pkginfo "${APP_DIR}" + +roost_install_app_icon "${ICON_COMPOSER_SRC}" "${ICON_SRC}" "${APP_DIR}" + +# M8-parity: embed roostctl under Contents/Resources/bin/ so `claude +# install` invoked from inside Roost-Iced.app writes hook paths that +# point at the bundled binary, not a dev-machine target/ path. +roost_build_and_embed_roostctl "${REPO_ROOT}" "${APP_DIR}" "${CONFIG}" + +# Signing. When ROOST_DEVELOPER_ID_IDENTITY is set (release CI, or a dev who +# holds the cert) we sign with that Developer ID + a secure `--timestamp` so the +# bundle can be notarized. Otherwise we fall back to ad-hoc (`-`) signing: fine +# for local launch, but Gatekeeper will warn and notarization is impossible. +# The inner→outer order (embedded roostctl first, then the .app) is required — +# codesign seals nested code into the outer signature. No framework-signing +# stage: unlike Roost.app, this bundle embeds no frameworks. +ENT_FILE="${MAC_DIR}/Resources/Roost-Iced.entitlements" +# The bundled roostctl helper gets the same narrower entitlements file +# bundle.sh uses: it never records audio/video or sends Apple events, +# so it must not inherit the app's capture entitlements. +ROOSTCTL_ENT_FILE="${MAC_DIR}/Resources/roostctl.entitlements" +if roost_setup_signing "${ENT_FILE}" "${ROOSTCTL_ENT_FILE}"; then + codesign_or_die "${APP_DIR}/Contents/Resources/bin/roostctl" "${ROOSTCTL_ENT_FILE}" + codesign_or_die "${APP_DIR}" +fi + +echo "==> Bundled: ${APP_DIR}" +echo " Bundle ID: ${BUNDLE_ID}" +echo " Version: ${VERSION}" +echo " Executable: ${APP_DIR}/Contents/MacOS/${APP_NAME}" +echo " Embedded CLI: ${APP_DIR}/Contents/Resources/bin/roostctl" +echo +echo "Note: with both Roost.app and Roost-Iced.app installed, 'claude" +echo "install' points Claude's hook file at whichever bundle's roostctl" +echo "ran it last (roadmap 6a decision — noted, not fixed here)." +echo +echo "Launch with: open '${APP_DIR}'" diff --git a/mac/scripts/bundle-lib.sh b/mac/scripts/bundle-lib.sh new file mode 100644 index 00000000..4c31edbd --- /dev/null +++ b/mac/scripts/bundle-lib.sh @@ -0,0 +1,358 @@ +# shellcheck shell=bash +# +# Shared toolkit-agnostic bundling stages for mac/scripts/bundle.sh and +# mac/scripts/bundle-iced.sh. +# +# Sourced, not executed: callers set `set -euo pipefail` themselves and +# source this file after computing SCRIPT_DIR/MAC_DIR/REPO_ROOT. Every +# function here is parameterized — no assumptions about app name, bundle +# dir, or entitlements paths baked in as globals. + +# roost_check_libghostty_archive REPO_ROOT +# +# Sanity check: the static libghostty-vt archive must exist or `swift +# build` / `cargo build` will fail at the linker. The same precondition +# the Mac README documents. +roost_check_libghostty_archive() { + local repo_root="$1" + if [ ! -f "${repo_root}/third_party/ghostty/out/lib/libghostty-vt.a" ]; then + echo "error: libghostty-vt static archive not built." >&2 + echo " Run: ${repo_root}/third_party/ghostty/build.sh" >&2 + exit 1 + fi +} + +# roost_workspace_version REPO_ROOT +# +# Prints the marketing version derived from the workspace's single +# source of truth — `[workspace.package].version` in Cargo.toml — so a +# local bundle build reports the same version the GTK UI, the .deb, and +# `roostctl identify` do. Callers should let $ROOST_VERSION override the +# result. The `^version` anchor matches only the top-level key, not the +# `version = "…"` entries nested under `[workspace.dependencies]`. +roost_workspace_version() { + local repo_root="$1" + local cargo_version + # Explicit failure check: command substitution suppresses errexit in + # bash 3.2, so without it a missing/unreadable version line would + # silently become 0.0.0 where the original inline pipeline aborted + # under pipefail. + cargo_version="$(grep -E '^version[[:space:]]*=' "${repo_root}/Cargo.toml" | head -1 \ + | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')" || { + echo "error: failed to read workspace version from ${repo_root}/Cargo.toml" >&2 + return 1 + } + echo "${cargo_version:-0.0.0}" +} + +# roost_assemble_skeleton APP_DIR +# +# Fresh bundle skeleton: Contents/MacOS + Contents/Resources. Both +# bundle scripts wipe and recreate on every run (no incremental +# state) so a stale bundle can't wear a previous build's leftovers. +roost_assemble_skeleton() { + local app_dir="$1" + echo "==> Assembling ${app_dir}" + rm -rf "${app_dir}" + mkdir -p "${app_dir}/Contents/MacOS" + mkdir -p "${app_dir}/Contents/Resources" +} + +# roost_stamp_plist TEMPLATE_PLIST APP_DIR VERSION +# +# Info.plist with version substitution. `sed -e s/.../.../g` is +# portable across BSD + GNU sed; quoting `@VERSION@` and using a +# unique-enough sentinel keeps the substitution unambiguous. +roost_stamp_plist() { + local template_plist="$1" + local app_dir="$2" + local version="$3" + echo "==> Stamping Info.plist (version=${version})" + sed -e "s/@VERSION@/${version}/g" "${template_plist}" \ + > "${app_dir}/Contents/Info.plist" +} + +# roost_write_pkginfo APP_DIR +# +# Classic four-byte PkgInfo so Finder recognizes the bundle type +# without leaning on Info.plist alone. macOS tolerates a missing +# PkgInfo nowadays but Spotlight prefers it. +roost_write_pkginfo() { + local app_dir="$1" + printf "APPL????" > "${app_dir}/Contents/PkgInfo" +} + +# roost_install_app_icon ICON_COMPOSER_SRC ICON_SRC APP_DIR +# +# App icon. On macOS 26 (Tahoe) a loose .icns is treated as legacy and +# inset on the system glass tile (a gray frame around the art). The fix +# is a compiled Icon Composer catalog (generated by +# packaging/icon/generate_icons.py) — `actool` renders it into +# Assets.car + a flattened AppIcon.icns, and Tahoe then fills the tile +# edge-to-edge (parity with ghostty/cmux). `actool` ships with full +# Xcode, not the bare Command Line Tools, so we fall back to the +# committed flat .icns when it's unavailable — that still builds a +# launchable bundle, just with the framed legacy icon on Tahoe. +# CFBundleIconName=AppIcon (set in the Info.plist template) routes the +# OS to the catalog; the .icns covers pre-Tahoe and the no-actool path. +# +# Reads LSMinimumSystemVersion back from APP_DIR's already-stamped +# Info.plist, so the caller must stamp Info.plist before calling this. +roost_install_app_icon() { + local icon_composer_src="$1" + local icon_src="$2" + local app_dir="$3" + + local icon_done=0 + if [ -d "${icon_composer_src}" ] && command -v xcrun >/dev/null 2>&1 \ + && xcrun --find actool >/dev/null 2>&1; then + # Match the bundle's own minimum OS so the catalog targets what the app ships. + local min_os + min_os="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' \ + "${app_dir}/Contents/Info.plist" 2>/dev/null || echo 26.0)" + local actool_tmp + actool_tmp="$(mktemp -d)" + echo "==> Compiling AppIcon.icon with actool (Tahoe glass icon, min ${min_os})" + if xcrun actool "${icon_composer_src}" \ + --compile "${actool_tmp}" \ + --platform macosx \ + --minimum-deployment-target "${min_os}" \ + --app-icon AppIcon \ + --output-partial-info-plist "${actool_tmp}/partial.plist" \ + --errors --warnings >/dev/null 2>&1 \ + && [ -f "${actool_tmp}/Assets.car" ]; then + cp "${actool_tmp}/Assets.car" "${app_dir}/Contents/Resources/Assets.car" + # actool also emits a flattened .icns — keep it as the pre-Tahoe fallback. + [ -f "${actool_tmp}/AppIcon.icns" ] \ + && cp "${actool_tmp}/AppIcon.icns" "${app_dir}/Contents/Resources/AppIcon.icns" + echo " Compiled: ${app_dir}/Contents/Resources/Assets.car" + icon_done=1 + else + echo " warn: actool failed; falling back to flat AppIcon.icns" >&2 + fi + rm -rf "${actool_tmp}" + fi + if [ "${icon_done}" -eq 0 ]; then + if [ -f "${icon_src}" ]; then + echo "==> Including flat AppIcon.icns (no actool — Tahoe will show the framed legacy icon)" + cp "${icon_src}" "${app_dir}/Contents/Resources/AppIcon.icns" + else + echo "==> No app icon found; bundle ships without a custom icon" + fi + fi +} + +# roost_find_cargo +# +# Prints the cargo to invoke. Discovers `cargo` on PATH instead of +# hardcoding ~/.cargo/bin/cargo — release runners may have cargo at a +# different prefix (toolchain managed by mise / rustup / system +# package). Falling back to the literal path preserves the prior +# behavior for the common dev case. +roost_find_cargo() { + local cargo_bin + cargo_bin="$(command -v cargo || true)" + if [ -z "${cargo_bin}" ] && [ -x "${HOME}/.cargo/bin/cargo" ]; then + cargo_bin="${HOME}/.cargo/bin/cargo" + fi + if [ -z "${cargo_bin}" ]; then + echo "error: cargo not found on PATH or at ~/.cargo/bin/cargo" >&2 + exit 1 + fi + echo "${cargo_bin}" +} + +# roost_setup_cargo_profile CONFIG +# +# Sets ROOST_CARGO_PROFILE_FLAG (word-split deliberately; empty for +# debug) and ROOST_CARGO_PROFILE_DIR in the caller's scope — the same +# caller-scope convention roost_setup_signing uses. +roost_setup_cargo_profile() { + ROOST_CARGO_PROFILE_FLAG="--release" + ROOST_CARGO_PROFILE_DIR="release" + if [ "$1" = "debug" ]; then + ROOST_CARGO_PROFILE_FLAG="" + ROOST_CARGO_PROFILE_DIR="debug" + fi +} + +# roost_cargo_target_dir REPO_ROOT +# +# Prints the artifact root, honoring CARGO_TARGET_DIR — shared caches +# (e.g. sccache + CI matrices that fan out across configs) routinely +# override the default `/target/` location. Cargo resolves a +# relative CARGO_TARGET_DIR from its own CWD (the repo root, where the +# build subshells cd) — discovery anchors the same way so the two +# can't diverge. +roost_cargo_target_dir() { + local repo_root="$1" + local cargo_target="${CARGO_TARGET_DIR:-${repo_root}/target}" + case "${cargo_target}" in + /*) ;; + *) cargo_target="${repo_root}/${cargo_target}" ;; + esac + echo "${cargo_target}" +} + +# roost_build_and_embed_roostctl REPO_ROOT APP_DIR CONFIG +# +# Embed roostctl under Contents/Resources/bin/ so `claude install` +# invoked from inside the bundled app writes hook paths that point at +# the bundled binary, not a dev-machine target/ path. The CLI build is +# fast and tracked through the same Cargo cache as any cargo build +# invocation; rebuilding here keeps the bundle in lockstep with +# whatever roost-cli source the developer has checked out. +roost_build_and_embed_roostctl() { + local repo_root="$1" + local app_dir="$2" + local config="$3" + + local cargo_bin + cargo_bin="$(roost_find_cargo)" + roost_setup_cargo_profile "${config}" + local cargo_profile_flag="${ROOST_CARGO_PROFILE_FLAG}" + local cargo_profile_dir="${ROOST_CARGO_PROFILE_DIR}" + echo "==> Building roostctl (cargo build -p roost-cli --${cargo_profile_dir})" + ( + # shellcheck disable=SC2164 # callers run this lib under `set -e` + cd "${repo_root}" + # shellcheck disable=SC2086 + "${cargo_bin}" build -p roost-cli ${cargo_profile_flag} + ) + + local cargo_target + cargo_target="$(roost_cargo_target_dir "${repo_root}")" + local roostctl_src="${cargo_target}/${cargo_profile_dir}/roostctl" + if [ ! -x "${roostctl_src}" ]; then + echo "error: cargo build did not produce ${roostctl_src}" >&2 + exit 1 + fi + mkdir -p "${app_dir}/Contents/Resources/bin" + cp "${roostctl_src}" "${app_dir}/Contents/Resources/bin/roostctl" + chmod +x "${app_dir}/Contents/Resources/bin/roostctl" + echo " Embedded: ${app_dir}/Contents/Resources/bin/roostctl" +} + +# roost_setup_signing ENT_FILE ROOSTCTL_ENT_FILE +# +# Signing setup. When ROOST_DEVELOPER_ID_IDENTITY is set (release CI, or +# a dev who holds the cert) we sign with that Developer ID + a secure +# `--timestamp` so the bundle can be notarized. Otherwise we fall back +# to ad-hoc (`-`) signing: fine for local launch, but Gatekeeper will +# warn and notarization is impossible. +# +# Failure handling: a botched signature is release-blocking (Gatekeeper +# reject, notarization fail, quarantined installs). Default is fail +# hard; the `ROOST_ALLOW_UNSIGNED=1` env var bypasses for the rare dev +# case where Xcode CLT codesign is missing. +# +# Sets SIGN_IDENTITY, TS_FLAG, and ROOST_SIGN_ENT_FILE in the caller's +# scope, and defines the codesign_or_die / codesign_framework_or_die +# functions (also in the caller's scope — this is bash, functions +# defined here are global to the sourcing script). +# ROOST_SIGN_ENT_FILE stays global (not a local) because +# codesign_or_die's default-entitlements argument reads it after this +# function has already returned. Returns 1 (without exiting) when +# signing must be skipped entirely because ROOST_ALLOW_UNSIGNED=1 +# bypassed a missing entitlements file or missing codesign — callers +# should skip calling the sign functions in that case. +roost_setup_signing() { + ROOST_SIGN_ENT_FILE="$1" + local ent_file="$1" + local roostctl_ent_file="$2" + + SIGN_IDENTITY="${ROOST_DEVELOPER_ID_IDENTITY:--}" + # `--timestamp` only with a real identity; ad-hoc signing can't be + # timestamped. Kept as a plain (unquoted-on-use) string so it expands + # to nothing when empty — bash 3.2-safe (no empty-array expansion + # under `set -u`). + TS_FLAG="" + if [ "${SIGN_IDENTITY}" != "-" ]; then + TS_FLAG="--timestamp" + fi + + if [ ! -f "${ent_file}" ] || [ ! -f "${roostctl_ent_file}" ]; then + # The entitlements files are committed and carry the app's TCC + # capabilities (mic/camera/Apple-events); a missing one must not + # silently ship an unsigned bundle that drops them. Fail hard like + # the missing-codesign case, unless the operator explicitly opts + # into an unsigned build. + if [ "${ROOST_ALLOW_UNSIGNED:-0}" = "1" ]; then + echo "==> warn: missing entitlements file (${ent_file} or ${roostctl_ent_file}); ROOST_ALLOW_UNSIGNED=1 set, shipping unsigned" + return 1 + else + echo "error: missing entitlements file (${ent_file} or ${roostctl_ent_file}) (set ROOST_ALLOW_UNSIGNED=1 to bypass)" >&2 + exit 1 + fi + elif ! command -v codesign >/dev/null 2>&1; then + # codesign absent (no Xcode CLT). Honor the fail-hard intent above: + # a missing signer would silently ship an unsigned bundle, so error + # out unless the operator explicitly opts into an unsigned build. + if [ "${ROOST_ALLOW_UNSIGNED:-0}" = "1" ]; then + echo "==> warn: codesign not found; ROOST_ALLOW_UNSIGNED=1 set, shipping unsigned" + return 1 + else + echo "error: codesign not found (set ROOST_ALLOW_UNSIGNED=1 to bypass)" >&2 + exit 1 + fi + fi + + if [ "${SIGN_IDENTITY}" = "-" ]; then + echo "==> Ad-hoc codesign (set ROOST_DEVELOPER_ID_IDENTITY for a notarizable build)" + else + echo "==> Developer ID codesign (identity: ${SIGN_IDENTITY})" + fi + + # shellcheck disable=SC2329 # invoked by the sourcing script after this function returns + codesign_or_die() { + local target="$1" + # Optional per-target entitlements; defaults to the app's file. The + # roostctl helper passes its own narrower file. + local ent="${2:-${ROOST_SIGN_ENT_FILE}}" + # shellcheck disable=SC2086 # TS_FLAG must word-split (empty => no flag) + if codesign --force --sign "${SIGN_IDENTITY}" \ + --entitlements "${ent}" \ + --options runtime \ + ${TS_FLAG} \ + "${target}" + then + return 0 + fi + if [ "${ROOST_ALLOW_UNSIGNED:-0}" = "1" ]; then + echo " warn: codesign(${target}) failed; ROOST_ALLOW_UNSIGNED=1 set, continuing" + return 0 + fi + echo " error: codesign(${target}) failed (set ROOST_ALLOW_UNSIGNED=1 to bypass)" >&2 + exit 1 + } + + # Sparkle.framework is signed --deep but WITHOUT --entitlements: the + # framework + its nested helpers (XPCServices/*.xpc, Updater.app, + # Autoupdate) carry their own designated requirements, and forcing the + # app's entitlements onto them can break Sparkle's XPC handshake. The + # app's `disable-library-validation` entitlement (on the outer bundle) + # is what lets it load this ad-hoc framework. --deep is safe here (a + # framework signed with uniform options) — it is only dangerous on the + # outer .app, where it would clobber these nested signatures. + # shellcheck disable=SC2329 # invoked by the sourcing script after this function returns + codesign_framework_or_die() { + local target="$1" + # shellcheck disable=SC2086 # TS_FLAG must word-split (empty => no flag) + if codesign --force --sign "${SIGN_IDENTITY}" \ + --options runtime \ + --deep \ + ${TS_FLAG} \ + "${target}" + then + return 0 + fi + if [ "${ROOST_ALLOW_UNSIGNED:-0}" = "1" ]; then + echo " warn: codesign(${target}) failed; ROOST_ALLOW_UNSIGNED=1 set, continuing" + return 0 + fi + echo " error: codesign(${target}) failed (set ROOST_ALLOW_UNSIGNED=1 to bypass)" >&2 + exit 1 + } + + return 0 +} diff --git a/mac/scripts/bundle.sh b/mac/scripts/bundle.sh index dfe1c094..4c013e26 100755 --- a/mac/scripts/bundle.sh +++ b/mac/scripts/bundle.sh @@ -34,6 +34,10 @@ # * Build a DMG (release.yml's `make-dmg` step does that). # * EdDSA-sign the DMG / publish the appcast (release.yml, issue #122). # +# The toolkit-agnostic stages (version derivation, icon pipeline, +# roostctl embed, signing machinery, the libghostty-vt precondition) +# live in bundle-lib.sh, shared with bundle-iced.sh. +# # Usage: # ./mac/scripts/bundle.sh # release build # ./mac/scripts/bundle.sh debug # debug build @@ -56,16 +60,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MAC_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" REPO_ROOT="$(cd "${MAC_DIR}/.." && pwd)" +# shellcheck source=mac/scripts/bundle-lib.sh +source "${SCRIPT_DIR}/bundle-lib.sh" + # Default the marketing version to the workspace's single source of -# truth — `[workspace.package].version` in Cargo.toml — so a local +# truth — see roost_workspace_version in bundle-lib.sh — so a local # `bundle.sh debug` reports the same version the GTK UI, the .deb, and # `roostctl identify` do. The release workflow still overrides -# ROOST_VERSION from the git tag (asserted to match Cargo.toml). The -# `^version` anchor matches only the top-level key, not the -# `version = "…"` entries nested under `[workspace.dependencies]`. -CARGO_VERSION="$(grep -E '^version[[:space:]]*=' "${REPO_ROOT}/Cargo.toml" | head -1 \ - | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')" -VERSION="${ROOST_VERSION:-${CARGO_VERSION:-0.0.0}}" +# ROOST_VERSION from the git tag (asserted to match Cargo.toml). +VERSION="${ROOST_VERSION:-$(roost_workspace_version "${REPO_ROOT}")}" APP_NAME="Roost" BUNDLE_ID="ai.stridelabs.Roost" TEMPLATE_PLIST="${MAC_DIR}/Resources/Info.plist.template" @@ -75,14 +78,7 @@ ICON_COMPOSER_SRC="${MAC_DIR}/AppIcon.icon" OUT_DIR="${MAC_DIR}/build" APP_DIR="${OUT_DIR}/${APP_NAME}.app" -# Sanity check: the static libghostty-vt archive must exist or -# `swift build` will fail at the linker. The same precondition the -# Mac README documents. -if [ ! -f "${REPO_ROOT}/third_party/ghostty/out/lib/libghostty-vt.a" ]; then - echo "error: libghostty-vt static archive not built." >&2 - echo " Run: ${REPO_ROOT}/third_party/ghostty/build.sh" >&2 - exit 1 -fi +roost_check_libghostty_archive "${REPO_ROOT}" echo "==> Building Roost (${CONFIG}) from SwiftPM" pushd "${MAC_DIR}" >/dev/null @@ -99,25 +95,13 @@ if [ ! -x "${SWIFT_BUILD_BIN}" ]; then exit 1 fi -echo "==> Assembling ${APP_DIR}" -rm -rf "${APP_DIR}" -mkdir -p "${APP_DIR}/Contents/MacOS" -mkdir -p "${APP_DIR}/Contents/Resources" +roost_assemble_skeleton "${APP_DIR}" cp "${SWIFT_BUILD_BIN}" "${APP_DIR}/Contents/MacOS/${APP_NAME}" chmod +x "${APP_DIR}/Contents/MacOS/${APP_NAME}" -# Info.plist with version substitution. `sed -e s/.../.../g` is -# portable across BSD + GNU sed; quoting `@VERSION@` and using a -# unique-enough sentinel keeps the substitution unambiguous. -echo "==> Stamping Info.plist (version=${VERSION})" -sed -e "s/@VERSION@/${VERSION}/g" "${TEMPLATE_PLIST}" \ - > "${APP_DIR}/Contents/Info.plist" - -# Classic four-byte PkgInfo so Finder recognizes the bundle type -# without leaning on Info.plist alone. macOS tolerates a missing -# PkgInfo nowadays but Spotlight prefers it. -printf "APPL????" > "${APP_DIR}/Contents/PkgInfo" +roost_stamp_plist "${TEMPLATE_PLIST}" "${APP_DIR}" "${VERSION}" +roost_write_pkginfo "${APP_DIR}" # Resource bundles SwiftPM emits — the .app ships them so the running # app can read its resources. `Roost_Roost.bundle` carries our theme @@ -160,99 +144,12 @@ mkdir -p "${APP_DIR}/Contents/Frameworks" cp -R "${SPARKLE_FW_SRC}" "${APP_DIR}/Contents/Frameworks/" echo " Embedded: ${APP_DIR}/Contents/Frameworks/Sparkle.framework" -# App icon. On macOS 26 (Tahoe) a loose .icns is treated as legacy and inset -# on the system glass tile (a gray frame around the art). The fix is a compiled -# Icon Composer catalog (mac/AppIcon.icon, generated by packaging/icon/ -# generate_icons.py) — `actool` renders it into Assets.car + a flattened -# AppIcon.icns, and Tahoe then fills the tile edge-to-edge (parity with -# ghostty/cmux). `actool` ships with full Xcode, not the bare Command Line -# Tools, so we fall back to the committed flat .icns when it's unavailable — -# that still builds a launchable bundle, just with the framed legacy icon on -# Tahoe. CFBundleIconName=AppIcon (set in Info.plist.template) routes the OS to -# the catalog; the .icns covers pre-Tahoe and the no-actool path. -ICON_DONE=0 -if [ -d "${ICON_COMPOSER_SRC}" ] && command -v xcrun >/dev/null 2>&1 \ - && xcrun --find actool >/dev/null 2>&1; then - # Match the bundle's own minimum OS so the catalog targets what the app ships. - MIN_OS="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' \ - "${APP_DIR}/Contents/Info.plist" 2>/dev/null || echo 26.0)" - ACTOOL_TMP="$(mktemp -d)" - echo "==> Compiling AppIcon.icon with actool (Tahoe glass icon, min ${MIN_OS})" - if xcrun actool "${ICON_COMPOSER_SRC}" \ - --compile "${ACTOOL_TMP}" \ - --platform macosx \ - --minimum-deployment-target "${MIN_OS}" \ - --app-icon AppIcon \ - --output-partial-info-plist "${ACTOOL_TMP}/partial.plist" \ - --errors --warnings >/dev/null 2>&1 \ - && [ -f "${ACTOOL_TMP}/Assets.car" ]; then - cp "${ACTOOL_TMP}/Assets.car" "${APP_DIR}/Contents/Resources/Assets.car" - # actool also emits a flattened .icns — keep it as the pre-Tahoe fallback. - [ -f "${ACTOOL_TMP}/AppIcon.icns" ] \ - && cp "${ACTOOL_TMP}/AppIcon.icns" "${APP_DIR}/Contents/Resources/AppIcon.icns" - echo " Compiled: ${APP_DIR}/Contents/Resources/Assets.car" - ICON_DONE=1 - else - echo " warn: actool failed; falling back to flat AppIcon.icns" >&2 - fi - rm -rf "${ACTOOL_TMP}" -fi -if [ "${ICON_DONE}" -eq 0 ]; then - if [ -f "${ICON_SRC}" ]; then - echo "==> Including flat AppIcon.icns (no actool — Tahoe will show the framed legacy icon)" - cp "${ICON_SRC}" "${APP_DIR}/Contents/Resources/AppIcon.icns" - else - echo "==> No app icon found; bundle ships without a custom icon" - fi -fi +roost_install_app_icon "${ICON_COMPOSER_SRC}" "${ICON_SRC}" "${APP_DIR}" # M8: embed roostctl under Contents/Resources/bin/ so `claude # install` invoked from inside Roost.app writes hook paths that # point at the bundled binary, not a dev-machine target/ path. -# The CLI build is fast and tracked through the same Cargo cache as -# any cargo build invocation; rebuilding here keeps the bundle in -# lockstep with whatever roost-cli source the developer has -# checked out. -# Discover `cargo` on PATH instead of hardcoding ~/.cargo/bin/cargo. -# Phase 8 release runners may have cargo at a different prefix -# (toolchain managed by mise / rustup / system package). Falling -# back to the literal path preserves the prior behavior for the -# common dev case. -CARGO_BIN="$(command -v cargo || true)" -if [ -z "${CARGO_BIN}" ] && [ -x "${HOME}/.cargo/bin/cargo" ]; then - CARGO_BIN="${HOME}/.cargo/bin/cargo" -fi -if [ -z "${CARGO_BIN}" ]; then - echo "error: cargo not found on PATH or at ~/.cargo/bin/cargo" >&2 - exit 1 -fi - -CARGO_PROFILE_FLAG="--release" -CARGO_PROFILE_DIR="release" -if [ "${CONFIG}" = "debug" ]; then - CARGO_PROFILE_FLAG="" - CARGO_PROFILE_DIR="debug" -fi -echo "==> Building roostctl (cargo build -p roost-cli --${CARGO_PROFILE_DIR})" -( - cd "${REPO_ROOT}" - # shellcheck disable=SC2086 - "${CARGO_BIN}" build -p roost-cli ${CARGO_PROFILE_FLAG} -) - -# Respect CARGO_TARGET_DIR for the artifact-discovery step. Shared -# caches (e.g. sccache + CI matrices that fan out across configs) -# routinely override the default `/target/` location. -CARGO_TARGET="${CARGO_TARGET_DIR:-${REPO_ROOT}/target}" -ROOSTCTL_SRC="${CARGO_TARGET}/${CARGO_PROFILE_DIR}/roostctl" -if [ ! -x "${ROOSTCTL_SRC}" ]; then - echo "error: cargo build did not produce ${ROOSTCTL_SRC}" >&2 - exit 1 -fi -mkdir -p "${APP_DIR}/Contents/Resources/bin" -cp "${ROOSTCTL_SRC}" "${APP_DIR}/Contents/Resources/bin/roostctl" -chmod +x "${APP_DIR}/Contents/Resources/bin/roostctl" -echo " Embedded: ${APP_DIR}/Contents/Resources/bin/roostctl" +roost_build_and_embed_roostctl "${REPO_ROOT}" "${APP_DIR}" "${CONFIG}" # Signing. When ROOST_DEVELOPER_ID_IDENTITY is set (release CI, or a dev who # holds the cert) we sign with that Developer ID + a secure `--timestamp` so the @@ -260,98 +157,12 @@ echo " Embedded: ${APP_DIR}/Contents/Resources/bin/roostctl" # for local launch, but Gatekeeper will warn and notarization is impossible. # The inner→outer order (embedded roostctl first, then the .app) is required — # codesign seals nested code into the outer signature. -# -# Failure handling: a botched signature is release-blocking (Gatekeeper reject, -# notarization fail, quarantined installs). Default is fail hard; the -# `ROOST_ALLOW_UNSIGNED=1` env var bypasses for the rare dev case where Xcode -# CLT codesign is missing. ENT_FILE="${MAC_DIR}/Resources/Roost.entitlements" # The bundled roostctl helper gets a narrower entitlements file: it never # records audio/video or sends Apple events, so it must not inherit the app's # capture entitlements (least privilege; cf. cmux's cmux-helper.entitlements). ROOSTCTL_ENT_FILE="${MAC_DIR}/Resources/roostctl.entitlements" -SIGN_IDENTITY="${ROOST_DEVELOPER_ID_IDENTITY:--}" -# `--timestamp` only with a real identity; ad-hoc signing can't be timestamped. -# Kept as a plain (unquoted-on-use) string so it expands to nothing when empty — -# bash 3.2-safe (no empty-array expansion under `set -u`). -TS_FLAG="" -if [ "${SIGN_IDENTITY}" != "-" ]; then - TS_FLAG="--timestamp" -fi -if [ ! -f "${ENT_FILE}" ] || [ ! -f "${ROOSTCTL_ENT_FILE}" ]; then - # The entitlements files are committed and carry the app's TCC capabilities - # (mic/camera/Apple-events); a missing one must not silently ship an unsigned - # bundle that drops them. Fail hard like the missing-codesign case, unless the - # operator explicitly opts into an unsigned build. - if [ "${ROOST_ALLOW_UNSIGNED:-0}" = "1" ]; then - echo "==> warn: missing entitlements file (${ENT_FILE} or ${ROOSTCTL_ENT_FILE}); ROOST_ALLOW_UNSIGNED=1 set, shipping unsigned" - else - echo "error: missing entitlements file (${ENT_FILE} or ${ROOSTCTL_ENT_FILE}) (set ROOST_ALLOW_UNSIGNED=1 to bypass)" >&2 - exit 1 - fi -elif ! command -v codesign >/dev/null 2>&1; then - # codesign absent (no Xcode CLT). Honor the fail-hard intent above: a missing - # signer would silently ship an unsigned bundle, so error out unless the - # operator explicitly opts into an unsigned build. - if [ "${ROOST_ALLOW_UNSIGNED:-0}" = "1" ]; then - echo "==> warn: codesign not found; ROOST_ALLOW_UNSIGNED=1 set, shipping unsigned" - else - echo "error: codesign not found (set ROOST_ALLOW_UNSIGNED=1 to bypass)" >&2 - exit 1 - fi -else - if [ "${SIGN_IDENTITY}" = "-" ]; then - echo "==> Ad-hoc codesign (set ROOST_DEVELOPER_ID_IDENTITY for a notarizable build)" - else - echo "==> Developer ID codesign (identity: ${SIGN_IDENTITY})" - fi - codesign_or_die() { - local target="$1" - # Optional per-target entitlements; defaults to the app's file. The - # roostctl helper passes its own narrower file. - local ent="${2:-${ENT_FILE}}" - # shellcheck disable=SC2086 # TS_FLAG must word-split (empty => no flag) - if codesign --force --sign "${SIGN_IDENTITY}" \ - --entitlements "${ent}" \ - --options runtime \ - ${TS_FLAG} \ - "${target}" - then - return 0 - fi - if [ "${ROOST_ALLOW_UNSIGNED:-0}" = "1" ]; then - echo " warn: codesign(${target}) failed; ROOST_ALLOW_UNSIGNED=1 set, continuing" - return 0 - fi - echo " error: codesign(${target}) failed (set ROOST_ALLOW_UNSIGNED=1 to bypass)" >&2 - exit 1 - } - # Sparkle.framework is signed --deep but WITHOUT --entitlements: the - # framework + its nested helpers (XPCServices/*.xpc, Updater.app, - # Autoupdate) carry their own designated requirements, and forcing the - # app's entitlements onto them can break Sparkle's XPC handshake. The - # app's `disable-library-validation` entitlement (on the outer bundle) - # is what lets it load this ad-hoc framework. --deep is safe here (a - # framework signed with uniform options) — it is only dangerous on the - # outer .app, where it would clobber these nested signatures. - codesign_framework_or_die() { - local target="$1" - # shellcheck disable=SC2086 # TS_FLAG must word-split (empty => no flag) - if codesign --force --sign "${SIGN_IDENTITY}" \ - --options runtime \ - --deep \ - ${TS_FLAG} \ - "${target}" - then - return 0 - fi - if [ "${ROOST_ALLOW_UNSIGNED:-0}" = "1" ]; then - echo " warn: codesign(${target}) failed; ROOST_ALLOW_UNSIGNED=1 set, continuing" - return 0 - fi - echo " error: codesign(${target}) failed (set ROOST_ALLOW_UNSIGNED=1 to bypass)" >&2 - exit 1 - } +if roost_setup_signing "${ENT_FILE}" "${ROOSTCTL_ENT_FILE}"; then codesign_or_die "${APP_DIR}/Contents/Resources/bin/roostctl" "${ROOSTCTL_ENT_FILE}" codesign_framework_or_die "${APP_DIR}/Contents/Frameworks/Sparkle.framework" codesign_or_die "${APP_DIR}" diff --git a/tools/input/linux/iced_clipboard_check.py b/tools/input/linux/iced_clipboard_check.py index ce5f2cc8..2951a721 100755 --- a/tools/input/linux/iced_clipboard_check.py +++ b/tools/input/linux/iced_clipboard_check.py @@ -1150,19 +1150,45 @@ def preview_accent_capture() -> tuple[tuple[int, int] | None, bytes]: ) # Plan 026 deferred the accent border to the real drag # threshold (strip_reorder DRAG_THRESHOLD = 8px), so a bare - # press renders nothing. Cross the threshold with a small - # nudge toward the target first; the drag-began border then - # serves the same causal purpose — Iced has consumed the - # press and armed the drag before the trajectory runs. Once - # dragging, backtracking never un-arms, so a step-1 position - # behind the nudge is safe. + # press renders nothing. Cross the threshold before fencing; + # the drag-began border then serves the same causal purpose — + # Iced has consumed the press and armed the drag before the + # trajectory runs. + # + # One nudge is not enough. Iced evaluates every event in a + # redraw batch against the *latest* cursor position, so a + # press that changes nothing on screen (re-pressing the pill + # that is already active) need not force a redraw between + # itself and the nudge that follows: both land in one batch, + # the gesture records the nudge's own x as its origin, and + # `origin.distance(current)` is 0 — the drag can never arm, + # however long the fence waits. That is what made this + # deterministic on CI's loaded tiny-skia runner and invisible + # on an idle box. + # + # Alternating between two points 12px apart makes arming + # independent of which of them the press was attributed to: + # whichever one it lands on, the other is a threshold + # crossing. Both stay inside the source pill, so a coalesced + # press still hits the intended child, and each capture's + # render is a batch boundary between moves. Once dragging, + # moving back never un-arms, so the trajectory below is + # unaffected. nudge_x = x0 + (12 if target_x >= x0 else -12) - launch.terminal_pointer( - ["mousemove", "--window", launch.window, str(nudge_x), str(y)] - ) + arming = [nudge_x, x0] pressed_capture: list[bytes] = [] def source_press_rendered() -> bool: + launch.terminal_pointer( + [ + "mousemove", + "--window", + launch.window, + str(arming[0]), + str(y), + ] + ) + arming.reverse() run, png = preview_accent_capture() if run is None: return False diff --git a/tools/roosttest/README.md b/tools/roosttest/README.md index 860ef015..8ce547d2 100644 --- a/tools/roosttest/README.md +++ b/tools/roosttest/README.md @@ -55,6 +55,7 @@ silently skipping ~30 mode-gated tests. See "Hermetic / fresh mode" below. | `test_selection.py` | The `selection.*` op set: set/dump/clear round trips, and copy completeness (#249) — a selection scrolled into scrollback still copies in full, a multi-row selection copies every row in order, a reversed drag copies in document order, and wide/CJK glyphs copy without a phantom space. IPC-only (no pasteboard), so it runs in every lane including headless Wayland. | | `test_osc52.py` | Program-initiated OSC 52 clipboard writes, plus the `clipboard.write`/`clipboard.dump` round trip they read through. Touches the host pasteboard, so it is the one module the headless-Wayland lanes skip. | | `test_ime.py` | End-to-end IME (plan 021): `tab.feed_ime` (preedit/commit/clear) driven through the same route the iced adapter's winit IME handler takes — byte-exact commit encoding, preedit-only-at-cursor (never reaches the PTY), single-emit on preedit-then-commit, and the one-shot discard latch that drops a stray commit after a route change (e.g. opening the palette) cancels a live composition. Iced-only; skipped without `ROOST_TEST_MODE=1`. | +| `test_dock_badge.py` | The macOS Dock-tile badge (plan 027 C7, the M6 § 6b native seam's first consumer): a pending notification badges the tile with the inbox count and clearing the inbox removes it, read back off AppKit via the `app.dock_badge` test-mode op rather than recomputed from the inbox. Skips unless the host is macOS **and** the target is iced — `make e2e-mac` collects the whole directory, so an OS-only skip would aim an iced-only op at the Swift app. Skipped without `ROOST_TEST_MODE=1`. | | `test_exit_on_empty.py` | Iced's exit-on-empty policy (plan 026 D8, mac parity): deleting the LAST project over IPC replies successfully, the process then exits on its own with status 0, and the throwaway `state.json` records the emptied workspace (proving `App`'s drop-time flush ran). **Runs in its own pytest invocation** — `make e2e-iced-exit`, and its own ci.yml step per lane — because it ends the UI it drives; a mid-suite exit would strand every module after it in the session-scoped harness. Deliberately NOT in `ICED_E2E_TESTS`, and self-enforcing: it skips (loudly) whenever it is collected beside another module, so a whole-directory run can't be poisoned by it. Also skipped off iced and without `--roost-fresh`. | | `fixtures/launcher.conf` | Seed config the harness points the UI at via `ROOST_CONFIG` (see below), giving the launcher tests a deterministic command list. | diff --git a/tools/roosttest/client.py b/tools/roosttest/client.py index fb55d94d..1d5ef54a 100644 --- a/tools/roosttest/client.py +++ b/tools/roosttest/client.py @@ -558,6 +558,23 @@ def app_selected_tab_id(self) -> int: res = self.call("app.selected_tab_id", {}) return int(res["tab_id"]) + def app_dock_badge(self) -> str | None: + """Return the macOS Dock tile's live badge label, or `None` when + the badge is cleared. Read straight off AppKit — the UI does not + re-derive it from its notification inbox first, so this asserts + the badge write actually landed. + + Gated by ROOST_TEST_MODE=1 (raises `RoostError('not-enabled')` + when off) and macOS-iced-only: the GTK UI and the iced UI on + Linux answer `RoostError('not-implemented')`; the Swift Mac app + has no dispatcher case at all and answers + `RoostError('unknown-op')` (same as `tab.feed_ime`).""" + res = self.call("app.dock_badge", {}) + # Direct key access: a missing field is a protocol violation, and + # `.get` would read it as "badge cleared" — the exact state the + # badge tests wait for. + return res["label"] + def tab_expand_selection_at( self, tab_id: int, diff --git a/tools/roosttest/test_dock_badge.py b/tools/roosttest/test_dock_badge.py new file mode 100644 index 00000000..9eb87021 --- /dev/null +++ b/tools/roosttest/test_dock_badge.py @@ -0,0 +1,97 @@ +"""Dock-badge E2E — plan 027 C7, the macOS native seam's first consumer. + +The iced UI mirrors its notification-inbox count onto +`NSApp.dockTile.badgeLabel` (`crates/roost-iced/src/macos/dock_badge.rs`), +the parity port of `mac/Sources/Roost/App.swift`'s `refreshDockBadge()` — +the count as a decimal string, `nil` at zero. The write rides the +reconcile in the iced update loop, so it lands asynchronously; every +assertion here is a condition wait, never a sleep. + +`app.dock_badge` reads the label back off AppKit rather than re-deriving +it from the inbox, so what these tests assert is that the write actually +reached the Dock — not merely that the count→label mapping is right +(`dock_badge::label`'s unit tests pin that). + +macOS-iced-only: see `_mac_iced_only`. Nothing here needs the Dock to be +*visible* — a locked screen or an occluded window changes none of it. +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +TEST_MODE = os.environ.get("ROOST_TEST_MODE") == "1" + + +@pytest.fixture(autouse=True) +def _mac_iced_only(target): + """Skip unless BOTH halves hold. + + `make e2e-mac` runs this whole directory on macOS, so an OS-only skip + would aim an iced-only op at the Swift app (whose dispatcher answers + `unknown-op`). And the iced UI also builds for Linux, where there is + no Dock at all — so a target-only skip would fail every Linux lane. + """ + if sys.platform != "darwin" or target != "iced": + pytest.skip("app.dock_badge is macOS-iced-only (roadmap M6 § 6b seam)") + + +def _wait_badge(roost, want: str | None, what: str) -> None: + roost._wait(lambda: roost.app_dock_badge() == want, 5.0, what) + + +def _drain_inbox(palette) -> None: + """Empty the inbox through the user-facing "Clear All" command. + + The badge is app-global state, so a pending notification an earlier + module left behind would poison the baseline. Clear-all drives the + same false-edge a user's triage does. + """ + palette.palette_open() + palette.palette_activate("clear_notifications") + + +@pytest.mark.skipif( + not TEST_MODE, + reason="app.dock_badge requires ROOST_TEST_MODE=1 in the UI's launch env", +) +class TestDockBadge: + def test_badge_appears_for_a_pending_notification_and_clears( + self, roost, project, palette + ): + _drain_inbox(palette) + _wait_badge(roost, None, "baseline: an empty inbox leaves no badge") + + a = roost.open_tab(project, cwd="/tmp") + roost.open_tab(project, cwd="/tmp") # steals active, so `a` is background + # Notification policy B suppresses a raise for the active tab of an + # active window, so the notified tab must be a background one. + roost.notify(a, "DockBadge", "pending") + roost.wait_notification(a, True) + _wait_badge(roost, "1", "one pending notification badges the Dock tile") + + # The same false-edge the UI's focus-and-clear drives. + roost.clear_notification(a) + roost.wait_notification(a, False) + _wait_badge(roost, None, "the badge clears when the inbox empties") + + def test_badge_counts_every_pending_tab(self, roost, project, palette): + """The badge is the count, not a presence dot — the property that + makes `label()`'s decimal formatting load-bearing.""" + _drain_inbox(palette) + _wait_badge(roost, None, "baseline: an empty inbox leaves no badge") + + a = roost.open_tab(project, cwd="/tmp") + b = roost.open_tab(project, cwd="/tmp") + roost.open_tab(project, cwd="/tmp") # steals active + roost.notify(a, "DockBadge", "first") + roost.notify(b, "DockBadge", "second") + roost.wait_notification(a, True) + roost.wait_notification(b, True) + _wait_badge(roost, "2", "two pending tabs badge the Dock tile as 2") + + _drain_inbox(palette) + _wait_badge(roost, None, "clear-all drops the badge") diff --git a/tools/roosttest/ui.py b/tools/roosttest/ui.py index bb53c51d..775fe76e 100644 --- a/tools/roosttest/ui.py +++ b/tools/roosttest/ui.py @@ -54,6 +54,26 @@ # would let a refusal line from a previous day satisfy `_boot_refusal`. _MAC_LOG_OFFSET: int | None = None +# The bundle-launched (`ROOST_ICED_APP`) sibling of `_MAC_LOG_OFFSET` + +# `_ICED_PROC`, scoped to the `iced` target only. Bundle mode launches +# Roost-Iced.app via LaunchServices (`open`), same as `_launch_mac` — no +# direct child, so there is no stdout to capture and no `Popen` handle to +# poll/wait on. `_ICED_BUNDLE_LOG_OFFSET` mirrors `_MAC_LOG_OFFSET`'s +# "everything after this point in the persistent log is this launch's" +# convention, and doubles as the "are we currently in bundle-launch mode" +# flag consulted by `_launch_output`/`_boot_refusal`. `_ICED_BUNDLE_PID` is +# the identify-verified pid of the launched Roost-Iced process, recorded +# only once its identity is confirmed (see `_launch_iced_bundle`) — this is +# the sole handle teardown (`_quit_iced_bundle`) signals against, deliberately +# pid-based rather than process-name-based so it can never reach the Swift +# Roost.app. +_ICED_BUNDLE_LOG_OFFSET: int | None = None +_ICED_BUNDLE_PID: int | None = None + +# Must stay in sync with `mac/scripts/bundle-iced.sh`'s `APP_NAME`/`BUNDLE_ID`. +ICED_BUNDLE_EXECUTABLE_NAME = "Roost-Iced" +ICED_BUNDLE_APP_ID = "ai.stridelabs.Roost.iced" + # The UI's own words when it refuses to start because another process holds # the state lock (`crates/roost-{iced,linux}/src/main.rs`, # `mac/Sources/Roost/App.swift` — all three share this wording). @@ -218,6 +238,37 @@ def rust_binary_path(target: str) -> tuple[Path, bool]: return REPO_ROOT / "target/debug" / spec.binary_name, False +def iced_bundle_app() -> Path | None: + """Resolve + validate `ROOST_ICED_APP`, or `None` when unset. + + Validated eagerly, right here where the override is read — never lazily + at the point `open` fails — so a bad path or platform raises a clear + exception at launch time instead of the harness silently falling back + to `target/debug/roost-iced` (see `rust_binary_path`, which stays + untouched and unconsulted when this returns non-None). + """ + raw = os.environ.get("ROOST_ICED_APP") + if not raw: + return None + if platform.system() != "Darwin": + raise RuntimeError( + f"ROOST_ICED_APP={raw!r} is set but this platform is " + f"{platform.system()!r}: the bundle launch path (LaunchServices " + "`open`) is macOS-only" + ) + app = Path(raw).expanduser() + if not app.is_absolute(): + app = REPO_ROOT / app + if not app.is_dir(): + raise FileNotFoundError(f"ROOST_ICED_APP does not exist: {app}") + executable = app / "Contents/MacOS" / ICED_BUNDLE_EXECUTABLE_NAME + if not executable.is_file(): + raise FileNotFoundError( + f"ROOST_ICED_APP={app} is missing its executable: {executable}" + ) + return app + + def is_alive(target: str) -> bool: try: c = Roost(socket_path(target)) @@ -245,6 +296,14 @@ def _mac_ui_log_path() -> Path: return Path.home() / f"Library/Logs/{TARGET_SPECS['mac'].mac_label}/roost.log" +def _iced_bundle_ui_log_path() -> Path: + """The bundle-launched Roost-Iced.app's own persistent file log — + `TARGET_SPECS["iced"].mac_label` ("Roost-iced"), the same directory a + bare `ROOST_BUNDLE_PROFILE=iced` binary logs to. `open` gives no child + to capture, same reasoning as `_mac_ui_log_path`.""" + return Path.home() / f"Library/Logs/{TARGET_SPECS['iced'].mac_label}/roost.log" + + def _launch_output(target: str) -> str: """What the harness-launched UI has written *since this launch*. @@ -261,6 +320,11 @@ def _launch_output(target: str) -> str: return "" log: Path | None = _mac_ui_log_path() offset = _MAC_LOG_OFFSET + elif target == "iced" and _ICED_BUNDLE_LOG_OFFSET is not None: + # Bundle mode: same reasoning as the mac branch above, scoped to + # the iced target's own log file. + log = _iced_bundle_ui_log_path() + offset = _ICED_BUNDLE_LOG_OFFSET else: proc, log = { "gtk": (_GTK_PROC, _GTK_LOG), @@ -272,6 +336,11 @@ def _launch_output(target: str) -> str: if log is None or not log.exists(): return "" try: + # A log that rotated/truncated since the offset was recorded is + # smaller than the offset itself — seeking there would just read + # nothing forever. Read from the top instead of past the file. + if log.stat().st_size < offset: + offset = 0 with open(log, "rb") as handle: handle.seek(offset) return handle.read().decode(errors="replace") @@ -309,6 +378,12 @@ def _boot_refusal(target: str) -> str | None: if target == "mac": if _roost_running(): return None + elif target == "iced" and _ICED_BUNDLE_LOG_OFFSET is not None: + # Bundle mode has no direct child to poll (LaunchServices detaches + # it), so liveness is a name probe, same shape as the mac branch — + # read-only, unlike teardown's pid-based signals. + if _roost_iced_bundle_running(): + return None else: proc = {"gtk": _GTK_PROC, "iced": _ICED_PROC}.get(target) rc = proc.poll() if proc is not None else None @@ -655,7 +730,20 @@ def launch(target: str, *, state_dir: Path | None = None, force: bool = False) - if not app.is_dir(): subprocess.run(["./scripts/bundle.sh", "debug"], cwd=REPO_ROOT / "mac", check=True) _launch_mac(app, state_dir=state_dir) + elif target == "iced" and (bundle_app := iced_bundle_app()) is not None: + _launch_iced_bundle(bundle_app, state_dir=state_dir) elif target in ("gtk", "iced"): + if target == "iced": + # A prior launch in this same process may have gone through + # bundle mode (ROOST_ICED_APP was set then, unset now); clear + # the flag `_launch_output`/`_boot_refusal` key off so this + # Popen launch isn't mistaken for one still in flight, and clear + # any pid it recorded — otherwise a stale bundle pid from an + # earlier (now-dead) bundle would make `quit("iced")` dispatch + # to bundle teardown instead of terminating this live process. + global _ICED_BUNDLE_LOG_OFFSET, _ICED_BUNDLE_PID + _ICED_BUNDLE_LOG_OFFSET = None + _ICED_BUNDLE_PID = None spec = TARGET_SPECS[target] assert spec.binary_name is not None and spec.rust_package is not None binary, explicit_binary = rust_binary_path(target) @@ -705,6 +793,176 @@ def launch(target: str, *, state_dir: Path | None = None, force: bool = False) - raise ValueError(f"unknown target {target!r}") +def _log_size_or_zero(log: Path) -> int: + """Everything after this size in `log`'s persistent file belongs to the + launch about to start — the "offset before open" convention shared by + `_launch_mac` and `_launch_iced_bundle` (bare-binary Rust launches get a + fresh capture file per run instead, so this convention only applies to + `open`-launched bundles, which have no child to give a clean stdout to + capture).""" + return log.stat().st_size if log.exists() else 0 + + +def _forward_env(argv: list[str], name: str) -> None: + """Append `--env NAME=value` to an `open` argv when the harness's own + process has `name` set, else leave `argv` untouched. Shared plumbing + between `_launch_mac` and `_launch_iced_bundle`'s env-forwarding — each + still decides its own allowlist and forwarding order.""" + if name in os.environ: + argv += ["--env", f"{name}={os.environ[name]}"] + + +def _launch_iced_bundle(app: Path, *, state_dir: Path | None = None) -> None: + """Launch a bundle-assembled Roost-Iced.app via LaunchServices (`open`). + + A target-parameterized sibling of `_launch_mac`, not a copy: every value + here keys off the `iced` target (`TARGET_SPECS["iced"]`, the bundle's own + `Roost-Iced` executable name, the iced socket) rather than `_launch_mac`'s + hardcoded "Roost" identity, so nothing here can silently drift onto the + Swift app. Unlike `_launch_mac` this does not retry-with-cleanup: that + dance exists for macos-latest CI flakiness around a specific app; a local + `make e2e-iced-bundle` run is the only consumer today, and a genuine boot + failure should surface immediately rather than being retried once. + """ + global _ICED_BUNDLE_LOG_OFFSET, _ICED_BUNDLE_PID + log = _iced_bundle_ui_log_path() + log.parent.mkdir(parents=True, exist_ok=True) + # Everything after this point in the bundle's persistent log belongs to + # this launch — same "offset before open" convention as `_launch_mac`. + _ICED_BUNDLE_LOG_OFFSET = _log_size_or_zero(log) + + config_path = _session_config_path() if state_dir is not None else SEED_CONFIG + argv = ["open", "--env", f"ROOST_CONFIG={config_path}"] + if state_dir is not None: + argv += ["--env", f"ROOST_STATE_DIR={state_dir}"] + # Enumerated allowlist (plan 027 W5), each forwarded only when the + # session env actually set it — same technique `_launch_mac` uses for + # ROOST_TEST_MODE. `ICED_BACKEND` matters here in a way it never does for + # Mac: without it, a CI cell pinned to tiny-skia would silently launch the + # bundle under wgpu and the renderer matrix would stop meaning anything. + # Deliberately NOT forwarded: ROOST_BUNDLE_PROFILE — the bundle-id-derived + # default profile (W3) is the thing this launch path exists to exercise; + # forwarding an override would bypass the very path under test. + for name in ("ROOST_TEST_MODE", "ROOST_TEST_TIMEOUT_SCALE", "ICED_BACKEND"): + _forward_env(argv, name) + # RUST_LOG is forwarded with a floor: the launch path asserts the + # INFO-level "resolved bundle identity" line after boot, so a session + # filter like RUST_LOG=warn (CI's default for e2e steps) must not + # silence the very line the assertion requires. Anything already + # naming roost_iced keeps the operator's explicit choice. + rust_log = os.environ.get("RUST_LOG") + if rust_log is not None: + if "roost_iced" not in rust_log: + rust_log = f"{rust_log},roost_iced=info" + argv += ["--env", f"RUST_LOG={rust_log}"] + argv += [str(app)] + subprocess.run(argv, check=True) + + # Everything from here on can fail after a real process is already up. + # Leaving a launched bundle running on any of these failures would leak + # it into whatever runs next, so any exception tears it down before + # propagating — pid-based if identity was confirmed, else a best-effort + # name kill (safe: `ICED_BUNDLE_EXECUTABLE_NAME` can never match the + # Swift app's process name). + try: + wait_alive("iced") + + pid = _answering_pid("iced") + if pid is None: + raise RuntimeError( + "iced bundle: wait_alive succeeded but identify no longer answers" + ) + command = _process_command(pid) + name = Path(command).name if command else None + if name != ICED_BUNDLE_EXECUTABLE_NAME: + raise RuntimeError( + f"iced bundle's identify pid {pid} belongs to process {command!r}, " + f"expected {ICED_BUNDLE_EXECUTABLE_NAME!r} — refusing to adopt it " + "for teardown (would risk signalling the wrong process)" + ) + # Name alone isn't enough — a same-named process from somewhere else + # on $PATH would pass it. `_process_command` returns the full + # executable path on macOS (`ps -o comm=`), so confirm it actually + # lives inside the bundle we launched. + if not command.startswith(str(app)): + raise RuntimeError( + f"iced bundle's identify pid {pid} executable {command!r} is " + f"not under the launched bundle {app} — refusing to adopt it " + "for teardown (would risk signalling the wrong process)" + ) + # Recorded only now that identity is confirmed — this is the sole handle + # `_quit_iced_bundle` acts on. + _ICED_BUNDLE_PID = pid + + _assert_bundle_identity_logged() + if os.environ.get("ROOST_TEST_MODE") == "1": + _assert_test_mode_canary("iced") + except Exception: + if _ICED_BUNDLE_PID is not None: + _quit_iced_bundle(graceful=scaled_timeout(10.0)) + else: + subprocess.run(["pkill", "-x", ICED_BUNDLE_EXECUTABLE_NAME], check=False) + raise + + +def _assert_bundle_identity_logged() -> None: + """W3's startup log line (`resolved bundle identity`) is the only + observable proof the bundle-id probe ran and resolved this launch's + profile — every mapping arm returns `Iced` today, so the log line is + the whole test surface. Assert it on every bundle-mode launch (plan 027 + W3/W5) rather than in one test module, so it can never silently regress + while the two curated e2e modules still pass.""" + output = _launch_output("iced") + want_field = f'bundle_id="{ICED_BUNDLE_APP_ID}"' + if "resolved bundle identity" not in output or want_field not in output: + raise RuntimeError( + "iced bundle launch did not log the W3 bundle-identity line " + f"(want a line containing `resolved bundle identity` and " + f"`{want_field}`); captured log since launch:\n" + output + ) + + +def _assert_test_mode_canary(target: str) -> None: + """The first thing a bundle-mode session does once ROOST_TEST_MODE was + requested: round-trip a test-mode-only op (`tab.feed_pty_bytes`). No + generic session-start capability probe exists elsewhere in the harness + (every other module just calls a gated op directly and lets it raise); + this is the bundle-launch-path's own version of that, so a dropped + ROOST_TEST_MODE in the `open --env` forwarding surfaces here — loudly, + at launch — instead of as a confusing `not-enabled` deep inside whichever + test happens to run first.""" + client = Roost(socket_path(target)) + try: + pid = int(client.identify()["pid"]) + project = client.create_project(name=f"bundle-canary-{pid}", cwd="/tmp") + try: + tab = client.open_tab(project, cwd="/tmp") + try: + client.tab_feed_pty_bytes(tab, b"") + except RoostError as error: + if error.code == "not-enabled": + raise RuntimeError( + "iced bundle launch: ROOST_TEST_MODE=1 was requested but " + "tab.feed_pty_bytes reports not-enabled — env forwarding " + "dropped ROOST_TEST_MODE on the way into the bundle" + ) from error + raise + finally: + # Closing this tab (the project's only one) would cascade to + # delete the project itself (plan 026 D8), so delete the + # project directly instead of closing the tab first — and + # swallow "not-found" the way the `project` fixture's own + # cleanup does (conftest.py): a test may already have removed + # it via that same cascade. + try: + client.delete_project(project) + except RoostError as error: + if error.code != "not-found": + raise + finally: + client.close() + + def _launch_mac(app: Path, *, state_dir: Path | None = None) -> None: """Clean any dead leftover, `open` the bundle, wait until ready — retrying the open once if the first launch never becomes ready. @@ -728,7 +986,7 @@ def _launch_mac(app: Path, *, state_dir: Path | None = None) -> None: # this attempt, so a boot failure (including a state-lock refusal) is # readable without the developer's accumulated history. mac_log = _mac_ui_log_path() - _MAC_LOG_OFFSET = mac_log.stat().st_size if mac_log.exists() else 0 + _MAC_LOG_OFFSET = _log_size_or_zero(mac_log) # `open --env` injects the seed config into the launched app # (LaunchServices otherwise drops the caller's env). Forward # ROOST_TEST_MODE + ROOST_STATE_DIR the same way so the bundled UI @@ -742,8 +1000,7 @@ def _launch_mac(app: Path, *, state_dir: Path | None = None) -> None: "open", "--env", f"ROOST_CONFIG={config_path}", ] - if "ROOST_TEST_MODE" in os.environ: - argv += ["--env", f"ROOST_TEST_MODE={os.environ['ROOST_TEST_MODE']}"] + _forward_env(argv, "ROOST_TEST_MODE") if state_dir is not None: argv += ["--env", f"ROOST_STATE_DIR={state_dir}"] # Isolate UserDefaults-backed prefs (sidebar visibility/width) to a @@ -765,6 +1022,51 @@ def _roost_running() -> bool: stderr=subprocess.DEVNULL).returncode == 0 +def _roost_iced_bundle_running() -> bool: + """Read-only liveness probe for the bundle-launched process, used only + while `_launch_iced_bundle` hasn't yet confirmed a pid via `identify` + (see `_boot_refusal`). Never used for termination — teardown + (`_quit_iced_bundle`) is pid-based so it can't hit the Swift Roost.app.""" + return subprocess.run(["pgrep", "-x", ICED_BUNDLE_EXECUTABLE_NAME], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL).returncode == 0 + + +def _process_command(pid: int) -> str | None: + """The `comm` (executable path/name) of a running pid, or `None` if it + isn't running / isn't visible to us. Used to verify `identify`'s + reported pid actually belongs to the bundle's own process before the + harness adopts it for teardown.""" + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "comm="], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _wait_pid_gone(pid: int, timeout: float) -> bool: + """Poll until `pid` is gone, or `timeout` elapses. Early-exits the + instant it dies, mirroring `_wait_gone`'s cost shape.""" + deadline = time.monotonic() + timeout + while _pid_alive(pid): + if time.monotonic() >= deadline: + return False + time.sleep(0.1) + return True + + def _wait_gone(timeout: float) -> bool: """Poll until no Roost process remains, or `timeout` elapses. @@ -842,6 +1144,49 @@ def _mac_cleanup() -> None: # replaced the old ROOST_TEST_RESET_STATE-gated unlink. +def _quit_iced_bundle(graceful: float = 10.0) -> None: + """Stop a bundle-launched Roost-Iced.app and *prove* it is gone — pid-based + (SIGTERM, wait, escalate to SIGKILL, wait again), never a process-name + kill: `_quit_mac_process` can reach for `pkill -x Roost` because "Roost" + unambiguously means the Swift app, but "Roost-Iced" the process name is + exactly what we're launching here, so a name-based kill would be safe in + isolation yet is banned on principle (plan 027 W5) — the one thing this + helper must never do is become copy-pasteable into a context where it + WOULD hit the Swift app. `end_session` deletes the session state dir + (state.lock included) immediately after `quit()` returns, so this must + not return while the bundle might still hold that lock — mirrors + `_quit_mac_process`'s confirmed-dead discipline. + """ + global _ICED_BUNDLE_PID + pid = _ICED_BUNDLE_PID + if pid is None: + return + if not _pid_alive(pid): + _ICED_BUNDLE_PID = None + return + # macOS recycles pids; a Roost-Iced pid can go dead and be reassigned to + # an unrelated process between launch and teardown. Confirm the pid + # still names a Roost-Iced process before signalling it — if not, treat + # it as already dead rather than risk killing whatever's there now. + command = _process_command(pid) + name = Path(command).name if command else None + if name != ICED_BUNDLE_EXECUTABLE_NAME: + _ICED_BUNDLE_PID = None + return + subprocess.run(["kill", str(pid)], check=False) # SIGTERM + if _wait_pid_gone(pid, graceful): + _ICED_BUNDLE_PID = None + return + subprocess.run(["kill", "-9", str(pid)], check=False) # SIGKILL + if not _wait_pid_gone(pid, 5.0): + raise RuntimeError( + f"Roost-Iced (pid {pid}) survived SIGKILL — refusing to unlink its " + "locks or delete its state dir (would risk a second instance " + "against fresh lock inodes)" + ) + _ICED_BUNDLE_PID = None + + def quit(target: str) -> None: if target == "mac": # Escalate rather than ask: `end_session` deletes the state dir (and @@ -854,6 +1199,9 @@ def quit(target: str) -> None: # relaunch is never signalled just for being slow to exit. _quit_mac_process(graceful=scaled_timeout(10.0)) return + if target == "iced" and _ICED_BUNDLE_PID is not None: + _quit_iced_bundle(graceful=scaled_timeout(10.0)) + return pid = _answering_pid(target) if pid is None: return diff --git a/tools/roosttest_unit/test_iced_bundle_launch.py b/tools/roosttest_unit/test_iced_bundle_launch.py new file mode 100644 index 00000000..fc11a14a --- /dev/null +++ b/tools/roosttest_unit/test_iced_bundle_launch.py @@ -0,0 +1,577 @@ +"""Unit coverage for the `ROOST_ICED_APP` bundle-launch path in +`tools/roosttest/ui.py` (plan 027 C4/W5). + +No real launches here — `linux-test`/`e2e-iced-bundle` cover the live path. +This pins: launch-mode selection (bundle vs bare-binary Popen), the three +loud validation errors, and the log-offset/pid bookkeeping the bundle path +uses in place of `_ICED_PROC`. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +import uuid +from pathlib import Path +from unittest.mock import Mock, patch + +ROOSTTEST_DIR = Path(__file__).resolve().parents[1] / "roosttest" +sys.path.insert(0, str(ROOSTTEST_DIR)) + +import ui # noqa: E402 + +from client import RoostError # noqa: E402 + + +def _absent_log_path() -> Path: + """A unique path that is never created — mocks the UI log location + without `tempfile.mktemp`'s predictable-name hazard (ruff S306).""" + return Path(tempfile.gettempdir()) / f"roost-absent-{uuid.uuid4().hex}.log" + + +def _make_bundle(root: Path, executable_name: str = "Roost-Iced") -> Path: + app = root / "Roost-Iced.app" + macos_dir = app / "Contents" / "MacOS" + macos_dir.mkdir(parents=True) + (macos_dir / executable_name).write_text("#!/bin/sh\n") + return app + + +class IcedBundleAppValidationTests(unittest.TestCase): + """`iced_bundle_app()` validates eagerly, at the point ROOST_ICED_APP is + read, and never falls back silently to the bare binary.""" + + def test_unset_returns_none(self) -> None: + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("ROOST_ICED_APP", None) + self.assertIsNone(ui.iced_bundle_app()) + + @patch("ui.platform.system", return_value="Linux") + def test_set_off_darwin_raises_loudly(self, _system) -> None: + with patch.dict(os.environ, {"ROOST_ICED_APP": "/anywhere.app"}): + with self.assertRaisesRegex(RuntimeError, "macOS-only"): + ui.iced_bundle_app() + + @patch("ui.platform.system", return_value="Darwin") + def test_missing_app_path_raises_loudly(self, _system) -> None: + with tempfile.TemporaryDirectory() as root: + missing = Path(root) / "Roost-Iced.app" + with patch.dict(os.environ, {"ROOST_ICED_APP": str(missing)}): + with self.assertRaisesRegex(FileNotFoundError, "does not exist"): + ui.iced_bundle_app() + + @patch("ui.platform.system", return_value="Darwin") + def test_missing_executable_raises_loudly_not_a_silent_fallback(self, _system) -> None: + with tempfile.TemporaryDirectory() as root: + app = Path(root) / "Roost-Iced.app" + (app / "Contents" / "MacOS").mkdir(parents=True) + # Deliberately no Roost-Iced executable inside. + with patch.dict(os.environ, {"ROOST_ICED_APP": str(app)}): + with self.assertRaisesRegex(FileNotFoundError, "missing its executable"): + ui.iced_bundle_app() + + @patch("ui.platform.system", return_value="Darwin") + def test_valid_bundle_resolves_the_app_path(self, _system) -> None: + with tempfile.TemporaryDirectory() as root: + app = _make_bundle(Path(root)) + with patch.dict(os.environ, {"ROOST_ICED_APP": str(app)}): + self.assertEqual(ui.iced_bundle_app(), app) + + @patch("ui.platform.system", return_value="Darwin") + def test_relative_path_resolves_against_repo_root(self, _system) -> None: + # Mirrors `rust_binary_path`'s override convention (ROOST__BIN): + # a relative override is repo-root-relative, not cwd-relative. + with tempfile.TemporaryDirectory() as root: + _make_bundle(Path(root) / "mac" / "build") + with patch("ui.REPO_ROOT", Path(root)): + with patch.dict( + os.environ, {"ROOST_ICED_APP": "mac/build/Roost-Iced.app"} + ): + self.assertEqual( + ui.iced_bundle_app(), + Path(root) / "mac" / "build" / "Roost-Iced.app", + ) + + +class LaunchModeSelectionTests(unittest.TestCase): + """`launch("iced", ...)` picks the bundle path only when `ROOST_ICED_APP` + resolves to something; otherwise the Popen path is untouched.""" + + def test_launch_dispatches_to_bundle_helper_when_app_resolves(self) -> None: + bundle = Path("/Applications/Roost-Iced.app") + with ( + patch("ui.is_alive", return_value=False), + patch("ui._SESSION_STATE_DIR", None), + patch("ui.iced_bundle_app", return_value=bundle), + patch("ui._launch_iced_bundle") as launch_bundle, + patch("ui.subprocess.run") as run, + ): + ui.launch("iced") + launch_bundle.assert_called_once_with(bundle, state_dir=None) + run.assert_not_called() + + def test_launch_falls_through_to_popen_path_when_app_unset(self) -> None: + state_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, state_dir, True) + with ( + patch("ui.is_alive", return_value=False), + patch("ui.iced_bundle_app", return_value=None), + patch("ui._launch_iced_bundle") as launch_bundle, + patch("ui.rust_binary_path", return_value=(Path("/usr/bin/true"), True)), + patch("ui._SESSION_STATE_DIR", state_dir), + patch("ui._session_config_path", return_value=state_dir / "launcher.conf"), + patch("ui.wait_alive"), + patch("ui.subprocess.Popen") as popen, + ): + popen.return_value = Mock(pid=999) + ui.launch("iced", state_dir=state_dir) + launch_bundle.assert_not_called() + popen.assert_called_once() + + def test_launch_clears_a_stale_bundle_log_offset_on_the_popen_path(self) -> None: + """A prior bundle-mode launch in this process must not leak into a + later bare-binary launch's `_launch_output`/`_boot_refusal`.""" + state_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, state_dir, True) + with ( + patch("ui.is_alive", return_value=False), + patch("ui.iced_bundle_app", return_value=None), + patch("ui._ICED_BUNDLE_LOG_OFFSET", 123), + patch("ui.rust_binary_path", return_value=(Path("/usr/bin/true"), True)), + patch("ui._SESSION_STATE_DIR", state_dir), + patch("ui._session_config_path", return_value=state_dir / "launcher.conf"), + patch("ui.wait_alive"), + patch("ui.subprocess.Popen") as popen, + ): + popen.return_value = Mock(pid=999) + ui.launch("iced", state_dir=state_dir) + self.assertIsNone(ui._ICED_BUNDLE_LOG_OFFSET) + + def test_launch_clears_a_stale_bundle_pid_on_the_popen_path(self) -> None: + """A prior bundle-mode launch's *pid* must not survive into a later + bare-binary launch either — otherwise `quit("iced")` would dispatch + to bundle teardown (signalling a long-dead pid) instead of + terminating the live bare-binary process this launch just started.""" + state_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, state_dir, True) + with ( + patch("ui.is_alive", return_value=False), + patch("ui.iced_bundle_app", return_value=None), + patch("ui._ICED_BUNDLE_PID", 4242), + patch("ui.rust_binary_path", return_value=(Path("/usr/bin/true"), True)), + patch("ui._SESSION_STATE_DIR", state_dir), + patch("ui._session_config_path", return_value=state_dir / "launcher.conf"), + patch("ui.wait_alive"), + patch("ui.subprocess.Popen") as popen, + ): + popen.return_value = Mock(pid=999) + ui.launch("iced", state_dir=state_dir) + self.assertIsNone(ui._ICED_BUNDLE_PID) + + +class LogOffsetBookkeepingTests(unittest.TestCase): + """`_launch_output("iced")` falls back to the bundle's persistent log, + offset-scoped, exactly like the mac branch does for `_MAC_LOG_OFFSET`.""" + + def _log(self, text: str) -> Path: + root = Path(tempfile.mkdtemp(prefix="roost-unit-iced-bundle-log-")) + self.addCleanup(shutil.rmtree, root, True) + log = root / "roost.log" + log.write_text(text) + return log + + def test_no_offset_and_no_proc_is_empty(self) -> None: + with ( + patch("ui._ICED_PROC", None), + patch("ui._ICED_BUNDLE_LOG_OFFSET", None), + ): + self.assertEqual(ui._launch_output("iced"), "") + + def test_bundle_offset_reads_the_bundle_log_from_the_recorded_offset(self) -> None: + log = self._log("stale\nresolved bundle identity bundle_id=\"x\"\n") + offset = len("stale\n") + with ( + patch("ui._ICED_BUNDLE_LOG_OFFSET", offset), + patch("ui._iced_bundle_ui_log_path", return_value=log), + ): + out = ui._launch_output("iced") + self.assertNotIn("stale", out) + self.assertIn("resolved bundle identity", out) + + def test_popen_path_still_wins_when_bundle_offset_is_none(self) -> None: + """Bundle-mode fallback must not shadow the ordinary Popen capture + path when the harness launched iced the normal way.""" + log = self._log("popen output\n") + proc = Mock() + with ( + patch("ui._ICED_PROC", proc), + patch("ui._ICED_LOG", log), + patch("ui._ICED_BUNDLE_LOG_OFFSET", None), + ): + self.assertIn("popen output", ui._launch_output("iced")) + + +class BundleIdentityLogAssertionTests(unittest.TestCase): + def test_missing_log_line_raises(self) -> None: + with patch("ui._launch_output", return_value="boot line only\n"): + with self.assertRaisesRegex(RuntimeError, "did not log the W3"): + ui._assert_bundle_identity_logged() + + def test_present_log_line_passes(self) -> None: + line = ( + 'INFO resolved bundle identity bundle_id="ai.stridelabs.Roost.iced" ' + 'profile="iced"\n' + ) + with patch("ui._launch_output", return_value=line): + ui._assert_bundle_identity_logged() # must not raise + + +class TestModeCanaryTests(unittest.TestCase): + """Cleanup is `delete_project` only, never an explicit `close_tab` first: + closing a project's only tab cascades to delete the project (plan 026 + D8), so closing it explicitly would race the harness's own + `delete_project` call against that cascade.""" + + def test_not_enabled_is_reported_as_a_dropped_test_mode(self) -> None: + client = Mock() + client.identify.return_value = {"pid": 4242} + client.create_project.return_value = 1 + client.open_tab.return_value = 2 + client.tab_feed_pty_bytes.side_effect = RoostError("not-enabled", "nope") + with patch("ui.Roost", return_value=client): + with self.assertRaisesRegex(RuntimeError, "env forwarding dropped"): + ui._assert_test_mode_canary("iced") + client.close_tab.assert_not_called() + client.delete_project.assert_called_once_with(1) + client.close.assert_called_once() + + def test_other_errors_propagate_unwrapped(self) -> None: + client = Mock() + client.identify.return_value = {"pid": 4242} + client.create_project.return_value = 1 + client.open_tab.return_value = 2 + client.tab_feed_pty_bytes.side_effect = RoostError("not-found", "gone") + with patch("ui.Roost", return_value=client): + with self.assertRaisesRegex(RoostError, "not-found"): + ui._assert_test_mode_canary("iced") + client.delete_project.assert_called_once_with(1) + + def test_delete_project_not_found_is_swallowed_as_an_already_cascaded_delete( + self, + ) -> None: + client = Mock() + client.identify.return_value = {"pid": 4242} + client.create_project.return_value = 1 + client.open_tab.return_value = 2 + client.delete_project.side_effect = RoostError("not-found", "gone") + with patch("ui.Roost", return_value=client): + ui._assert_test_mode_canary("iced") # must not raise + client.close.assert_called_once() + + def test_success_cleans_up_the_throwaway_project_without_closing_the_tab_first( + self, + ) -> None: + client = Mock() + client.identify.return_value = {"pid": 4242} + client.create_project.return_value = 1 + client.open_tab.return_value = 2 + with patch("ui.Roost", return_value=client): + ui._assert_test_mode_canary("iced") + client.tab_feed_pty_bytes.assert_called_once_with(2, b"") + client.close_tab.assert_not_called() + client.delete_project.assert_called_once_with(1) + + +class BundlePidVerificationTests(unittest.TestCase): + """`_launch_iced_bundle` refuses to adopt a pid that doesn't belong to + the bundle's own process before recording it for teardown.""" + + def test_mismatched_process_name_refuses_to_adopt_the_pid(self) -> None: + with ( + patch("ui._iced_bundle_ui_log_path", return_value=_absent_log_path()), + patch("ui.subprocess.run"), + patch("ui.wait_alive"), + patch("ui._answering_pid", return_value=4242), + patch("ui._process_command", return_value="/usr/bin/something-else"), + ): + with self.assertRaisesRegex(RuntimeError, "refusing to adopt"): + ui._launch_iced_bundle(Path("/Applications/Roost-Iced.app")) + self.assertIsNone(ui._ICED_BUNDLE_PID) + + def test_matching_process_name_adopts_the_pid(self) -> None: + # Set the allowlisted vars (plus ROOST_BUNDLE_PROFILE, which must + # NOT be forwarded) so the assertions below exercise real content, + # not mocks asserting on mocks. ROOST_TEST_MODE=1 means the real + # gate would fire the live canary, so it's mocked below too. + with ( + patch.dict( + os.environ, + { + "ROOST_TEST_MODE": "1", + "RUST_LOG": "debug", + "ROOST_BUNDLE_PROFILE": "mac", + }, + clear=True, + ), + patch("ui._iced_bundle_ui_log_path", return_value=_absent_log_path()), + patch("ui.subprocess.run") as run, + patch("ui.wait_alive"), + patch("ui._answering_pid", return_value=4242), + patch("ui._process_command", return_value="/Applications/Roost-Iced.app/Contents/MacOS/Roost-Iced"), + patch("ui._assert_bundle_identity_logged") as assert_identity, + patch("ui._assert_test_mode_canary") as assert_canary, + ): + try: + ui._launch_iced_bundle(Path("/Applications/Roost-Iced.app")) + self.assertEqual(ui._ICED_BUNDLE_PID, 4242) + finally: + ui._ICED_BUNDLE_PID = None + open_argv = run.call_args_list[0].args[0] + self.assertEqual(open_argv[0], "open") + self.assertIn("ROOST_TEST_MODE=1", open_argv) + # A filter that doesn't name roost_iced gets the info floor: the + # launch path asserts the INFO-level identity line, so a session + # RUST_LOG=warn (CI's e2e default) must not silence it. + self.assertIn("RUST_LOG=debug,roost_iced=info", open_argv) + self.assertFalse( + any(arg.startswith("ROOST_BUNDLE_PROFILE=") for arg in open_argv), + f"ROOST_BUNDLE_PROFILE must never be forwarded into the bundle " + f"launch (it exists to exercise the bundle-id-derived default " + f"profile): {open_argv!r}", + ) + assert_identity.assert_called_once() + assert_canary.assert_called_once_with("iced") + + def _launch_argv_with_env(self, env: dict[str, str]) -> list[str]: + with ( + tempfile.TemporaryDirectory() as temp_dir, + patch.dict(os.environ, env, clear=True), + patch( + "ui._iced_bundle_ui_log_path", + return_value=Path(temp_dir) / "iced-ui.log", + ), + patch("ui.subprocess.run") as run, + patch("ui.wait_alive"), + patch("ui._answering_pid", return_value=4242), + patch( + "ui._process_command", + return_value="/Applications/Roost-Iced.app/Contents/MacOS/Roost-Iced", + ), + patch("ui._assert_bundle_identity_logged"), + patch("ui._assert_test_mode_canary"), + ): + try: + ui._launch_iced_bundle(Path("/Applications/Roost-Iced.app")) + finally: + ui._ICED_BUNDLE_PID = None + return run.call_args_list[0].args[0] + + def test_an_explicit_roost_iced_rust_log_is_kept_verbatim(self) -> None: + argv = self._launch_argv_with_env({"RUST_LOG": "warn,roost_iced=debug"}) + self.assertIn("RUST_LOG=warn,roost_iced=debug", argv) + self.assertFalse(any("roost_iced=info" in a for a in argv)) + + def test_unset_rust_log_is_not_forwarded(self) -> None: + argv = self._launch_argv_with_env({}) + self.assertFalse(any(a.startswith("RUST_LOG=") for a in argv)) + + def test_executable_outside_the_bundle_refuses_to_adopt_the_pid(self) -> None: + """Same executable *name* as the bundle, but not living inside it — + e.g. a `Roost-Iced` on $PATH from an unrelated build. Must not be + adopted even though the process-name check alone would pass.""" + with ( + patch("ui._iced_bundle_ui_log_path", return_value=_absent_log_path()), + patch("ui.subprocess.run"), + patch("ui.wait_alive"), + patch("ui._answering_pid", return_value=4242), + patch("ui._process_command", return_value="/usr/local/bin/Roost-Iced"), + ): + with self.assertRaisesRegex(RuntimeError, "not under the launched bundle"): + ui._launch_iced_bundle(Path("/Applications/Roost-Iced.app")) + self.assertIsNone(ui._ICED_BUNDLE_PID) + + +class LaunchFailureTeardownTests(unittest.TestCase): + """`_launch_iced_bundle` must not leave a launched bundle running behind + any exception raised after the `open` spawn — else a boot-validation + failure (or a canary failure) leaks a live Roost-Iced process into + whatever the harness runs next.""" + + def test_identity_mismatch_pkills_by_name_when_no_pid_was_ever_adopted( + self, + ) -> None: + with ( + patch("ui._iced_bundle_ui_log_path", return_value=_absent_log_path()), + patch("ui.subprocess.run") as run, + patch("ui.wait_alive"), + patch("ui._answering_pid", return_value=4242), + patch("ui._process_command", return_value="/usr/bin/something-else"), + ): + with self.assertRaisesRegex(RuntimeError, "refusing to adopt"): + ui._launch_iced_bundle(Path("/Applications/Roost-Iced.app")) + self.assertIsNone(ui._ICED_BUNDLE_PID) + pkill_calls = [c.args[0] for c in run.call_args_list if c.args[0][0] == "pkill"] + self.assertEqual(pkill_calls, [["pkill", "-x", ui.ICED_BUNDLE_EXECUTABLE_NAME]]) + + def test_canary_failure_after_pid_adoption_pid_kills_the_bundle(self) -> None: + """Once identity is confirmed and the pid is recorded, a later + failure (here: the ROOST_TEST_MODE canary) must tear the bundle + down through the pid-based path, not a name-based `pkill`.""" + with ( + patch.dict(os.environ, {"ROOST_TEST_MODE": "1"}, clear=True), + patch("ui._iced_bundle_ui_log_path", return_value=_absent_log_path()), + patch("ui.subprocess.run") as run, + patch("ui.wait_alive"), + patch("ui._answering_pid", return_value=4242), + patch( + "ui._process_command", + return_value="/Applications/Roost-Iced.app/Contents/MacOS/Roost-Iced", + ), + patch("ui._assert_bundle_identity_logged"), + patch( + "ui._assert_test_mode_canary", + side_effect=RuntimeError("env forwarding dropped ROOST_TEST_MODE"), + ), + patch("ui._pid_alive", return_value=False), # short-circuits to "already dead" + ): + with self.assertRaisesRegex(RuntimeError, "env forwarding dropped"): + ui._launch_iced_bundle(Path("/Applications/Roost-Iced.app")) + # The pid-based path (`_quit_iced_bundle`), not a name kill. + pkill_calls = [c.args[0] for c in run.call_args_list if c.args[0][0] == "pkill"] + self.assertEqual(pkill_calls, []) + self.assertIsNone(ui._ICED_BUNDLE_PID) + + +class BootLivenessDispatchTests(unittest.TestCase): + """`_boot_refusal` must use the read-only bundle-name probe while a + bundle launch is in flight (no pid recorded yet), not the generic + `_ICED_PROC.poll()` branch (which is always None in bundle mode and + would otherwise permanently read as "still booting").""" + + def _log(self, text: str) -> Path: + root = Path(tempfile.mkdtemp(prefix="roost-unit-iced-bundle-refusal-")) + self.addCleanup(shutil.rmtree, root, True) + log = root / "roost.log" + log.write_text(text) + return log + + def test_running_bundle_process_is_not_a_refusal(self) -> None: + with ( + patch("ui._ICED_BUNDLE_LOG_OFFSET", 0), + patch("ui._roost_iced_bundle_running", return_value=True), + ): + self.assertIsNone(ui._boot_refusal("iced")) + + def test_exited_bundle_process_with_refusal_line_is_reported(self) -> None: + log = self._log( + "Error: another Roost (pid 1) is using this state directory; " + "exiting rather than writing state.json from two processes.\n" + ) + with ( + patch("ui._ICED_BUNDLE_LOG_OFFSET", 0), + patch("ui._iced_bundle_ui_log_path", return_value=log), + patch("ui._roost_iced_bundle_running", return_value=False), + ): + message = ui._boot_refusal("iced") + self.assertIsNotNone(message) + assert message is not None + self.assertIn("refusal, not a hang", message) + + +class QuitDispatchTests(unittest.TestCase): + """`quit("iced")` must route to the pid-based bundle teardown when the + harness owns a bundle-launched process, and never touch `Roost` by name.""" + + def test_quit_dispatches_to_bundle_teardown_when_a_pid_is_recorded(self) -> None: + with ( + patch("ui._ICED_BUNDLE_PID", 4242), + patch("ui._quit_iced_bundle") as quit_bundle, + ): + ui.quit("iced") + quit_bundle.assert_called_once() + + def test_quit_is_a_pure_no_op_when_no_bundle_pid_was_recorded(self) -> None: + with ( + patch("ui._ICED_BUNDLE_PID", None), + patch("ui.subprocess.run") as run, + ): + ui._quit_iced_bundle() + run.assert_not_called() + + def test_quit_escalates_to_sigkill_and_proves_death_before_returning(self) -> None: + with ( + patch("ui._ICED_BUNDLE_PID", 4242), + patch("ui._pid_alive", return_value=True), # the one up-front liveness check + patch( + "ui._process_command", + return_value="/Applications/Roost-Iced.app/Contents/MacOS/Roost-Iced", + ), + patch("ui._wait_pid_gone", side_effect=[False, True]), # SIGTERM window times out, SIGKILL window doesn't + patch("ui.subprocess.run") as run, + ): + ui._quit_iced_bundle() + self.assertEqual( + [call.args[0] for call in run.call_args_list], + [["kill", "4242"], ["kill", "-9", "4242"]], + ) + self.assertIsNone(ui._ICED_BUNDLE_PID) + + def test_quit_raises_rather_than_returning_when_sigkill_is_survived(self) -> None: + with ( + patch("ui._ICED_BUNDLE_PID", 4242), + patch("ui._pid_alive", return_value=True), + patch( + "ui._process_command", + return_value="/Applications/Roost-Iced.app/Contents/MacOS/Roost-Iced", + ), + patch("ui._wait_pid_gone", return_value=False), + patch("ui.subprocess.run"), + ): + with self.assertRaisesRegex(RuntimeError, "survived SIGKILL"): + ui._quit_iced_bundle() + + def test_process_name_kill_is_never_used_for_bundle_teardown(self) -> None: + """Regression guard: teardown must stay pid-based. A `pkill -x + Roost-Iced` (or `-x Roost`) would be a process-name kill banned by + plan 027 W5 — this asserts every `subprocess.run` call `kill`s the + recorded pid directly.""" + with ( + patch("ui._ICED_BUNDLE_PID", 4242), + patch("ui._pid_alive", side_effect=[True, False]), + patch( + "ui._process_command", + return_value="/Applications/Roost-Iced.app/Contents/MacOS/Roost-Iced", + ), + patch("ui._wait_pid_gone", return_value=True), + patch("ui.subprocess.run") as run, + ): + ui._quit_iced_bundle() + for call in run.call_args_list: + argv = call.args[0] + self.assertNotIn("pkill", argv) + self.assertIn("4242", argv) + + def test_quit_treats_a_reused_pid_as_already_dead_without_signalling_it( + self, + ) -> None: + """macOS can recycle a pid between launch and teardown. A live pid + whose process no longer names the bundle must be treated as already + dead — never signalled — or teardown risks killing an unrelated + process that happened to land on the same number.""" + with ( + patch("ui._ICED_BUNDLE_PID", 4242), + patch("ui._pid_alive", return_value=True), + patch("ui._process_command", return_value="/usr/bin/something-else"), + patch("ui.subprocess.run") as run, + ): + ui._quit_iced_bundle() + run.assert_not_called() + self.assertIsNone(ui._ICED_BUNDLE_PID) + + +if __name__ == "__main__": + unittest.main()