Skip to content

Commit fae4776

Browse files
authored
Merge pull request #347 from charliek/feature/plan-028-mac-iced-menubar-sparkle
M6 6d+6c: native macOS menu bar for Roost-Iced + Sparkle mechanics, no feed (plan 028)
2 parents 4bea477 + 7ff1d42 commit fae4776

35 files changed

Lines changed: 5060 additions & 121 deletions

File tree

.github/workflows/ci.yml

Lines changed: 233 additions & 18 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
/third_party/ghostty/src/
1515
/third_party/ghostty/.cache/
1616

17+
# Fetched Sparkle stage (third_party/sparkle/fetch.sh; pinned version+SHA)
18+
/third_party/sparkle/out/
19+
1720
# Mac (Xcode + SwiftPM) build artifacts
1821
/mac/build/
1922
/mac/.build/

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,18 @@ run-mac: bundle ## Launch the bundled Mac app
7171

7272
# ---- test -------------------------------------------------------------
7373

74-
.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
75-
76-
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
77-
# `test_dock_badge.py` self-skips unless the host is macOS AND the target is
78-
# iced (the Dock badge is the M6 6b seam's consumer), so it costs a skip line
79-
# on the Linux lanes and runs for real on the macOS ones.
74+
.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
75+
76+
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
77+
# `test_sparkle.py`'s two classes split by lane: the bare-binary class
78+
# runs here (no framework beside a cargo binary ⇒ updater unavailable)
79+
# and its bundle class self-skips — `make e2e-iced-sparkle` is where the
80+
# latter runs, against a bundle assembled with the fixture's TEST-ONLY
81+
# public key.
82+
# `test_dock_badge.py` and `test_menu_bar.py` self-skip unless the host is
83+
# macOS AND the target is iced (the native menu bar + Dock badge are both
84+
# macOS-iced-only seams — plan 027 § 6b / plan 028 § 6d), so they cost a
85+
# skip line on the Linux lanes and run for real on the macOS ones.
8086
# `selection.*` reads UI state over IPC and never touches the host
8187
# pasteboard, so `test_selection.py` belongs in the list above and runs
8288
# under headless Wayland too. Only files that read/write the real
@@ -87,11 +93,24 @@ ICED_CLIPBOARD_TESTS := tools/roosttest/test_osc52.py
8793
# session it would strand every module that runs after it. Always fresh: it
8894
# empties the workspace, so it must own the instance it drives.
8995
ICED_EXIT_E2E_TESTS := tools/roosttest/test_exit_on_empty.py
96+
# `test_menu_quit.py` (plan 028 C3) is ALSO app-ending (the menu's Quit
97+
# item), so it needs the same "own invocation" isolation `ICED_EXIT_E2E_TESTS`
98+
# gets — but not the SAME invocation as that list: the session-scoped
99+
# harness fixture launches exactly one UI for the whole pytest run, so
100+
# whichever exit-ending module ran first would strand the other. Kept as
101+
# its own list/target/CI steps rather than folded into ICED_EXIT_E2E_TESTS.
102+
ICED_MENU_QUIT_E2E_TESTS := tools/roosttest/test_menu_quit.py
90103
# The release-profile lane's curated subset, not the full ICED_E2E_TESTS
91104
# list: startup, the core op set, the VT pipeline, and font shaping/glyph
92105
# rasterization — the last two because the one release-only bug this stack
93106
# has produced was issue #299's swash shaping hang.
94107
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
108+
# The Sparkle lane's bundle inputs. The feed URL is a deliberate dead
109+
# placeholder: the seam's test-mode delegate override replaces it with
110+
# the live loopback port at check time, and a plist URL that could never
111+
# resolve means a stray launch of this bundle can't reach anything.
112+
SPARKLE_TEST_PUBLIC_KEY := tools/roosttest/fixtures/sparkle/TEST-ONLY-public-ed-key.txt
113+
SPARKLE_TEST_PLACEHOLDER_FEED := http://127.0.0.1:1/placeholder
95114
test: test-rust test-mac test-harness ## All unit/integration tests (Rust + Swift + harness)
96115

97116
# roost-vt's tests/*.rs all start with `#![cfg(feature = "ffi")]`, so the
@@ -126,6 +145,9 @@ e2e-iced: ## Required functional E2E against Iced
126145
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)
127146
ROOST_TEST_MODE=1 uv run --group test pytest $(ICED_EXIT_E2E_TESTS) --roost-target iced --roost-fresh
128147

