Skip to content
Closed
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
152 changes: 150 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

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

17 changes: 14 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 --------------------------------------------------------------
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
41 changes: 30 additions & 11 deletions crates/roost-engine/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -351,6 +352,14 @@ pub enum UiRequest {
AppSelectedTabId {
reply: tokio::sync::oneshot::Sender<Result<i64, String>>,
},
/// `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<Result<Option<String>, String>>,
},
}

/// Resolved clipboard target for the `clipboard.*` ops. Lives in this
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading