Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 233 additions & 18 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
/third_party/ghostty/src/
/third_party/ghostty/.cache/

# Fetched Sparkle stage (third_party/sparkle/fetch.sh; pinned version+SHA)
/third_party/sparkle/out/

# Mac (Xcode + SwiftPM) build artifacts
/mac/build/
/mac/.build/
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 38 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,18 @@ 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 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 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.
.PHONY: test test-rust test-iced test-mac test-harness e2e e2e-gtk e2e-iced e2e-iced-exit e2e-iced-menu-quit e2e-iced-clipboard e2e-mac e2e-gtk-ci e2e-iced-ci e2e-iced-release-ci e2e-mac-ci e2e-iced-bundle e2e-iced-sparkle 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 tools/roosttest/test_dock_badge.py tools/roosttest/test_menu_bar.py tools/roosttest/test_sparkle.py
# `test_sparkle.py`'s two classes split by lane: the bare-binary class
# runs here (no framework beside a cargo binary ⇒ updater unavailable)
# and its bundle class self-skips — `make e2e-iced-sparkle` is where the
# latter runs, against a bundle assembled with the fixture's TEST-ONLY
# public key.
# `test_dock_badge.py` and `test_menu_bar.py` self-skip unless the host is
# macOS AND the target is iced (the native menu bar + Dock badge are both
# macOS-iced-only seams — plan 027 § 6b / plan 028 § 6d), so they cost a
# skip line on the Linux lanes and run 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
Expand All @@ -87,11 +93,24 @@ ICED_CLIPBOARD_TESTS := tools/roosttest/test_osc52.py
# session it would strand every module that runs after it. Always fresh: it
# empties the workspace, so it must own the instance it drives.
ICED_EXIT_E2E_TESTS := tools/roosttest/test_exit_on_empty.py
# `test_menu_quit.py` (plan 028 C3) is ALSO app-ending (the menu's Quit
# item), so it needs the same "own invocation" isolation `ICED_EXIT_E2E_TESTS`
# gets — but not the SAME invocation as that list: the session-scoped
# harness fixture launches exactly one UI for the whole pytest run, so
# whichever exit-ending module ran first would strand the other. Kept as
# its own list/target/CI steps rather than folded into ICED_EXIT_E2E_TESTS.
ICED_MENU_QUIT_E2E_TESTS := tools/roosttest/test_menu_quit.py
# The release-profile lane's curated subset, not the full ICED_E2E_TESTS
# list: startup, the core op set, the VT pipeline, and font shaping/glyph
# rasterization — the last two because the one release-only bug this stack
# has produced was issue #299's swash shaping hang.
ICED_RELEASE_E2E_TESTS := tools/roosttest/test_smoke.py tools/roosttest/test_iced_walking_skeleton.py tools/roosttest/test_osc_pipeline.py tools/roosttest/test_z_typography.py tools/roosttest/test_sprite_pixels.py
# The Sparkle lane's bundle inputs. The feed URL is a deliberate dead
# placeholder: the seam's test-mode delegate override replaces it with
# the live loopback port at check time, and a plist URL that could never
# resolve means a stray launch of this bundle can't reach anything.
SPARKLE_TEST_PUBLIC_KEY := tools/roosttest/fixtures/sparkle/TEST-ONLY-public-ed-key.txt
SPARKLE_TEST_PLACEHOLDER_FEED := http://127.0.0.1:1/placeholder
test: test-rust test-mac test-harness ## All unit/integration tests (Rust + Swift + harness)

# roost-vt's tests/*.rs all start with `#![cfg(feature = "ffi")]`, so the
Expand Down Expand Up @@ -126,6 +145,9 @@ e2e-iced: ## Required functional E2E against Iced
e2e-iced-exit: ## Iced exit-on-empty E2E in its own lane (DESTRUCTIVE: force-quits a running Iced UI, and the UI it launches exits)
ROOST_TEST_MODE=1 uv run --group test pytest $(ICED_EXIT_E2E_TESTS) --roost-target iced --roost-fresh

e2e-iced-menu-quit: ## Iced menu-Quit E2E in its own lane (DESTRUCTIVE: force-quits a running Iced UI, and the UI it launches exits via the menu). macOS-iced-only; self-skips elsewhere.
ROOST_TEST_MODE=1 uv run --group test pytest $(ICED_MENU_QUIT_E2E_TESTS) --roost-target iced --roost-fresh

e2e-iced-clipboard: ## Native Iced clipboard/OSC E2E (macOS or Linux X11; not headless Wayland)
uv run --group test pytest $(ICED_CLIPBOARD_TESTS) --roost-target iced

Expand Down Expand Up @@ -155,7 +177,16 @@ e2e-mac-ci: ## Mac E2E at CI parity. DESTRUCTIVE: force-quits any running Roost
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
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 tools/roosttest/test_menu_bar.py --roost-target iced --roost-fresh

e2e-iced-sparkle: ## macOS-only: assemble a TEST-KEYED Roost-Iced.app + run the Sparkle E2E against a loopback appcast
@[ "$$(uname -s)" = "Darwin" ] || { echo "e2e-iced-sparkle is macOS-only: it launches Roost-Iced.app via LaunchServices (open)"; exit 1; }
@test -f $(SPARKLE_TEST_PUBLIC_KEY) || { echo "missing $(SPARKLE_TEST_PUBLIC_KEY)"; exit 1; }
ROOST_ICED_SPARKLE_FEED_URL=$(SPARKLE_TEST_PLACEHOLDER_FEED) \
ROOST_ICED_SPARKLE_ED_PUBLIC_KEY="$$(cat $(SPARKLE_TEST_PUBLIC_KEY))" \
$(MAKE) bundle-iced
ROOST_ICED_APP=mac/build/Roost-Iced.app ROOST_TEST_MODE=1 \
uv run --group test pytest tools/roosttest/test_sparkle.py --roost-target iced --roost-fresh

smoke-gtk: ## Screenshot-driven UI smoke against a running GTK UI
tools/screenshot/smoke.sh gtk
Expand Down
100 changes: 86 additions & 14 deletions crates/roost-engine/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,17 @@ use roost_ipc::agent::{self, TabAgentReportParams};
use roost_ipc::messages::{
ops, AppActivateParams, AppActiveTerminalFocusedParams, AppActiveTerminalFocusedResult,
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,
AppMenuActivateParams, AppMenuDumpParams, AppMenuDumpResult, AppRenderStatsParams,
AppRenderStatsResult, AppSelectedTabIdParams, AppSelectedTabIdResult, AppSetWindowFocusParams,
AppUpdateCheckParams, AppUpdateStatusParams, AppUpdateStatusResult, 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,
Expand Down Expand Up @@ -360,6 +361,34 @@ pub enum UiRequest {
AppDockBadge {
reply: tokio::sync::oneshot::Sender<Result<Option<String>, String>>,
},
/// `app.menu_dump` — read back the live native menu bar the macOS
/// iced UI installed, walking `NSApp.mainMenu` itself rather than
/// re-deriving from the keybind table. Gated + macOS-iced-only like
/// `AppDockBadge`.
AppMenuDump {
reply: tokio::sync::oneshot::Sender<Result<AppMenuDumpResult, String>>,
},
/// `app.menu_activate` — resolve `path` through the live native
/// menu bar by title and fire it via
/// `performActionForItemAtIndex:`, the same dispatch a real click
/// takes. Gated + macOS-iced-only like `AppDockBadge`.
AppMenuActivate {
path: Vec<String>,
reply: tokio::sync::oneshot::Sender<Result<(), String>>,
},
/// `app.update_status` — read back the macOS iced UI's Sparkle
/// updater state (framework loaded, updater started, last completed
/// check). Gated + macOS-iced-only like `AppDockBadge`.
AppUpdateStatus {
reply: tokio::sync::oneshot::Sender<Result<AppUpdateStatusResult, String>>,
},
/// `app.update_check` — start a non-interactive
/// `checkForUpdateInformation` on the Sparkle updater. Results land
/// in `AppUpdateStatus`. Gated + macOS-iced-only like
/// `AppDockBadge`.
AppUpdateCheck {
reply: tokio::sync::oneshot::Sender<Result<(), String>>,
},
}

/// Resolved clipboard target for the `clipboard.*` ops. Lives in this
Expand Down Expand Up @@ -1108,6 +1137,39 @@ async fn dispatch(
.map_err(map_test_op_err)?;
encode(&AppDockBadgeResult { label })
}
ops::APP_MENU_DUMP => {
let _: AppMenuDumpParams = decode(params)?;
let result = h
.ui_call(|reply| UiRequest::AppMenuDump { reply })
.await?
.map_err(map_test_op_err)?;
encode(&result)
}
ops::APP_MENU_ACTIVATE => {
let p: AppMenuActivateParams = decode(params)?;
h.ui_call(|reply| UiRequest::AppMenuActivate {
path: p.path,
reply,
})
.await?
.map_err(map_test_op_err)?;
Ok(serde_json::json!({}))
}
ops::APP_UPDATE_STATUS => {
let _: AppUpdateStatusParams = decode(params)?;
let result = h
.ui_call(|reply| UiRequest::AppUpdateStatus { reply })
.await?
.map_err(map_test_op_err)?;
encode(&result)
}
ops::APP_UPDATE_CHECK => {
let _: AppUpdateCheckParams = decode(params)?;
h.ui_call(|reply| UiRequest::AppUpdateCheck { reply })
.await?
.map_err(map_test_op_err)?;
Ok(serde_json::json!({}))
}
ops::EVENTS_SUBSCRIBE => {
// Honest failure rather than a false ACK: the server never
// pushes events on the connection yet, so a client that
Expand Down Expand Up @@ -1162,13 +1224,17 @@ fn rgb_hex(c: (u8, u8, u8)) -> String {
/// * `tab.feed_ime`'s `tab_id` not matching the tab that currently
/// holds the keyboard route → `invalid-param` — the caller asked
/// to feed the wrong tab, not a server failure.
/// * `app.menu_activate`'s path resolution failing (unknown path,
/// ambiguous title, or a disabled item) → `invalid-param` — the
/// caller asked for a path the live menu bar doesn't support.
/// * an op a UI hasn't wired up yet (`tab.feed_ime` on GTK, still
/// 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.
/// * anything else (capture buffer poisoned, feed channel closed,
/// the native menu bar not installed yet) → `internal`, so a real
/// failure surfaces clearly rather than being mistaken for a
/// missing tab.
///
/// The substring contract is the simplest seam between the UI and
/// the dispatcher while the surface stays small; bumping to a typed
Expand All @@ -1178,7 +1244,13 @@ fn map_test_op_err(err: String) -> HandlerError {
HandlerError::new("not-enabled", err)
} else if err.contains("has no live terminal") || err.contains("no word/line span") {
HandlerError::not_found(err)
} else if err.contains("is not the active terminal") {
} else if err.contains("is not the active terminal")
|| err.contains("no menu item")
|| err.contains("ambiguous menu")
|| err.contains("is disabled")
|| err.contains("has no submenu to descend into")
|| err.contains("must not be empty")
{
Comment thread
coderabbitai[bot] marked this conversation as resolved.
HandlerError::invalid_param(err)
} else if err.contains("not supported on this UI") {
HandlerError::new("not-implemented", err)
Expand Down
16 changes: 14 additions & 2 deletions crates/roost-iced/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ unicode-width = { workspace = true }
# 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]
# `dlopen`/`dlerror` for the Sparkle seam (plan 028 § 3.8) — the
# framework is loaded at runtime from inside the bundle, never linked,
# so no build machinery may depend on it. Already in the workspace's
# graph via roost-ipc + roost-engine.
libc = "0.2"
objc2-core-foundation = { version = "0.3", default-features = false, features = [
"std",
"CFBundle",
Expand All @@ -83,8 +88,9 @@ objc2-core-foundation = { version = "0.3", default-features = false, features =
# 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.
# lock. Features are the seam consumers' minimum, not the crates' defaults:
# `objc2-app-kit`'s `default` turns on ~250 of them, so each consumer adds
# only what it names.
objc2 = "0.6"
objc2-foundation = { version = "0.3", default-features = false, features = [
"std",
Expand All @@ -94,7 +100,13 @@ objc2-foundation = { version = "0.3", default-features = false, features = [
objc2-app-kit = { version = "0.3", default-features = false, features = [
"std",
"NSApplication",
# `NSControlStateValue` — the Window menu's active-row checkmark.
"NSCell",
"NSDockTile",
# `NSEventModifierFlags` — menu key-equivalent modifier masks.
"NSEvent",
"NSMenu",
"NSMenuItem",
# `NSApplication`'s superclass; the class item does not exist without it.
"NSResponder",
] }
Expand Down
Loading
Loading