148+
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.
149+
ROOST_TEST_MODE=1 uv run --group test pytest $(ICED_MENU_QUIT_E2E_TESTS) --roost-target iced --roost-fresh
150+
129151
e2e-iced-clipboard: ## Native Iced clipboard/OSC E2E (macOS or Linux X11; not headless Wayland)
130152
uv run --group test pytest $(ICED_CLIPBOARD_TESTS) --roost-target iced
131153

@@ -155,7 +177,16 @@ e2e-mac-ci: ## Mac E2E at CI parity. DESTRUCTIVE: force-quits any running Roost
155177
e2e-iced-bundle: ## macOS-only: assemble Roost-Iced.app + run the curated bundle smoke against it (ROOST_ICED_APP)
156178
@[ "$$(uname -s)" = "Darwin" ] || { echo "e2e-iced-bundle is macOS-only: it launches Roost-Iced.app via LaunchServices (open)"; exit 1; }
157179
$(MAKE) bundle-iced
158-
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
180+
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
181+
182+
e2e-iced-sparkle: ## macOS-only: assemble a TEST-KEYED Roost-Iced.app + run the Sparkle E2E against a loopback appcast
183+
@[ "$$(uname -s)" = "Darwin" ] || { echo "e2e-iced-sparkle is macOS-only: it launches Roost-Iced.app via LaunchServices (open)"; exit 1; }
184+
@test -f $(SPARKLE_TEST_PUBLIC_KEY) || { echo "missing $(SPARKLE_TEST_PUBLIC_KEY)"; exit 1; }
185+
ROOST_ICED_SPARKLE_FEED_URL=$(SPARKLE_TEST_PLACEHOLDER_FEED) \
186+
ROOST_ICED_SPARKLE_ED_PUBLIC_KEY="$$(cat $(SPARKLE_TEST_PUBLIC_KEY))" \
187+
$(MAKE) bundle-iced
188+
ROOST_ICED_APP=mac/build/Roost-Iced.app ROOST_TEST_MODE=1 \
189+
uv run --group test pytest tools/roosttest/test_sparkle.py --roost-target iced --roost-fresh
159190

160191
smoke-gtk: ## Screenshot-driven UI smoke against a running GTK UI
161192
tools/screenshot/smoke.sh gtk

crates/roost-engine/src/ipc.rs

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,17 @@ use roost_ipc::agent::{self, TabAgentReportParams};
2323
use roost_ipc::messages::{
2424
ops, AppActivateParams, AppActiveTerminalFocusedParams, AppActiveTerminalFocusedResult,
2525
AppCursorShapeParams, AppCursorShapeResult, AppDockBadgeParams, AppDockBadgeResult,
26-
AppRenderStatsParams, AppRenderStatsResult, AppSelectedTabIdParams, AppSelectedTabIdResult,
27-
AppSetWindowFocusParams, ClipboardDumpParams, ClipboardDumpResult, ClipboardWriteParams,
28-
IdentifyParams, IdentifyResult, NotificationCreateParams, PaletteActivateParams,
29-
PaletteDismissParams, PaletteOpenParams, PalettePresentParams, PalettePresentResult,
30-
PaletteQueryParams, PaletteStateParams, PaletteStateResult, ProjectCreateParams,
31-
ProjectCreateResult, ProjectDeleteParams, ProjectRenameParams, ProjectReorderParams,
32-
ResolvedCell, ScreenshotParams, ScreenshotResult, SelectionClearParams, SelectionDumpParams,
33-
SelectionDumpResult, SelectionSetParams, SidebarDumpParams, SidebarDumpResult,
34-
SidebarSetWidthParams, TabAgentReportResult, TabCapturePtyInputParams,
35-
TabCapturePtyInputResult, TabClearNotificationParams, TabCloseParams,
26+
AppMenuActivateParams, AppMenuDumpParams, AppMenuDumpResult, AppRenderStatsParams,
27+
AppRenderStatsResult, AppSelectedTabIdParams, AppSelectedTabIdResult, AppSetWindowFocusParams,
28+
AppUpdateCheckParams, AppUpdateStatusParams, AppUpdateStatusResult, ClipboardDumpParams,
29+
ClipboardDumpResult, ClipboardWriteParams, IdentifyParams, IdentifyResult,
30+
NotificationCreateParams, PaletteActivateParams, PaletteDismissParams, PaletteOpenParams,
31+
PalettePresentParams, PalettePresentResult, PaletteQueryParams, PaletteStateParams,
32+
PaletteStateResult, ProjectCreateParams, ProjectCreateResult, ProjectDeleteParams,
33+
ProjectRenameParams, ProjectReorderParams, ResolvedCell, ScreenshotParams, ScreenshotResult,
34+
SelectionClearParams, SelectionDumpParams, SelectionDumpResult, SelectionSetParams,
35+
SidebarDumpParams, SidebarDumpResult, SidebarSetWidthParams, TabAgentReportResult,
36+
TabCapturePtyInputParams, TabCapturePtyInputResult, TabClearNotificationParams, TabCloseParams,
3637
TabDispatchMouseEventParams, TabDumpCursor, TabDumpParams, TabDumpResolvedParams,
3738
TabDumpResolvedResult, TabDumpResult, TabExpandSelectionAtParams, TabExpandSelectionAtResult,
3839
TabFeedImeParams, TabFeedPtyBytesParams, TabFocusParams, TabFocusResult, TabListResult,
@@ -360,6 +361,34 @@ pub enum UiRequest {
360361
AppDockBadge {
361362
reply: tokio::sync::oneshot::Sender<Result<Option<String>, String>>,
362363
},
364+
/// `app.menu_dump` — read back the live native menu bar the macOS
365+
/// iced UI installed, walking `NSApp.mainMenu` itself rather than
366+
/// re-deriving from the keybind table. Gated + macOS-iced-only like
367+
/// `AppDockBadge`.
368+
AppMenuDump {
369+
reply: tokio::sync::oneshot::Sender<Result<AppMenuDumpResult, String>>,
370+
},
371+
/// `app.menu_activate` — resolve `path` through the live native
372+
/// menu bar by title and fire it via
373+
/// `performActionForItemAtIndex:`, the same dispatch a real click
374+
/// takes. Gated + macOS-iced-only like `AppDockBadge`.
375+
AppMenuActivate {
376+
path: Vec<String>,
377+
reply: tokio::sync::oneshot::Sender<Result<(), String>>,
378+
},
379+
/// `app.update_status` — read back the macOS iced UI's Sparkle
380+
/// updater state (framework loaded, updater started, last completed
381+
/// check). Gated + macOS-iced-only like `AppDockBadge`.
382+
AppUpdateStatus {
383+
reply: tokio::sync::oneshot::Sender<Result<AppUpdateStatusResult, String>>,
384+
},
385+
/// `app.update_check` — start a non-interactive
386+
/// `checkForUpdateInformation` on the Sparkle updater. Results land
387+
/// in `AppUpdateStatus`. Gated + macOS-iced-only like
388+
/// `AppDockBadge`.
389+
AppUpdateCheck {
390+
reply: tokio::sync::oneshot::Sender<Result<(), String>>,
391+
},
363392
}
364393

365394
/// Resolved clipboard target for the `clipboard.*` ops. Lives in this
@@ -1108,6 +1137,39 @@ async fn dispatch(
11081137
.map_err(map_test_op_err)?;
11091138
encode(&AppDockBadgeResult { label })
11101139
}
1140+
ops::APP_MENU_DUMP => {
1141+
let _: AppMenuDumpParams = decode(params)?;
1142+
let result = h
1143+
.ui_call(|reply| UiRequest::AppMenuDump { reply })
1144+
.await?
1145+
.map_err(map_test_op_err)?;
1146+
encode(&result)
1147+
}
1148+
ops::APP_MENU_ACTIVATE => {
1149+
let p: AppMenuActivateParams = decode(params)?;
1150+
h.ui_call(|reply| UiRequest::AppMenuActivate {
1151+
path: p.path,
1152+
reply,
1153+
})
1154+
.await?
1155+
.map_err(map_test_op_err)?;
1156+
Ok(serde_json::json!({}))
1157+
}
1158+
ops::APP_UPDATE_STATUS => {
1159+
let _: AppUpdateStatusParams = decode(params)?;
1160+
let result = h
1161+
.ui_call(|reply| UiRequest::AppUpdateStatus { reply })
1162+
.await?
1163+
.map_err(map_test_op_err)?;
1164+
encode(&result)
1165+
}
1166+
ops::APP_UPDATE_CHECK => {
1167+
let _: AppUpdateCheckParams = decode(params)?;
1168+
h.ui_call(|reply| UiRequest::AppUpdateCheck { reply })
1169+
.await?
1170+
.map_err(map_test_op_err)?;
1171+
Ok(serde_json::json!({}))
1172+
}
11111173
ops::EVENTS_SUBSCRIBE => {
11121174
// Honest failure rather than a false ACK: the server never
11131175
// pushes events on the connection yet, so a client that
@@ -1162,13 +1224,17 @@ fn rgb_hex(c: (u8, u8, u8)) -> String {
11621224
/// * `tab.feed_ime`'s `tab_id` not matching the tab that currently
11631225
/// holds the keyboard route → `invalid-param` — the caller asked
11641226
/// to feed the wrong tab, not a server failure.
1227+
/// * `app.menu_activate`'s path resolution failing (unknown path,
1228+
/// ambiguous title, or a disabled item) → `invalid-param` — the
1229+
/// caller asked for a path the live menu bar doesn't support.
11651230
/// * an op a UI hasn't wired up yet (`tab.feed_ime` on GTK, still
11661231
/// iced-only), or one that is structurally unavailable there
11671232
/// (`app.dock_badge` off macOS — there is no Dock) →
11681233
/// `not-implemented`, mirroring `events.subscribe`.
1169-
/// * anything else (capture buffer poisoned, feed channel closed)
1170-
/// → `internal`, so a real failure surfaces clearly rather than
1171-
/// being mistaken for a missing tab.
1234+
/// * anything else (capture buffer poisoned, feed channel closed,
1235+
/// the native menu bar not installed yet) → `internal`, so a real
1236+
/// failure surfaces clearly rather than being mistaken for a
1237+
/// missing tab.
11721238
///
11731239
/// The substring contract is the simplest seam between the UI and
11741240
/// the dispatcher while the surface stays small; bumping to a typed
@@ -1178,7 +1244,13 @@ fn map_test_op_err(err: String) -> HandlerError {
11781244
HandlerError::new("not-enabled", err)
11791245
} else if err.contains("has no live terminal") || err.contains("no word/line span") {
11801246
HandlerError::not_found(err)
1181-
} else if err.contains("is not the active terminal") {
1247+
} else if err.contains("is not the active terminal")
1248+
|| err.contains("no menu item")
1249+
|| err.contains("ambiguous menu")
1250+
|| err.contains("is disabled")
1251+
|| err.contains("has no submenu to descend into")
1252+
|| err.contains("must not be empty")
1253+
{
11821254
HandlerError::invalid_param(err)
11831255
} else if err.contains("not supported on this UI") {
11841256
HandlerError::new("not-implemented", err)

crates/roost-iced/Cargo.toml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ unicode-width = { workspace = true }
7272
# must move with THAT pair: a bump that shifts objc2-core-foundation's
7373
# major would otherwise resolve a second copy of the crate into the lock.
7474
[target.'cfg(target_os = "macos")'.dependencies]
75+
# `dlopen`/`dlerror` for the Sparkle seam (plan 028 § 3.8) — the
76+
# framework is loaded at runtime from inside the bundle, never linked,
77+
# so no build machinery may depend on it. Already in the workspace's
78+
# graph via roost-ipc + roost-engine.
79+
libc = "0.2"
7580
objc2-core-foundation = { version = "0.3", default-features = false, features = [
7681
"std",
7782
"CFBundle",
@@ -83,8 +88,9 @@ objc2-core-foundation = { version = "0.3", default-features = false, features =
8388
# these ride the objc2 0.6 generation arboard + softbuffer already pull in
8489
# (winit still rides objc2 0.5) and must move with THAT pair — a bump that
8590
# shifts the major would resolve a second copy of the ecosystem into the
86-
# lock. Features are the Dock-badge consumer's minimum, not the crates'
87-
# defaults: `objc2-app-kit`'s `default` turns on ~250 of them.
91+
# lock. Features are the seam consumers' minimum, not the crates' defaults:
92+
# `objc2-app-kit`'s `default` turns on ~250 of them, so each consumer adds
93+
# only what it names.
8894
objc2 = "0.6"
8995
objc2-foundation = { version = "0.3", default-features = false, features = [
9096
"std",
@@ -94,7 +100,13 @@ objc2-foundation = { version = "0.3", default-features = false, features = [
94100
objc2-app-kit = { version = "0.3", default-features = false, features = [
95101
"std",
96102
"NSApplication",
103+
# `NSControlStateValue` — the Window menu's active-row checkmark.
104+
"NSCell",
97105
"NSDockTile",
106+
# `NSEventModifierFlags` — menu key-equivalent modifier masks.
107+
"NSEvent",
108+
"NSMenu",
109+
"NSMenuItem",
98110
# `NSApplication`'s superclass; the class item does not exist without it.
99111
"NSResponder",
100112
] }

0 commit comments

Comments
 (0)