diff --git a/crates/ghostty-apprt-gtk/src/class/application.rs b/crates/ghostty-apprt-gtk/src/class/application.rs index 81ed9c9..ac58d72 100644 --- a/crates/ghostty-apprt-gtk/src/class/application.rs +++ b/crates/ghostty-apprt-gtk/src/class/application.rs @@ -293,6 +293,12 @@ mod imp { /// The global shortcut logic. pub global_shortcuts: OnceCell, + /// True once the shortcuts' `config` property is bound to ours + /// (Zig: `bindProperty` in `startupGlobalShortcuts`, after the + /// D-Bus connection was set; before that the config must not reach + /// the shortcuts, or the portal session is created twice). + pub global_shortcuts_bound: Cell, + /// Set to true so long as we request a window exactly once. This /// prevents quitting the app before we've shown one window. pub requested_window: Cell, @@ -827,7 +833,9 @@ impl Application { } // Keep global shortcuts in sync with the config (property binding). - if let Some(gs) = self.imp().global_shortcuts.get() { + if self.imp().global_shortcuts_bound.get() + && let Some(gs) = self.imp().global_shortcuts.get() + { gs.set_config(self.imp().config.borrow().as_ref()); } } @@ -1075,6 +1083,7 @@ impl Application { gs.set_connection(self.dbus_connection().as_ref()); // Keep the shortcut config in sync with the app. + self.imp().global_shortcuts_bound.set(true); gs.set_config(self.imp().config.borrow().as_ref()); // Setup the signal handler for global shortcut triggers diff --git a/crates/ghostty-apprt-gtk/src/class/global_shortcuts.rs b/crates/ghostty-apprt-gtk/src/class/global_shortcuts.rs index f2006bf..3418e46 100644 --- a/crates/ghostty-apprt-gtk/src/class/global_shortcuts.rs +++ b/crates/ghostty-apprt-gtk/src/class/global_shortcuts.rs @@ -157,6 +157,42 @@ fn vardict(entries: Vec<(&str, glib::Variant)>) -> glib::Variant { map.to_variant() } +/// `(a{sv})`: the `CreateSession` arguments. +fn create_session_payload(handle_token: &str, session_handle_token: &str) -> glib::Variant { + let opts = vardict(vec![ + ("handle_token", handle_token.to_variant()), + ("session_handle_token", session_handle_token.to_variant()), + ]); + glib::Variant::tuple_from_iter([opts]) +} + +/// `(oa(sa{sv})sa{sv})`: the `BindShortcuts` arguments (session handle, +/// shortcuts, parent window, options). +fn bind_shortcuts_payload<'a>( + handle: &str, + shortcuts: impl Iterator, + request_token: &str, +) -> Option { + let handle = ObjectPath::try_from(handle.to_owned()).ok()?; + let binds = glib::Variant::array_from_iter_with_type( + glib::VariantTy::new("(sa{sv})").ok()?, + shortcuts.map(|(trigger, description)| { + let opts = vardict(vec![ + ("description", description.to_variant()), + ("preferred_trigger", trigger.to_variant()), + ]); + glib::Variant::tuple_from_iter([trigger.to_variant(), opts]) + }), + ); + let opts = vardict(vec![("handle_token", request_token.to_variant())]); + Some(glib::Variant::tuple_from_iter([ + handle.to_variant(), + binds, + String::new().to_variant(), + opts, + ])) +} + impl GlobalShortcuts { pub fn new() -> Self { glib::Object::new() @@ -279,39 +315,18 @@ impl GlobalShortcuts { fn make_payload(&self, method: Method, request_token: &str) -> Option { match method { // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession - Method::CreateSession => { - let opts = vardict(vec![ - ("handle_token", request_token.to_variant().to_variant()), - ( - "session_handle_token", - generate_token().to_variant().to_variant(), - ), - ]); - Some((opts,).to_variant()) - } + Method::CreateSession => Some(create_session_payload(request_token, &generate_token())), // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts Method::BindShortcuts => { let imp = self.imp(); let handle = imp.handle.borrow().clone()?; - let handle = ObjectPath::try_from(handle).ok()?; - - let binds: Vec<(String, glib::Variant)> = imp - .map - .borrow() - .iter() - .map(|(trigger, action)| { - let opts = vardict(vec![ - ("description", action.to_string().to_variant().to_variant()), - ("preferred_trigger", trigger.to_variant().to_variant()), - ]); - (trigger.clone(), opts) - }) - .collect(); - let opts = vardict(vec![( - "handle_token", - request_token.to_variant().to_variant(), - )]); - Some((handle, binds, String::new(), opts).to_variant()) + let map = imp.map.borrow(); + bind_shortcuts_payload( + &handle, + map.iter() + .map(|(trigger, action)| (trigger.as_str(), action.to_string())), + request_token, + ) } } } @@ -459,3 +474,40 @@ impl GlobalShortcuts { self.emit_by_name::<()>("trigger", &[&action.to_string()]); } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn create_session_payload_signature() { + let v = create_session_payload("tok", "stok"); + assert_eq!(v.type_().as_str(), "(a{sv})"); + let (m,): (HashMap,) = v.get().unwrap(); + assert_eq!(m["handle_token"].get::().unwrap(), "tok"); + assert_eq!(m["session_handle_token"].get::().unwrap(), "stok"); + } + + #[test] + fn bind_shortcuts_payload_signature() { + let v = bind_shortcuts_payload( + "/org/freedesktop/portal/desktop/session/1_2/x", + [("CTRL+ALT+x", "new_window".to_owned())].into_iter(), + "tok", + ) + .unwrap(); + assert_eq!(v.type_().as_str(), "(oa(sa{sv})sa{sv})"); + assert_eq!(v.n_children(), 4); + let binds = v.child_value(1); + assert_eq!(binds.n_children(), 1); + let bind = binds.child_value(0); + assert_eq!(bind.child_value(0).str(), Some("CTRL+ALT+x")); + let opts: HashMap = bind.child_value(1).get().unwrap(); + assert_eq!(opts["description"].get::().unwrap(), "new_window"); + assert_eq!( + opts["preferred_trigger"].get::().unwrap(), + "CTRL+ALT+x" + ); + } +} diff --git a/docs/rewrite/KNOWN_DIFFERENCES.md b/docs/rewrite/KNOWN_DIFFERENCES.md index 88a5aaa..3cec0e8 100644 --- a/docs/rewrite/KNOWN_DIFFERENCES.md +++ b/docs/rewrite/KNOWN_DIFFERENCES.md @@ -11,6 +11,7 @@ What remains is verification coverage that this Linux machine cannot provide: - Native Apple execution: Metal, IOSurface, CoreText, Apple input APIs, Mach handling and Apple packaging (compile-checked only, `just check-macos`). - Native Windows (real Windows console host, Windows-only APIs Wine stubs: `ResizePseudoConsole` is a Wine stub, POSIX-semantics rename over an existing file is unsupported by Wine). The Windows code paths are otherwise run under Wine (`just test-windows-wine`, see `notes/windows-wine.md`): ConPTY, `CreateProcessW`, pipes, the IO thread and `cmd.exe` children are runtime-tested; `just check-windows` stays the clippy gate. - Native FreeBSD execution (cross `cargo check` only, if the target could be added). -- Live portal / D-Bus integration. +- Real input methods (ibus/fcitx are not installed; the IME paths are covered with a mock `GtkIMContext`, see `notes/verification-VR.md`). +- Desktop-specific portal backends (GNOME/KDE GlobalShortcuts, a real notification daemon). The D-Bus/portal protocol Ghostty speaks (GTK single-instance and `+new-window`, desktop notifications, GlobalShortcuts, Settings colour scheme, systemd cgroup scope, xdg-open) *is* now covered at runtime against the original on a private bus, with mocks and the real xdg-desktop-portal + gtk backend (`just live-dbus`, `notes/live-dbus.md`). - Real-browser rendering (wasm is checked with Node only). - Native io_uring execution; Swift code was excluded. diff --git a/docs/rewrite/notes/live-dbus.md b/docs/rewrite/notes/live-dbus.md new file mode 100644 index 0000000..2cebea4 --- /dev/null +++ b/docs/rewrite/notes/live-dbus.md @@ -0,0 +1,61 @@ +# Live D-Bus / portal integration (agent LIVE) + +Harness: `scripts/live-dbus-test.sh` (`just live-dbus [scenario...]`), helpers in `scripts/live-dbus/` +(`scenario.sh`, `scenarios.sh`, `mock.py`, `normalize.py`, child scripts, `golden/`, `build-rust.sh`). +Automated gate: `tests/live_dbus.rs` (golden mode, see below). + +## Method +- Every scenario runs once per binary (original `/usr/bin/ghostty` = oracle, Rust release-profile build) on a + **private session bus**: `dbus-run-session --config-file=...` with its own `servicedir` (only + `com.mitchellh.ghostty.service` pointing at the binary under test, so D-Bus activation starts the build under test, not + `/usr/bin/ghostty`), inside a private headless weston (as `visual-parity.sh`), private HOME/XDG dirs, + `GSETTINGS_BACKEND=memory`. The user's bus, portal and desktop are never touched. All spawned processes carry an + `LDB_TAG` env marker and are swept at the end. +- Mock services (`mock.py`, Gio via PyGObject) on the private bus: `org.freedesktop.Notifications`, + `org.freedesktop.systemd1` (Manager.StartTransientUnit, switchable to failing), `org.freedesktop.portal.Desktop` + (GlobalShortcuts incl. Request/Session objects, Settings, OpenURI). Control interface `test.ghostty.Mock` + (`Activate`, `SetColorScheme`, `Invoke`, `SetFail`). Every received call is logged as JSON. +- `busctl --user monitor --json=short` records all traffic; `normalize.py` keeps `org.gtk.*`, Notifications, systemd1 and + portal messages and normalizes pids, tokens, unique names, paths. Observable effects ("facts") come from the exported + GtkApplication window objects (`busctl tree`), process counts, child cwd/argv, and the pty (colour-scheme reports). +- `golden/.txt` = the original's facts + received mock calls (recorded with `UPDATE_GOLDEN=1`). With the + original installed the harness compares original vs Rust directly **and** Rust vs golden; without it (CI, + `tests/live_dbus.rs`, `LABELS=rust`) only Rust vs golden. Debug builds use the app id `com.mitchellh.ghostty-debug` + (as Zig), so the Rust build must be the release profile (`scripts/live-dbus/build-rust.sh`). +- Real `xdg-desktop-portal` + `xdg-desktop-portal-gtk` do run on the private bus (scenario `real-portal`, keyfile + gsettings backend so `color-scheme` can be changed live). They cannot give Ghostty an app id without systemd/flatpak + ("An app id is required"), so GlobalShortcuts against the real frontend only yields `NotAllowed`; the BindShortcuts + payload is therefore checked against the mock. + +## Scenarios: original vs Rust +| Scenario | Covers | Result | +|---|---|---| +| single-instance | first launch owns `com.mitchellh.ghostty`; `+new-window` (plain, `-e`, `--working-directory`, both, wrong/bad `--class`), second GUI launch forwarded, `-e` launch is never single-instance, `--class=com.example.custom-app` own bus name/path and `+new-window --class`; exact `org.gtk.Actions.Activate new-window-command` payloads (the CLI prepends `--working-directory=`), exported actions, window counts, child cwd/argv | identical | +| single-instance-detect | `gtk-single-instance=detect`: plain launch = single instance, `TERM_PROGRAM` set or any CLI argument = dedicated instance | identical | +| notifications | OSC 9, OSC 777 (title/body/empty parts/`;` in body/markup/non-ASCII), repeated body (replaces id), `ActionInvoked default` incl. unknown id, `desktop-notifications=false` after reload; exact `Notify(app, replaces_id, icon, summary, body, actions, hints, timeout)` | identical (app name `""`, summary `Ghostty` for OSC 9, actions `[default,""]`, hints `desktop-entry`, `image-path`, `urgency=1`) | +| shortcuts | `global:` keybinds -> `CreateSession`/`BindShortcuts` payload (ids, descriptions, order), `Activated` -> action, unknown id ignored, session `Close` + re-create on reload, none without global binds | **diverged, fixed** (see below) | +| settings | portal `org.freedesktop.appearance color-scheme` (Read/ReadAll/SettingChanged) -> CSI ? 997 reports (mode 2031), `theme = light:..,dark:..` OSC 11 colour, `window-theme=dark` | identical | +| cgroup | `linux-cgroup` default/always/never, memory/processes limits (`MemoryHigh`,`TasksMax`,`ManagedOOMMemoryPressure`,`PIDs`), failing StartTransientUnit (app keeps running) | identical | +| openuri | `open-config` -> `xdg-open `; **no** portal OpenURI/OpenFile call (upstream TODO in `Application.openUrl`; the Rust port has the same TODO) | identical | +| openurl-click | ctrl+click on plain URL / OSC 8 link / `file://` / path -> `xdg-open` argv | Rust only (the original cannot be driven: no input injection, Xwayland pointer is not reachable in headless weston); golden derived from `Surface.zig`/`os/open.zig` | +| real-portal | real xdg-desktop-portal + gtk backend: Settings color-scheme live changes, GlobalShortcuts error path, Inhibit monitor | identical | + +## Divergences found and fixed (Rust port bugs, not upstream bugs: normal commits) +1. **GlobalShortcuts payloads had the wrong D-Bus signature** (`crates/ghostty-apprt-gtk/src/class/global_shortcuts.rs`): + `CreateSession` was sent as `(v)` instead of `(a{sv})`, option values were double-boxed (`v` in `v`), and + `BindShortcuts` was `(oa(sv)sv)` instead of `(oa(sa{sv})sa{sv})`. A real portal rejects these, so global shortcuts could + never work. Now built with `tuple_from_iter`/typed arrays by `create_session_payload`/`bind_shortcuts_payload`; + unit tests assert the signatures. +2. **CreateSession was sent twice at startup** (`class/application.rs`): the app's `config` notify forwarded the config to + `GlobalShortcuts` before `startup_global_shortcuts`; Zig binds the property only after `setDbusConnection` + (`bindProperty(.., .sync_create)`), so the session is created once. New `global_shortcuts_bound` flag. + +No upstream (deviant) bugs were found in this area; `DEVIANT_FIXES.md` is unchanged by this work. + +## Not covered +- Input method (ibus/fcitx not installed) stays in "Not verifiable on this machine". +- The original cannot receive pointer input here, so link clicks are checked for the Rust build only. +- `notify-on-command-finished` never produced a notification for either binary in the headless setup (identical, no + focus change events), so only OSC 9/777 are compared. +- Real GlobalShortcuts backends (GNOME/KDE) are not installed; the real frontend answers `NotAllowed` for non-sandboxed + apps without systemd-assigned app ids. diff --git a/justfile b/justfile index c332134..9c9f677 100644 --- a/justfile +++ b/justfile @@ -299,3 +299,10 @@ check-all: clippy test check-wasm check-windows # Capture original Ghostty vs the Rust build in a headless weston and diff them. visual-parity *scenarios: RUST_BIN="${RUST_BIN:-target/vp/production/ghostty}" scripts/visual-parity.sh {{scenarios}} + +# Live D-Bus / portal comparison (private bus + nested weston): notifications, +# single-instance, global shortcuts, color-scheme, cgroup scope. Builds the +# iteration binary first. UPDATE_GOLDEN=1 re-records scripts/live-dbus/golden. +live-dbus *scenarios: + scripts/live-dbus/build-rust.sh + RUST_BIN="${RUST_BIN:-target/vp/production/ghostty}" scripts/live-dbus-test.sh {{scenarios}} diff --git a/scripts/live-dbus-test.sh b/scripts/live-dbus-test.sh new file mode 100755 index 0000000..a01c2c6 --- /dev/null +++ b/scripts/live-dbus-test.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Live D-Bus / portal comparison of original Ghostty and the Rust build. +# +# scripts/live-dbus-test.sh [scenario...] +# +# Scenarios: single-instance single-instance-detect notifications shortcuts settings cgroup openuri openurl-click real-portal +# (default: all). Each scenario runs once per binary on a *private* session +# bus (dbus-run-session with its own config: only our service dir, no user +# services) inside a private headless weston; mock services +# (scripts/live-dbus/mock.py) answer org.freedesktop.Notifications, +# org.freedesktop.systemd1 and the portal interfaces Ghostty uses. The +# user's session bus, portal and desktop are never touched. +# +# The original's normalized result (D-Bus traffic + observable effects) is the +# oracle; the Rust result must be identical. Exit status 1 on any difference, +# 0 when everything matches or the needed tools are missing (skip). +# +# env: RUST_BIN (default target/vp/production/ghostty: debug builds use a different app id), REF_BIN (default /usr/bin/ghostty), +# OUT (default target/live-dbus), KEEP=1 keeps the weston log. +set -u +ROOT=$(cd "$(dirname "$0")/.." && pwd) +RUST_BIN=${RUST_BIN:-$ROOT/target/vp/production/ghostty} +REF_BIN=${REF_BIN:-/usr/bin/ghostty} +OUT=${OUT:-$ROOT/target/live-dbus} +LD=$ROOT/scripts/live-dbus +SCEN=${*:-single-instance single-instance-detect notifications shortcuts settings cgroup openuri openurl-click real-portal} + +for t in weston dbus-run-session busctl python3 xdotool; do + command -v $t >/dev/null || { echo "live-dbus: SKIP ($t missing)"; exit 0; } +done +python3 -c 'import gi; gi.require_version("Gio","2.0")' 2>/dev/null || { echo "live-dbus: SKIP (python gi missing)"; exit 0; } +[ -x "$RUST_BIN" ] || [ "${LABELS:-}" = ref ] || { echo "live-dbus: SKIP (no rust build at $RUST_BIN)"; exit 0; } + +mkdir -p "$OUT" +export XDG_RUNTIME_DIR=${VP_RUNTIME_DIR:-/tmp/vp-runtime-$UID} +mkdir -p -m700 "$XDG_RUNTIME_DIR" /tmp/.X11-unix 2>/dev/null +WORK=$(mktemp -d "$OUT/work.XXXX") +TAG=ldb$$ +SOCK=ldb$$ +cat >"$WORK/weston.ini" <"$WORK/weston.log" 2>&1 & +WPID=$! +cleanup() { + kill $WPID 2>/dev/null + for p in /proc/[0-9]*; do + grep -qa "LDB_TAG=$TAG" "$p/environ" 2>/dev/null && kill -9 "${p#/proc/}" 2>/dev/null + done + wait 2>/dev/null + [ -n "${KEEP:-}" ] || rm -rf "$WORK" +} +trap cleanup EXIT +for _ in $(seq 50); do [ -S "$XDG_RUNTIME_DIR/$SOCK" ] && break; sleep 0.1; done +[ -S "$XDG_RUNTIME_DIR/$SOCK" ] || { echo "live-dbus: SKIP (weston did not start)"; cat "$WORK/weston.log"; exit 0; } +sleep 1 +XDISPLAY=$(grep -o "display :[0-9]*" "$WORK/weston.log" | tail -1 | sed 's/display //') + +rc=0 +RUST_ONLY=" openurl-click " # the original cannot be driven (no input injection): golden file only +cut_bus() { sed '/^== bus traffic/,$d' "$1"; } +run_label() { # scenario label bin + local s=$1 label=$2 bin=$3 + local d=$OUT/$s/$label; rm -rf "$d"; mkdir -p "$d" + local run=$WORK/run-$s-$label; mkdir -p "$run/services" + # Private bus: only our service dir (activation must start the binary + # under test, not /usr/bin/ghostty), no user services. + cat >"$run/session.conf" < +sessionunix:tmpdir=$XDG_RUNTIME_DIREXTERNAL +$run/services + + +CONF + printf '[D-BUS Service]\nName=com.mitchellh.ghostty\nExec=%s --gtk-single-instance=true --initial-window=false\n' "$bin" \ + >"$run/services/com.mitchellh.ghostty.service" + LDB_LABEL=$label LDB_TAG=$TAG WAYLAND_DISPLAY=$SOCK DISPLAY=$XDISPLAY LDB_BIN=$bin LDB_RUN=$run LDB_ROOT=$ROOT \ + timeout 240 dbus-run-session --config-file="$run/session.conf" -- \ + bash "$LD/scenario.sh" "$s" "$bin" "$d" >"$d/driver.log" 2>&1 + LDB_RUN=$run LDB_ROOT=$ROOT python3 "$LD/normalize.py" "$d" >"$d/result.txt" 2>"$d/normalize.err" +} +for s in $SCEN; do + golden=$LD/golden/$s.txt + mkdir -p "$OUT/$s" + have_ref=0 + case "$RUST_ONLY" in *" $s "*) ;; *) [ -x "$REF_BIN" ] && [ "${LABELS:-ref rust}" != rust ] && have_ref=1 ;; esac + if [ $have_ref = 1 ]; then run_label "$s" ref "$REF_BIN"; fi + if [ -x "$RUST_BIN" ] && [ "${LABELS:-rust}" != ref ]; then + if [ $have_ref = 1 ] || [ -f "$golden" ] || [ -n "${UPDATE_GOLDEN:-}" ]; then + run_label "$s" rust "$RUST_BIN" + fi + fi + [ "${LABELS:-}" = ref ] && continue + ok=1 + if grep -qs '^ERROR' "$OUT"/$s/*/facts.txt; then echo "live-dbus: FAIL $s (scenario error, see $OUT/$s/*/facts.txt)"; rc=1; continue; fi + if [ $have_ref = 1 ] && [ -f "$OUT/$s/rust/result.txt" ]; then + if diff -u "$OUT/$s/ref/result.txt" "$OUT/$s/rust/result.txt" >"$OUT/$s/diff.txt"; then :; else + echo "live-dbus: FAIL $s (original vs Rust)"; cat "$OUT/$s/diff.txt"; ok=0; fi + if [ -n "${UPDATE_GOLDEN:-}" ] || [ ! -f "$golden" ]; then cut_bus "$OUT/$s/ref/result.txt" >"$golden"; fi + fi + if [ -f "$golden" ] && [ -f "$OUT/$s/rust/result.txt" ]; then + if [ -n "${UPDATE_GOLDEN:-}" ] && [ $have_ref = 0 ]; then cut_bus "$OUT/$s/rust/result.txt" >"$golden"; fi + if ! diff -u "$golden" <(cut_bus "$OUT/$s/rust/result.txt") >"$OUT/$s/diff-golden.txt"; then + echo "live-dbus: FAIL $s (Rust vs recorded original, golden/$s.txt)"; cat "$OUT/$s/diff-golden.txt"; ok=0; fi + fi + if [ $ok = 1 ]; then + echo "live-dbus: PASS $s$([ $have_ref = 1 ] && echo ' (original + golden)' || echo ' (golden only)')" + else rc=1; fi +done +exit $rc diff --git a/scripts/live-dbus/build-rust.sh b/scripts/live-dbus/build-rust.sh new file mode 100755 index 0000000..54866aa --- /dev/null +++ b/scripts/live-dbus/build-rust.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Iteration build of the Rust binary used by live-dbus-test.sh / visual-parity. +cd "$(dirname "$0")/../.." || exit 1 +CARGO_TARGET_DIR=target/vp CARGO_PROFILE_PRODUCTION_LTO=off CARGO_PROFILE_PRODUCTION_CODEGEN_UNITS=16 \ + CARGO_PROFILE_PRODUCTION_INCREMENTAL=true exec cargo build --profile production "$@" diff --git a/scripts/live-dbus/child-links.sh b/scripts/live-dbus/child-links.sh new file mode 100644 index 0000000..ee45657 --- /dev/null +++ b/scripts/live-dbus/child-links.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Prints links on known rows, then reports the text area size (CSI 18 t). +esc=$(printf '\033'); bel=$(printf '\007') +stty raw -echo +printf '%s[H%s[2J' "$esc" "$esc" +printf 'https://example.org/plain\r\n' +printf '%s]8;;https://example.com/osc8%s\\osc8-link%s]8;;%s\\\r\n' "$esc" "$esc" "$esc" "$esc" +printf 'file:///etc/hostname\r\n' +printf '/etc/hostname\r\n' +printf '%s[18t' "$esc" +exec cat -v >>"$OUTF" diff --git a/scripts/live-dbus/child-notify.sh b/scripts/live-dbus/child-notify.sh new file mode 100755 index 0000000..0ef55fc --- /dev/null +++ b/scripts/live-dbus/child-notify.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Terminal child for the notifications scenario. +esc=$(printf '\033'); bel=$(printf '\007') +sleep 2 +printf '%s]9;osc9 body%s' "$esc" "$bel"; sleep 1.2 +printf '%s]777;notify;Title777;Body777%s' "$esc" "$bel"; sleep 1.2 +printf '%s]777;notify;;OnlyBody%s' "$esc" "$bel"; sleep 1.2 +printf '%s]777;notify;Only title;%s' "$esc" "$bel"; sleep 1.2 +printf '%s]9;markup & "quotes" \303\251\342\202\254%s' "$esc" "$bel"; sleep 1.2 +printf '%s]9;osc9 body%s' "$esc" "$bel"; sleep 1.2 +printf '%s]777;notify;semi;colon;body%s' "$esc" "$bel"; sleep 1.2 +# command finished (OSC 133 prompt marks): C then D;3 after >1s +printf '%s]133;A%s%s]133;B%s%s]133;C%s' "$esc" "$bel" "$esc" "$bel" "$esc" "$bel"; sleep 2 +printf '%s]133;D;3%s' "$esc" "$bel"; sleep 1.5 +printf '%s]133;A%s%s]133;B%s%s]133;C%s' "$esc" "$bel" "$esc" "$bel" "$esc" "$bel"; sleep 2 +printf '%s]133;D;0%s' "$esc" "$bel" +# wait for the harness to ask for one more notification +while [ ! -e "$FLAG2" ]; do sleep 0.3; done +printf '%s]9;after reload%s' "$esc" "$bel" +sleep 60 diff --git a/scripts/live-dbus/child-theme.sh b/scripts/live-dbus/child-theme.sh new file mode 100755 index 0000000..7945e76 --- /dev/null +++ b/scripts/live-dbus/child-theme.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Enables color-scheme reporting (mode 2031), asks for the current scheme +# (CSI ? 996 n) and keeps asking for the background colour (OSC 11). +esc=$(printf '\033'); bel=$(printf '\007') +stty raw -echo +printf '%s[?2031h%s[?996n' "$esc" "$esc" +( while :; do printf '%s]11;?%s' "$esc" "$bel"; sleep 0.7; done ) & +exec cat -v >>"$OUTF" diff --git a/scripts/live-dbus/golden/cgroup.txt b/scripts/live-dbus/golden/cgroup.txt new file mode 100644 index 0000000..2314a4c --- /dev/null +++ b/scripts/live-dbus/golden/cgroup.txt @@ -0,0 +1,15 @@ +== facts == +phase default: +phase always: + call: {"args": ["app-ghostty-surface-transient-PID.scope", "fail", [["ManagedOOMMemoryPressure", "kill"], ["PIDs", [PID]]], []], "iface": "org.freedesktop.systemd1.Manager", "method": "StartTransientUnit"} +phase always-limits: + call: {"args": ["app-ghostty-surface-transient-PID.scope", "fail", [["MemoryHigh", 123456789], ["TasksMax", 77], ["ManagedOOMMemoryPressure", "kill"], ["PIDs", [PID]]], []], "iface": "org.freedesktop.systemd1.Manager", "method": "StartTransientUnit"} +phase never: +phase always-failing: + call: {"args": ["app-ghostty-surface-transient-PID.scope", "fail", [["ManagedOOMMemoryPressure", "kill"], ["PIDs", [PID]]], []], "iface": "org.freedesktop.systemd1.Manager", "method": "StartTransientUnit"} +cgroup done +== mock received == +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.systemd1.Manager.StartTransientUnit ["app-ghostty-surface-transient-PID.scope", "fail", [["ManagedOOMMemoryPressure", "kill"], ["PIDs", [PID]]], []] diff --git a/scripts/live-dbus/golden/notifications.txt b/scripts/live-dbus/golden/notifications.txt new file mode 100644 index 0000000..04a4c08 --- /dev/null +++ b/scripts/live-dbus/golden/notifications.txt @@ -0,0 +1,16 @@ +== facts == +notifications so far: 7 +alive after ActionInvoked: instances=1 windows=1 +alive after more ActionInvoked: instances=1 windows=1 +notifications after desktop-notifications=false: 7 +== mock received == +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.Notifications.Notify ["", 0, "", "Ghostty", "osc9 body", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1] +org.freedesktop.Notifications.Notify ["", 0, "", "Title777", "Body777", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1] +org.freedesktop.Notifications.Notify ["", 0, "", "Ghostty", "OnlyBody", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1] +org.freedesktop.Notifications.Notify ["", 0, "", "Only title", "", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1] +org.freedesktop.Notifications.Notify ["", 0, "", "Ghostty", "markup & \"quotes\" \u00e9\u20ac", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1] +org.freedesktop.Notifications.Notify ["", 1, "", "Ghostty", "osc9 body", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1] +org.freedesktop.Notifications.Notify ["", 0, "", "semi", "colon;body", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1] diff --git a/scripts/live-dbus/golden/openuri.txt b/scripts/live-dbus/golden/openuri.txt new file mode 100644 index 0000000..dec5c9c --- /dev/null +++ b/scripts/live-dbus/golden/openuri.txt @@ -0,0 +1,7 @@ +== facts == +spawn: xdg-open argc=1 [$RUN/cfg/ghostty/config] cwd=$ROOT +portal OpenURI/OpenFile calls: 0 +== mock received == +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] diff --git a/scripts/live-dbus/golden/real-portal.txt b/scripts/live-dbus/golden/real-portal.txt new file mode 100644 index 0000000..602cdec --- /dev/null +++ b/scripts/live-dbus/golden/real-portal.txt @@ -0,0 +1,14 @@ +== facts == +portal frontend: (,) +tty: startup: 997 reports=1 last bg=rgb:1111/2222/3333 +tty: after gsettings color-scheme=prefer-light: 997 reports=1,2 last bg=rgb:ffff/eeee/dddd +tty: after gsettings color-scheme=prefer-dark: 997 reports=2,1 last bg=rgb:1111/2222/3333 +tty: after gsettings color-scheme=default: 997 reports=1,2 last bg=rgb:ffff/eeee/dddd +tty: after gsettings color-scheme=prefer-dark: 997 reports=2,1 last bg=rgb:1111/2222/3333 +global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed +global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed +global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed +global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed +global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed +alive: instances=1 +== mock received == diff --git a/scripts/live-dbus/golden/settings.txt b/scripts/live-dbus/golden/settings.txt new file mode 100644 index 0000000..b26031c --- /dev/null +++ b/scripts/live-dbus/golden/settings.txt @@ -0,0 +1,12 @@ +== facts == +tty: startup: 997 reports=1 last bg=rgb:1111/2222/3333 +tty: after portal color-scheme=2: 997 reports=1,2 last bg=rgb:ffff/eeee/dddd +tty: after portal color-scheme=1: 997 reports=2,1 last bg=rgb:1111/2222/3333 +tty: after portal color-scheme=0: 997 reports=1,2 last bg=rgb:ffff/eeee/dddd +tty: after portal color-scheme=2: 997 reports=- last bg=rgb:ffff/eeee/dddd +tty: after reload window-theme=dark: 997 reports=2 last bg=rgb:ffff/eeee/dddd +tty: after portal color-scheme=2 with window-theme=dark: 997 reports=- last bg=rgb:ffff/eeee/dddd +== mock received == +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] diff --git a/scripts/live-dbus/golden/shortcuts.txt b/scripts/live-dbus/golden/shortcuts.txt new file mode 100644 index 0000000..8231c0e --- /dev/null +++ b/scripts/live-dbus/golden/shortcuts.txt @@ -0,0 +1,16 @@ +== facts == +step start: windows=1 +new_window shortcut id: CTRL+ALT+x +step Activated(new_window): windows=2 +step Activated(unknown): windows=2 +step reload without global: windows=2 +step reload with global again: windows=2 +== mock received == +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.portal.GlobalShortcuts.CreateSession [{"handle_token": "ghostty_TOKEN", "session_handle_token": "ghostty_TOKEN"}] +org.freedesktop.portal.GlobalShortcuts.BindShortcuts ["/org/freedesktop/portal/desktop/session/SENDER/ghostty_TOKEN", [["CTRL+ALT+x", {"description": "new_window", "preferred_trigger": "CTRL+ALT+x"}], ["SHIFT+LOGO+b", {"description": "new_tab", "preferred_trigger": "SHIFT+LOGO+b"}], ["SHIFT+CTRL+F12", {"description": "reload_config", "preferred_trigger": "SHIFT+CTRL+F12"}], ["CTRL+ALT+q", {"description": "text:hi", "preferred_trigger": "CTRL+ALT+q"}]], "", {"handle_token": "ghostty_TOKEN"}] +org.freedesktop.portal.Session.Close {"path": "/org/freedesktop/portal/desktop/session/SENDER/ghostty_TOKEN"} +org.freedesktop.portal.GlobalShortcuts.CreateSession [{"handle_token": "ghostty_TOKEN", "session_handle_token": "ghostty_TOKEN"}] +org.freedesktop.portal.GlobalShortcuts.BindShortcuts ["/org/freedesktop/portal/desktop/session/SENDER/ghostty_TOKEN", [["CTRL+ALT+x", {"description": "new_window", "preferred_trigger": "CTRL+ALT+x"}]], "", {"handle_token": "ghostty_TOKEN"}] diff --git a/scripts/live-dbus/golden/single-instance-detect.txt b/scripts/live-dbus/golden/single-instance-detect.txt new file mode 100644 index 0000000..7b3c697 --- /dev/null +++ b/scripts/live-dbus/golden/single-instance-detect.txt @@ -0,0 +1,24 @@ +== facts == +plain launch owns name: 0 +step plain: windows=1 instances=1 +second plain launch rc=0 +step second plain: windows=2 instances=1 +step TERM_PROGRAM: windows=2 instances=2 +step TERM_PROGRAM ended: windows=2 instances=1 +step CLI arg: windows=2 instances=2 +step CLI arg ended: windows=2 instances=1 +ipc newwin rc=0 +step +new-window: windows=3 instances=1 +== mock received == +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] diff --git a/scripts/live-dbus/golden/single-instance.txt b/scripts/live-dbus/golden/single-instance.txt new file mode 100644 index 0000000..cb6bc53 --- /dev/null +++ b/scripts/live-dbus/golden/single-instance.txt @@ -0,0 +1,49 @@ +== facts == +first instance owns name: 0 +step first: windows=1 instances=1 +ipc newwin rc=0 +step +new-window: windows=2 instances=1 +ipc newwin-e rc=0 +step +new-window -e: windows=3 instances=1 +ipc newwin-wd rc=0 +step +new-window --working-directory: windows=4 instances=1 +ipc newwin-e-wd rc=0 +step +new-window -e..--working-directory: windows=5 instances=1 +ipc newwin-class rc=1 + ipc-err: D-Bus method call returned an error err=GDBus.Error:org.freedesktop.DBus.Error.ServiceUnknown: The name com.example.nobody was not provided by any .service files +step +new-window --class=nobody: windows=5 instances=1 +second launch rc=0 +step second launch: windows=6 instances=1 +child: /usr/bin/bash cwd=$HOME +child: /usr/bin/bash cwd=$HOME +child: /usr/bin/bash cwd=$ROOT +child: /usr/bin/bash cwd=/usr +child: sleep 601 cwd=$ROOT +step -e launch: windows=6 instances=2 +step -e launch ended: windows=6 instances=1 +custom class owns name: 0 +step custom: windows=1 instances=2 +ipc newwin-custom rc=0 +step +new-window --class=custom: custom windows=2 default windows=6 +ipc newwin-custom-e rc=0 +step +new-window --class=custom -e: custom windows=3 default windows=6 +ipc newwin-badclass rc=1 + ipc-err: D-Bus bus name is not valid: 1bad +badclass done +bus name: com.example.custom-app +bus name: com.mitchellh.ghostty +actions: ([open-config, present-surface, quit, new-window-command, new-window, reload-config],) +custom actions: ([open-config, present-surface, quit, new-window-command, new-window, reload-config],) +== mock received == +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"] +org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"] diff --git a/scripts/live-dbus/mock.py b/scripts/live-dbus/mock.py new file mode 100755 index 0000000..2c9ebe3 --- /dev/null +++ b/scripts/live-dbus/mock.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Mock D-Bus services for the live D-Bus comparison (scripts/live-dbus-test.sh). + +Runs on the private session bus and claims: + org.freedesktop.Notifications Notify/CloseNotification/GetCapabilities/GetServerInformation + org.freedesktop.systemd1 Manager.StartTransientUnit + org.freedesktop.portal.Desktop GlobalShortcuts, Settings, OpenURI (+ Request objects) + test.ghostty.Mock control: Activate(s), SetColorScheme(u), Quit() + +Every received call is appended to $MOCK_LOG as one JSON line: + {"iface": ..., "method": ..., "args": ...} +The portal pieces are mocks (the real xdg-desktop-portal needs a running +backend and a desktop session); they implement just what Ghostty calls. +""" +import json +import os +import signal +import sys + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + +LOG = os.environ.get("MOCK_LOG", "/dev/stderr") +COLOR_SCHEME = int(os.environ.get("MOCK_COLOR_SCHEME", "0")) # 0 none, 1 dark, 2 light +BIND_REPLY = os.environ.get("MOCK_BIND", "ok") + +XML = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +CTRL_XML = """ + + + + + + + +""" + +nid = [0] +state = {"session": None, "scheme": COLOR_SCHEME} +loop = GLib.MainLoop() +conns = {} + + +def log(iface, method, args): + def conv(o): + if isinstance(o, (bytes, bytearray)): + return list(o) + return str(o) + + with open(LOG, "a") as f: + f.write(json.dumps({"iface": iface, "method": method, "args": args}, default=conv, sort_keys=True) + "\n") + + +def unpack(v): + return v.unpack() if v is not None else None + + +def request_path(sender, options): + tok = options.get("handle_token", "tok") + return "/org/freedesktop/portal/desktop/request/" + sender[1:].replace(".", "_") + "/" + tok + + +def reply_request(conn, sender, options, results): + """Emit Request.Response after the method reply was sent.""" + path = request_path(sender, options) + + def emit(): + conn.emit_signal( + sender, path, "org.freedesktop.portal.Request", "Response", + GLib.Variant("(ua{sv})", (0, results)), + ) + return False + + GLib.timeout_add(100, emit) + return path + + +def settings_all(): + return {"org.freedesktop.appearance": {"color-scheme": GLib.Variant("u", state["scheme"])}} + + +def on_call(conn, sender, path, iface, method, params, inv): + args = unpack(params) + if iface == "org.freedesktop.Notifications": + if method == "Notify": + log(iface, method, args) + nid[0] += 1 + inv.return_value(GLib.Variant("(u)", (nid[0],))) + elif method == "GetCapabilities": + inv.return_value(GLib.Variant("(as)", (["actions", "body", "body-markup", "icon-static"],))) + elif method == "GetServerInformation": + inv.return_value(GLib.Variant("(ssss)", ("mock", "ghostty-test", "1.0", "1.2"))) + else: + log(iface, method, args) + inv.return_value(None) + elif iface == "org.freedesktop.systemd1.Manager": + log(iface, method, args) + if state.get("fail"): + inv.return_dbus_error("org.freedesktop.DBus.Error.AccessDenied", "mock failure") + return + inv.return_value(GLib.Variant("(o)", ("/org/freedesktop/systemd1/job/1",))) + elif iface == "org.freedesktop.portal.GlobalShortcuts": + log(iface, method, args) + if method == "CreateSession": + opts = args[0] + sess = "/org/freedesktop/portal/desktop/session/" + sender[1:].replace(".", "_") + "/" + opts.get("session_handle_token", "s") + state["session"] = sess + state["sender"] = sender + sess_info = Gio.DBusNodeInfo.new_for_xml(XML).lookup_interface("org.freedesktop.portal.Session") + conn.register_object(sess, sess_info, on_call, None, None) + rp = reply_request(conn, sender, opts, {"session_handle": GLib.Variant("s", sess)}) + inv.return_value(GLib.Variant("(o)", (rp,))) + else: + rp = reply_request(conn, sender, args[3], {"shortcuts": GLib.Variant("a(sa{sv})", [])}) + inv.return_value(GLib.Variant("(o)", (rp,))) + elif iface == "org.freedesktop.portal.Session": + log(iface, method, {"path": path}) + inv.return_value(None) + elif iface == "org.freedesktop.portal.Settings": + log(iface, method, args) + if method == "ReadAll": + inv.return_value(GLib.Variant("(a{sa{sv}})", (settings_all(),))) + else: + inv.return_value(GLib.Variant("(v)", (GLib.Variant("v", GLib.Variant("u", state["scheme"])),))) + elif iface == "org.freedesktop.portal.OpenURI": + log(iface, method, args) + rp = reply_request(conn, sender, args[-1], {}) + inv.return_value(GLib.Variant("(o)", (rp,))) + elif iface == "org.freedesktop.portal.Request": + inv.return_value(None) + elif iface == "test.ghostty.Mock": + if method == "Activate": + conn.emit_signal( + None, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.GlobalShortcuts", "Activated", + GLib.Variant("(osta{sv})", (state["session"], args[0], 0, {})), + ) + elif method == "SetColorScheme": + state["scheme"] = args[0] + conn.emit_signal( + None, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings", "SettingChanged", + GLib.Variant("(ssv)", ("org.freedesktop.appearance", "color-scheme", GLib.Variant("u", args[0]))), + ) + elif method == "Invoke": + conn.emit_signal( + None, "/org/freedesktop/Notifications", "org.freedesktop.Notifications", "ActionInvoked", + GLib.Variant("(us)", (args[0], args[1])), + ) + elif method == "SetFail": + state["fail"] = args[0] + elif method == "Quit": + loop.quit() + inv.return_value(None) + + +def on_prop(conn, sender, path, iface, name): + return GLib.Variant("u", 1 if iface == "org.freedesktop.portal.Settings" else 3) + + +def on_bus(conn, name): + node = Gio.DBusNodeInfo.new_for_xml(XML) + paths = { + "org.freedesktop.Notifications": ["/org/freedesktop/Notifications"], + "org.freedesktop.systemd1.Manager": ["/org/freedesktop/systemd1"], + "org.freedesktop.portal.GlobalShortcuts": ["/org/freedesktop/portal/desktop"], + "org.freedesktop.portal.Settings": ["/org/freedesktop/portal/desktop"], + "org.freedesktop.portal.OpenURI": ["/org/freedesktop/portal/desktop"], + "org.freedesktop.portal.Session": [], + } + for ifc in node.interfaces: + for p in paths.get(ifc.name, []): + conn.register_object(p, ifc, on_call, on_prop, None) + ctrl = Gio.DBusNodeInfo.new_for_xml(CTRL_XML) + conn.register_object("/test/ghostty/Mock", ctrl.interfaces[0], on_call, None, None) + conns["c"] = conn + + +def acquired(conn, name): + if name == "test.ghostty.Mock": + sys.stdout.write("READY\n") + sys.stdout.flush() + + +# Own all names on one connection; announce READY once the last is acquired. +bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) +on_bus(bus, None) +names = os.environ.get("MOCK_NAMES", "notifications,systemd,portal").split(",") +want = [] +if "notifications" in names: + want.append("org.freedesktop.Notifications") +if "systemd" in names: + want.append("org.freedesktop.systemd1") +if "portal" in names: + want.append("org.freedesktop.portal.Desktop") +want.append("test.ghostty.Mock") +for n in want: + Gio.bus_own_name_on_connection(bus, n, Gio.BusNameOwnerFlags.NONE, acquired, None) +signal.signal(signal.SIGTERM, lambda *a: loop.quit()) +GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, lambda *a: loop.quit()) +loop.run() diff --git a/scripts/live-dbus/normalize.py b/scripts/live-dbus/normalize.py new file mode 100755 index 0000000..3f66f85 --- /dev/null +++ b/scripts/live-dbus/normalize.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Turn a scenario output dir into a comparable text: facts + filtered bus +traffic + mock-received calls, with volatile values (unique names, pids, +cookies, tokens, paths) normalized.""" +import json +import os +import pwd +import re +import sys + +d = sys.argv[1] +out = [] + + +def rd(name): + p = os.path.join(d, name) + return open(p).read() if os.path.exists(p) else "" + + +def norm(s): + s = re.sub(r"app-ghostty-surface-transient-\d+", "app-ghostty-surface-transient-PID", s) + s = re.sub(r'"PIDs", (\[\d+\]|\{"data": \[\d+\])', lambda m: re.sub(r"\d+", "PID", m.group(0)), s) + s = re.sub(r"ghostty_[0-9a-f]{7}", "ghostty_TOKEN", s) + s = re.sub(r"gtk[0-9]{6,}", "gtkTOKEN", s) + s = re.sub(r"/request/[0-9_]+/", "/request/SENDER/", s) + s = re.sub(r"/session/[0-9_]+/", "/session/SENDER/", s) + s = s.replace(os.environ.get("LDB_RUN", "\0"), "$RUN") + s = s.replace(os.environ.get("LDB_ROOT", "\0"), "$ROOT") + s = s.replace(os.path.expanduser("~" + os.environ.get("USER", "")) if False else pwd.getpwuid(os.getuid()).pw_dir, "$HOME") + return s + + +# Scope creation races with the end of a scenario; only the cgroup scenario checks it. +CGROUP = os.path.basename(os.path.dirname(d.rstrip("/"))) == "cgroup" + +out.append("== facts ==") +for l in rd("facts.txt").splitlines(): + out.append(norm(l)) + +out.append("== mock received ==") +for l in rd("mock.jsonl").splitlines(): + j = json.loads(l) + if j["method"] == "StartTransientUnit" and not CGROUP: + continue + out.append(norm(f"{j['iface']}.{j['method']} {json.dumps(j['args'], sort_keys=True)}")) + +out.append("== bus traffic (filtered) ==") +names = {} + + +def label(n): + if n is None: + return "-" + if n.startswith(":"): + names.setdefault(n, f"conn{len(names)+1}") + return names[n] + return n + + +KEEP = re.compile(r"^(org\.gtk\.|org\.freedesktop\.(Notifications|systemd1|portal))") +for l in rd("bus.json").splitlines(): + try: + m = json.loads(l) + except ValueError: + continue + iface = m.get("interface") or "" + if not KEEP.match(iface): + continue + if m.get("member") == "StartTransientUnit" and not CGROUP: + continue + payload = m.get("payload") or {} + data = json.dumps(payload.get("data"), sort_keys=True) + out.append(norm(f"{m['type']} {iface}.{m.get('member')} path={m.get('path')} -> {data}")) +print("\n".join(out)) diff --git a/scripts/live-dbus/replay-windows.txt b/scripts/live-dbus/replay-windows.txt new file mode 100644 index 0000000..9d4d637 --- /dev/null +++ b/scripts/live-dbus/replay-windows.txt @@ -0,0 +1,4 @@ +wait 5000 +windows-dump +wait 4000 +windows-dump diff --git a/scripts/live-dbus/scenario.sh b/scripts/live-dbus/scenario.sh new file mode 100755 index 0000000..4344a42 --- /dev/null +++ b/scripts/live-dbus/scenario.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# One scenario against one binary, executed inside `dbus-run-session` +# (see ../live-dbus-test.sh). usage: scenario.sh +# Writes /{bus.json,mock.jsonl,facts.txt,...}; normalize.py turns them +# into the comparable result.txt. +set -u +SCEN=$1; BIN=$2; D=$3 +W=$LDB_RUN +LD=$LDB_ROOT/scripts/live-dbus +mkdir -p "$W/home" "$W/cfg/ghostty" "$W/data" "$W/cache" +export HOME=$W/home XDG_CONFIG_HOME=$W/cfg XDG_DATA_HOME=$W/data XDG_CACHE_HOME=$W/cache +export GSETTINGS_BACKEND=memory GDK_BACKEND=wayland LANG=C.UTF-8 SHELL=/bin/sh +export MOCK_LOG=$D/mock.jsonl +unset TERM_PROGRAM TERM_PROGRAM_VERSION GHOSTTY_BIN_DIR GHOSTTY_SHELL_FEATURES +export GHOSTTY_RESOURCES_DIR=${GHOSTTY_RESOURCES_DIR:-} +[ -z "$GHOSTTY_RESOURCES_DIR" ] && unset GHOSTTY_RESOURCES_DIR +FACTS=$D/facts.txt; : >"$FACTS" +fact() { echo "$*" >>"$FACTS"; } +PIDS=() +bg() { "$@" & PIDS+=($!); } +finish() { + for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done + for p in /proc/[0-9]*; do + grep -qa "LDB_TAG=$LDB_TAG" "$p/environ" 2>/dev/null || continue + case "$(cat "$p/comm" 2>/dev/null)" in dbus-*|bash|timeout) continue ;; esac + kill "${p#/proc/}" 2>/dev/null + done + sleep 0.5 +} +trap finish EXIT + +cfg() { printf '%s\n' "shell-integration = none" "confirm-close-surface = false" \ + "quit-after-last-window-closed = false" "window-theme = auto" "$@" >"$W/cfg/ghostty/config"; } + +start_bus_tools() { # [mock names] + busctl --user monitor --json=short >"$D/bus.json" 2>"$D/monitor.err" & + PIDS+=($!) + MOCK_NAMES=${1:-notifications,systemd,portal} python3 -W ignore "$LD/mock.py" >"$D/mock.out" 2>&1 & + PIDS+=($!) + for _ in $(seq 50); do grep -q READY "$D/mock.out" && break; sleep 0.1; done + sleep 0.5 +} + +# Wait until the app owns its bus name. +wait_name() { for _ in $(seq 300); do busctl --user list --acquired --no-pager 2>/dev/null | grep -q "^$1 " && return 0; sleep 0.1; done; return 1; } +mockctl() { gdbus call --session --dest test.ghostty.Mock --object-path /test/ghostty/Mock --method test.ghostty.Mock."$@" >/dev/null; } + +# GUI window objects exported on the bus by GtkApplication. +windows() { # [bus name] + # (never activate the service: only query a name that is already owned) + busctl --user list --acquired --no-pager 2>/dev/null | grep -q "^${1:-com.mitchellh.ghostty} " || { echo 0; return; } + busctl --user tree "${1:-com.mitchellh.ghostty}" --list --no-pager 2>/dev/null | grep -c '/window/[0-9]*$' +} +# Number of GUI instances of the binary under test (not +actions). +instances() { + local n=0 p + for p in /proc/[0-9]*; do + [ "$(readlink "$p/exe" 2>/dev/null)" = "$(readlink -f "$BIN")" ] || continue + tr '\0' ' ' <"$p/cmdline" | grep -q ' +' && continue + n=$((n+1)) + done + echo $n +} +# Command lines of terminal children (sh/sleep/...) of GUI instances, sorted. +children() { + local p c + for p in /proc/[0-9]*; do + [ "$(readlink "$p/exe" 2>/dev/null)" = "$(readlink -f "$BIN")" ] || continue + tr '\0' ' ' <"$p/cmdline" | grep -q ' +' && continue + for c in $(pgrep -P "${p#/proc/}"); do + echo "$(tr '\0' ' ' <"/proc/$c/cmdline") cwd=$(readlink /proc/$c/cwd)" + done + done | sort +} +# pids of processes with this comm that belong to this run +tagged() { local p; for p in /proc/[0-9]*; do + [ "$(cat "$p/comm" 2>/dev/null)" = "$1" ] && grep -qa "LDB_TAG=$LDB_TAG" "$p/environ" 2>/dev/null && echo "${p#/proc/}" +done; } +settle() { sleep "${1:-2}"; } + +run_ipc() { # label args... + local label=$1; shift + timeout 15 "$BIN" "$@" >"$D/ipc-$label.log" 2>&1 + fact "ipc $label rc=$?" + grep -E "D-Bus|not valid|error" "$D/ipc-$label.log" | grep -v "^warning\|^info" | sed 's/^/ ipc-err: /' >>"$FACTS" +} + +. "$LD/scenarios.sh" +"scn_${SCEN//-/_}" diff --git a/scripts/live-dbus/scenarios.sh b/scripts/live-dbus/scenarios.sh new file mode 100755 index 0000000..0360119 --- /dev/null +++ b/scripts/live-dbus/scenarios.sh @@ -0,0 +1,275 @@ +# Scenario bodies, sourced by scenario.sh. + +scn_single_instance() { + cfg "gtk-single-instance = true" + start_bus_tools + # (A launch with -e is never single-instance: see the "-e launch" step.) + bg env GHOSTTY_DEBUG_INPUT_SCRIPT="$LD/replay-windows.txt" "$BIN" >"$D/app1.log" 2>&1 + wait_name com.mitchellh.ghostty; fact "first instance owns name: $?" + settle 4; fact "step first: windows=$(windows) instances=$(instances)" + run_ipc newwin +new-window; settle + fact "step +new-window: windows=$(windows) instances=$(instances)" + run_ipc newwin-e +new-window -e sleep 601; settle + fact "step +new-window -e: windows=$(windows) instances=$(instances)" + run_ipc newwin-wd +new-window --working-directory=/usr; settle + fact "step +new-window --working-directory: windows=$(windows) instances=$(instances)" + run_ipc newwin-e-wd +new-window -e sleep 602 --working-directory=/usr; settle + fact "step +new-window -e..--working-directory: windows=$(windows) instances=$(instances)" + run_ipc newwin-class +new-window --class=com.example.nobody; settle 1 + fact "step +new-window --class=nobody: windows=$(windows) instances=$(instances)" + # A second GUI launch is forwarded to the first instance and exits. + timeout 20 "$BIN" >"$D/second.log" 2>&1; fact "second launch rc=$?" + settle; fact "step second launch: windows=$(windows) instances=$(instances)" + children | sed 's/^/child: /' >>"$FACTS" + # A launch with -e is not single-instance: it runs on its own. + bg timeout 8 "$BIN" -e sleep 603 >"$D/dash-e.log" 2>&1 + settle 5; fact "step -e launch: windows=$(windows) instances=$(instances)" + settle 5; fact "step -e launch ended: windows=$(windows) instances=$(instances)" + # Custom class: a separate instance on its own bus name / object path. + bg "$BIN" --class=com.example.custom-app >"$D/app2.log" 2>&1 + wait_name com.example.custom-app; fact "custom class owns name: $?" + settle 4; fact "step custom: windows=$(windows com.example.custom-app) instances=$(instances)" + run_ipc newwin-custom +new-window --class=com.example.custom-app; settle + fact "step +new-window --class=custom: custom windows=$(windows com.example.custom-app) default windows=$(windows)" + run_ipc newwin-custom-e +new-window --class=com.example.custom-app -e sleep 605; settle + fact "step +new-window --class=custom -e: custom windows=$(windows com.example.custom-app) default windows=$(windows)" + run_ipc newwin-badclass +new-window --class=1bad; fact "badclass done" + busctl --user list --acquired --no-pager | awk '$1 ~ /^(com\.mitchellh|com\.example)/ {print "bus name: "$1}' >>"$FACTS" + gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \ + --method org.gtk.Actions.List 2>&1 | tr -d "'" >"$D/actions.txt"; fact "actions: $(cat "$D/actions.txt")" + gdbus call --session --dest com.example.custom-app --object-path /com/example/custom_app \ + --method org.gtk.Actions.List 2>&1 | tr -d "'" >"$D/actions2.txt"; fact "custom actions: $(cat "$D/actions2.txt")" +} + +scn_single_instance_detect() { + cfg "gtk-single-instance = detect" + start_bus_tools + bg "$BIN" >"$D/app1.log" 2>&1 + wait_name com.mitchellh.ghostty; fact "plain launch owns name: $?" + settle 4; fact "step plain: windows=$(windows) instances=$(instances)" + timeout 20 "$BIN" >"$D/second.log" 2>&1; fact "second plain launch rc=$?" + settle; fact "step second plain: windows=$(windows) instances=$(instances)" + # TERM_PROGRAM set: a dedicated instance (false) that does not use the bus name. + bg env TERM_PROGRAM=x timeout 12 "$BIN" >"$D/termprog.log" 2>&1 + settle 6; fact "step TERM_PROGRAM: windows=$(windows) instances=$(instances)" + settle 7; fact "step TERM_PROGRAM ended: windows=$(windows) instances=$(instances)" + # Any CLI argument: dedicated instance. + bg timeout 12 "$BIN" --title=cli >"$D/cliarg.log" 2>&1 + settle 6; fact "step CLI arg: windows=$(windows) instances=$(instances)" + settle 7; fact "step CLI arg ended: windows=$(windows) instances=$(instances)" + run_ipc newwin +new-window; settle + fact "step +new-window: windows=$(windows) instances=$(instances)" +} + +scn_notifications() { + export FLAG2=$W/go2 + rm -f "$FLAG2" + cfg "desktop-notifications = true" "notify-on-command-finished = always" "notify-on-command-finished-after = 1s" \ + "command = sh $LD/child-notify.sh" + start_bus_tools + bg "$BIN" >"$D/app.log" 2>&1 + settle 16 + fact "notifications so far: $(grep -c "\"method\": \"Notify\"" "$MOCK_LOG" 2>/dev/null || true)" + # Activate the first notifications (default action): must not crash. + mockctl Invoke 1 default; settle 2 + fact "alive after ActionInvoked: instances=$(instances) windows=$(windows)" + mockctl Invoke 2 default; mockctl Invoke 999 default; settle 1 + fact "alive after more ActionInvoked: instances=$(instances) windows=$(windows)" + cfg "desktop-notifications = false" "command = sh $LD/child-notify.sh" + gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \ + --method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 2 + touch "$FLAG2"; settle 3 + fact "notifications after desktop-notifications=false: $(grep -c "\"method\": \"Notify\"" "$MOCK_LOG" 2>/dev/null || true)" +} + +scn_shortcuts() { + cfg "keybind = global:ctrl+alt+x=new_window" "keybind = global:super+shift+b=new_tab" \ + "keybind = global:ctrl+shift+f12=reload_config" "keybind = global:alt+space>q=quit" \ + "keybind = global:ctrl+alt+q=text:hi" "keybind = ctrl+alt+z=new_window" + start_bus_tools + bg "$BIN" >"$D/app.log" 2>&1 + wait_name com.mitchellh.ghostty; settle 5 + fact "step start: windows=$(windows)" + id=$(python3 - "$MOCK_LOG" <<'PY' +import json, sys +for l in open(sys.argv[1]): + j = json.loads(l) + if j["method"] == "BindShortcuts": + for sid, opts in j["args"][1]: + if opts.get("description", "").startswith("new_window"): + print(sid) +PY +) + fact "new_window shortcut id: $id" + mockctl Activate "$id"; settle 3 + fact "step Activated(new_window): windows=$(windows)" + mockctl Activate nonexistent; settle 1 + fact "step Activated(unknown): windows=$(windows)" + # Reload with the keybinds removed: the old session is closed. + cfg "keybind = ctrl+alt+z=new_window" + gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \ + --method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 3 + fact "step reload without global: windows=$(windows)" + cfg "keybind = global:ctrl+alt+x=new_window" + gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \ + --method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 3 + fact "step reload with global again: windows=$(windows)" +} + +scn_settings() { + mkdir -p "$W/themes" + printf 'background = ffeedd\nforeground = 111111\n' >"$W/themes/light" + printf 'background = 112233\nforeground = eeeeee\n' >"$W/themes/dark" + cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = auto" "command = sh $LD/child-theme.sh" + MOCK_COLOR_SCHEME=1 start_bus_tools + export OUTF=$D/tty.txt + : >"$OUTF" + bg "$BIN" >"$D/app.log" 2>&1 + for _ in $(seq 600); do [ -s "$OUTF" ] && break; sleep 0.1; done + [ -s "$OUTF" ] || fact "ERROR: terminal child produced no output" + settle 3 + for scheme in 2 1 0 2; do + printf "\n--- after portal color-scheme=$scheme\n" >>"$OUTF" + mockctl SetColorScheme $scheme; settle 2 + done + printf "\n--- after reload window-theme=dark\n" >>"$OUTF" + cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = dark" "command = sh $LD/child-theme.sh" + gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \ + --method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 2 + printf "\n--- after portal color-scheme=2 with window-theme=dark\n" >>"$OUTF" + mockctl SetColorScheme 2; settle 2 + python3 "$LD/summarize-tty.py" "$OUTF" | sed 's/^/tty: /' >>"$FACTS" +} + +scn_cgroup() { + start_bus_tools + phase() { # name, config lines... + local name=$1; shift + : >"$MOCK_LOG" + cfg "$@" + bg "$BIN" -e sleep 30 >"$D/app-$name.log" 2>&1 + local pid=$! + # wait for the terminal child (the scope is created right before it is exec'd) + for _ in $(seq 250); do pgrep -P $pid -x sleep >/dev/null && break; sleep 0.1; done + settle 3 + fact "phase $name:" + grep StartTransientUnit "$MOCK_LOG" | sed 's/^/ call: /' >>"$FACTS" + kill $pid 2>/dev/null + for _ in $(seq 100); do kill -0 $pid 2>/dev/null || break; sleep 0.1; done + kill -9 $pid 2>/dev/null; kill $(tagged sleep) 2>/dev/null; settle 1 + } + phase default "gtk-single-instance = false" + phase always "gtk-single-instance = false" "linux-cgroup = always" + phase always-limits "gtk-single-instance = false" "linux-cgroup = always" "linux-cgroup-memory-limit = 123456789" "linux-cgroup-processes-limit = 77" + phase never "gtk-single-instance = false" "linux-cgroup = never" + mockctl SetFail true + phase always-failing "gtk-single-instance = false" "linux-cgroup = always" + fact "cgroup done" +} + +scn_openuri() { + # Ghostty 1.3.1 does not use the OpenURI portal (upstream TODO): links and + # the config file are opened by spawning `xdg-open`. A fake xdg-open on + # PATH records the invocations; the mock records any portal OpenURI call. + mkdir -p "$W/bin" + cat >"$W/bin/xdg-open" <>"$D/xdg-open.log" +for a in "\$@"; do printf ' [%s]' "\$a" >>"$D/xdg-open.log"; done +printf ' cwd=%s\n' "\$(pwd)" >>"$D/xdg-open.log" +XDG + chmod +x "$W/bin/xdg-open" + export PATH=$W/bin:$PATH GTK_USE_PORTAL=1 + : >"$D/xdg-open.log" + cfg "gtk-single-instance = true" + start_bus_tools + bg "$BIN" >"$D/app.log" 2>&1 + wait_name com.mitchellh.ghostty; settle 5 + gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \ + --method org.gtk.Actions.Activate open-config '[]' '{}' >/dev/null; settle 3 + sed "s|$W|\$RUN|g" "$D/xdg-open.log" | sed 's/^/spawn: /' >>"$FACTS" + fact "portal OpenURI/OpenFile calls: $(grep -c 'portal.OpenURI' "$MOCK_LOG")" +} + +scn_openurl_click() { + # The original has no input replay and weston's headless pointer is not + # reachable through Xwayland, so this scenario only runs for the Rust + # build; its result is compared against golden/openurl-click.txt, which is + # derived from the Zig source (internal_os/open.zig spawns `xdg-open `). + [ "${LDB_LABEL:-}" = rust ] || return + mkdir -p "$W/bin" + cat >"$W/bin/xdg-open" <>"$D/xdg-open.log" +for a in "\$@"; do printf ' [%s]' "\$a" >>"$D/xdg-open.log"; done +printf '\n' >>"$D/xdg-open.log" +XDG + chmod +x "$W/bin/xdg-open" + export PATH=$W/bin:$PATH GDK_BACKEND=x11 OUTF=$D/tty.txt + : >"$D/xdg-open.log"; : >"$OUTF" + cfg "gtk-single-instance = false" "gtk-titlebar = false" "window-padding-x = 0" "window-padding-y = 0" \ + "font-size = 14" "window-decoration = none" "command = sh $LD/child-links.sh" "window-width = 60" "window-height = 12" + start_bus_tools + # Launch 1 measures the cell size. + bg "$BIN" >"$D/app-measure.log" 2>&1 + local pid=$! + settle 7 + rows=$(grep -o '\^\[\[8;[0-9]*;[0-9]*t' "$OUTF" | head -1 | cut -d';' -f2) + cols=$(grep -o '\^\[\[8;[0-9]*;[0-9]*t' "$OUTF" | head -1 | cut -d';' -f3 | tr -d t) + wid=$(xdotool search --onlyvisible --class ghostty 2>/dev/null | head -1) + eval "$(xdotool getwindowgeometry --shell "$wid" 2>/dev/null)" + kill $pid; settle 1 + [ -n "${WIDTH:-}" ] && [ -n "${rows:-}" ] || { fact "no geometry"; return; } + cw=$((WIDTH / cols)); ch=$((HEIGHT / rows)) + { + for row in 0 1 2 3; do + echo "mouse-move $((cw * 3)) $((row * ch + ch / 2)) ctrl" + echo "wait 600" + echo "click 1 ctrl" + echo "wait 1500" + done + echo "wait 500" + } >"$W/click.txt" + : >"$D/xdg-open.log"; : >"$OUTF" + bg env GHOSTTY_DEBUG_INPUT_SCRIPT="$W/click.txt" "$BIN" >"$D/app.log" 2>&1 + settle 16 + sed "s|$W|\$RUN|g" "$D/xdg-open.log" | sed 's/^/spawn: /' >>"$FACTS" +} + +# The real xdg-desktop-portal frontend + the gtk backend (gsettings keyfile +# backend, so we can change color-scheme live) on the private bus. +scn_real_portal() { + mkdir -p "$W/cfg/xdg-desktop-portal" "$W/cfg/glib-2.0/settings" "$W/themes" + printf '[preferred]\ndefault=gtk\n' >"$W/cfg/xdg-desktop-portal/portals.conf" + portal_env="/usr/bin/env XDG_CURRENT_DESKTOP=GNOME GSETTINGS_BACKEND=keyfile XDG_CONFIG_HOME=$W/cfg HOME=$W/home XDG_DATA_HOME=$W/data XDG_CACHE_HOME=$W/cache WAYLAND_DISPLAY=$WAYLAND_DISPLAY" + svc() { printf '[D-BUS Service]\nName=%s\nExec=%s\n' "$1" "$2" >"$LDB_RUN/services/$1.service"; } + svc org.freedesktop.portal.Desktop "$portal_env /usr/lib/xdg-desktop-portal" + svc org.freedesktop.impl.portal.desktop.gtk "$portal_env /usr/lib/xdg-desktop-portal-gtk" + svc org.freedesktop.impl.portal.PermissionStore "$portal_env /usr/lib/xdg-permission-store" + printf 'background = ffeedd\nforeground = 111111\n' >"$W/themes/light" + printf 'background = 112233\nforeground = eeeeee\n' >"$W/themes/dark" + keyfile=$W/cfg/glib-2.0/settings/keyfile + setscheme() { printf '[org/gnome/desktop/interface]\ncolor-scheme='"'"'%s'"'"'\n' "$1" >"$keyfile"; } + setscheme prefer-dark + cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = auto" "command = sh $LD/child-theme.sh" \ + "keybind = global:ctrl+alt+x=new_window" + start_bus_tools notifications,systemd + # Start the portal first so that its backends are up. + gdbus call --session --dest org.freedesktop.portal.Desktop --object-path /org/freedesktop/portal/desktop \ + --method org.freedesktop.DBus.Properties.Get org.freedesktop.portal.Settings version >"$D/portal-version.txt" 2>&1 + fact "portal frontend: $(tr -d '\n' <"$D/portal-version.txt" | cut -c1-80)" + settle 2 + export OUTF=$D/tty.txt; : >"$OUTF" + bg env GSETTINGS_BACKEND=memory "$BIN" >"$D/app.log" 2>&1 + for _ in $(seq 600); do [ -s "$OUTF" ] && break; sleep 0.1; done + [ -s "$OUTF" ] || fact "ERROR: terminal child produced no output" + settle 3 + for scheme in prefer-light prefer-dark default prefer-dark; do + printf "\n--- after gsettings color-scheme=$scheme\n" >>"$OUTF" + setscheme $scheme; settle 3 + done + python3 "$LD/summarize-tty.py" "$OUTF" | sed 's/^/tty: /' >>"$FACTS" + # Only the D-Bus error names (the log prefixes differ between the builds). + sed -n 's/.*request failed=\(GDBus.Error:[A-Za-z.]*\).*/\1/p' "$D/app.log" | sed 's/^/global-shortcuts request error: /' >>"$FACTS" + fact "alive: instances=$(instances)" +} diff --git a/scripts/live-dbus/summarize-tty.py b/scripts/live-dbus/summarize-tty.py new file mode 100644 index 0000000..661d522 --- /dev/null +++ b/scripts/live-dbus/summarize-tty.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Summarize the raw `cat -v` capture of child-theme.sh: per phase (split by +the `--- after` markers) the collapsed sequence of CSI ? 997 reports and the +last OSC 11 background answer.""" +import re +import sys + +text = open(sys.argv[1]).read() +parts = re.split(r"\n--- (after [^\n]*)\n", "\n" + text) +# parts: [startup, name1, body1, name2, body2, ...]; markers are written +# right before the action, so the body after a marker is its effect. +names = ["startup"] + parts[1::2] +chunks = [parts[0]] + parts[2::2] +for name, chunk in zip(names, chunks): + reports = re.findall(r"\^\[\[\?997;(\d)n", chunk) + collapsed = [r for i, r in enumerate(reports) if i == 0 or r != reports[i - 1]] + bgs = re.findall(r"\^\[\]11;(rgb:[0-9a-f/]+)\^G", chunk) + print(f"{name}: 997 reports={','.join(collapsed) or '-'} last bg={bgs[-1] if bgs else '-'}") diff --git a/tests/live_dbus.rs b/tests/live_dbus.rs new file mode 100644 index 0000000..426d925 --- /dev/null +++ b/tests/live_dbus.rs @@ -0,0 +1,89 @@ +#![allow(clippy::unwrap_used)] +//! Runs `scripts/live-dbus-test.sh` in golden mode: the Rust build is started +//! on a private D-Bus session bus inside a private headless weston, with mock +//! Notifications / systemd1 / portal services (and the real +//! xdg-desktop-portal + gtk backend), and its observable D-Bus behaviour is +//! compared with `scripts/live-dbus/golden/*.txt`, recorded from the original +//! Ghostty 1.3.1 (`UPDATE_GOLDEN=1 just live-dbus` re-records them against the +//! installed original). See `docs/rewrite/notes/live-dbus.md`. +//! +//! Skipped (passes) when a needed tool is missing, when there is no +//! release-profile Rust build to test (debug builds use a different D-Bus +//! application id, `com.mitchellh.ghostty-debug`) or when that build is older +//! than the sources (build it with `scripts/live-dbus/build-rust.sh`). Set +//! `GHOSTTY_SKIP_LIVE_DBUS` to skip explicitly. + +use std::{ + path::{Path, PathBuf}, + process::Command, + time::SystemTime, +}; + +fn newest_source(dir: &Path, newest: &mut SystemTime) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let p = e.path(); + let name = e.file_name(); + if p.is_dir() { + if name != "target" && name != ".git" { + newest_source(&p, newest); + } + } else if matches!( + p.extension().and_then(|x| x.to_str()), + Some("rs" | "toml" | "blp" | "ui" | "css") + ) && let Ok(m) = e.metadata().and_then(|m| m.modified()) + { + *newest = (*newest).max(m); + } + } +} + +fn have(tool: &str) -> bool { + Command::new("sh") + .args(["-c", &format!("command -v {tool}")]) + .output() + .is_ok_and(|o| o.status.success()) +} + +#[test] +fn rust_build_matches_recorded_original_on_private_bus() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + if std::env::var_os("GHOSTTY_SKIP_LIVE_DBUS").is_some() { + return; + } + for tool in ["weston", "dbus-run-session", "busctl", "python3", "xdotool"] { + if !have(tool) { + eprintln!("live-dbus: skipped ({tool} missing)"); + return; + } + } + let bin = std::env::var_os("RUST_BIN") + .map_or_else(|| root.join("target/vp/production/ghostty"), PathBuf::from); + let Ok(built) = std::fs::metadata(&bin).and_then(|m| m.modified()) else { + eprintln!("live-dbus: skipped (no release build at {})", bin.display()); + return; + }; + let mut newest = SystemTime::UNIX_EPOCH; + for d in ["crates", "src", "resources"] { + newest_source(&root.join(d), &mut newest); + } + if newest > built { + eprintln!( + "live-dbus: skipped ({} is older than the sources)", + bin.display() + ); + return; + } + let out = Command::new("bash") + .arg(root.join("scripts/live-dbus-test.sh")) + .env("LABELS", "rust") + .env("RUST_BIN", &bin) + .env("OUT", root.join("target/live-dbus")) + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + eprintln!("{text}"); + assert!(out.status.success(), "live-dbus comparison failed:\n{text}"); +}