diff --git a/crates/ghostty-apprt-gtk/src/debug_input.rs b/crates/ghostty-apprt-gtk/src/debug_input.rs index c8f744f..a837fde 100644 --- a/crates/ghostty-apprt-gtk/src/debug_input.rs +++ b/crates/ghostty-apprt-gtk/src/debug_input.rs @@ -24,11 +24,25 @@ //! click 1 [mods] # press+release; repeat quickly for multi-click //! drag 10 10 200 10 [mods] # move, press left, move in steps, release //! scroll 0 -3 # wheel (dx dy, in ticks) +//! hscroll 130 # horizontal touchpad scroll (surface units); +-120 switches tabs +//! tabs-dump # log the tab count and selected tab of the active window //! resize 600 400 # set the window default size (logical px) //! clipboard-set a\nb # set the GDK clipboard (\n and \\ escapes) //! window-screenshot /abs/out.png # GTK render of the whole window (dialogs included) //! windows-dump # log every toplevel window / dialog //! clipboard-dump # log the GDK clipboard and primary text +//! ime-preedit ´a # IM: preedit-start (if needed) + preedit-changed with this preedit +//! ime-preedit-end # IM: preedit-end +//! ime-commit á # IM: commit signal (outside any key event) +//! ime-key [text] # key press+release through the IM filter, mode: +//! # preedit | commit | commit-simple | handled-commit | pass +//! ime-state # log whether the surface is composing +//! animations off # disable GTK/libadwaita animations (so dialogs render at once) +//! quick-focus-out # emit notify::is-active on the quick terminal window (it is not +//! # compositor-focused here), running the autohide handler +//! dialog-info # log the open AdwAlertDialog (heading, body, responses, contents) +//! dialog-remember # turn on the "remember choice" switch of the open dialog +//! dialog-click Allow # click the dialog button with this label (Allow/Deny/OK/Cancel...) //! quit # quit the application //! ``` //! @@ -38,9 +52,14 @@ use std::{cell::RefCell, rc::Rc, time::Duration}; -use gtk::{gdk, glib, glib::translate::IntoGlib, prelude::*}; +use adw::prelude::*; +use gtk::{gdk, glib, glib::translate::IntoGlib}; -use crate::class::{application::Application, surface::Surface, window::Window}; +use crate::class::{ + application::Application, + surface::{DebugIm, DebugImKey, Surface}, + window::Window, +}; /// One parsed command. #[derive(Debug, Clone, PartialEq)] @@ -58,11 +77,23 @@ pub enum Step { Click(u32, String), Drag(f64, f64, f64, f64, String), Scroll(f64, f64), + HScroll(f64), + TabsDump, ClipboardDump, Resize(i32, i32), ClipboardSet(String), WindowsDump, WindowScreenshot(String), + ImePreedit(String), + ImePreeditEnd, + ImeCommit(String), + ImeKey(String, String, String), + ImeState, + Animations(bool), + DialogInfo, + QuickFocusOut, + DialogRemember, + DialogClick(String), Quit, } @@ -149,6 +180,8 @@ pub fn parse(script: &str) -> Result, String> { }; Step::Scroll(dx, dy) } + "hscroll" => Step::HScroll(rest.parse().map_err(|_| err("hscroll needs dx"))?), + "tabs-dump" => Step::TabsDump, "clipboard-dump" => Step::ClipboardDump, "windows-dump" => Step::WindowsDump, "window-screenshot" if !rest.is_empty() => Step::WindowScreenshot(rest.to_owned()), @@ -164,6 +197,38 @@ pub fn parse(script: &str) -> Result, String> { }; Step::Resize(w, h) } + "ime-preedit" if !rest.is_empty() => Step::ImePreedit(rest.to_owned()), + "ime-preedit-end" => Step::ImePreeditEnd, + "ime-commit" if !rest.is_empty() => Step::ImeCommit(rest.to_owned()), + "ime-state" => Step::ImeState, + "animations" => Step::Animations(match rest { + "on" => true, + "off" => false, + _ => return Err(err("animations needs on|off")), + }), + "dialog-info" => Step::DialogInfo, + "quick-focus-out" => Step::QuickFocusOut, + "dialog-remember" => Step::DialogRemember, + "dialog-click" if !rest.is_empty() => Step::DialogClick(rest.to_owned()), + "ime-key" => { + let mut it = rest.splitn(3, char::is_whitespace); + let (Some(mode), Some(key)) = (it.next(), it.next()) else { + return Err(err("ime-key needs [text]")); + }; + if ![ + "preedit", + "commit", + "commit-simple", + "handled-commit", + "pass", + ] + .contains(&mode) + { + return Err(err("bad ime-key mode")); + } + let text = it.next().unwrap_or("").trim_start().to_owned(); + Step::ImeKey(mode.to_owned(), key.to_owned(), text) + } "quit" => Step::Quit, _ => return Err(err(&format!("bad command {line:?}"))), }); @@ -240,6 +305,7 @@ pub fn start(app: &Application) { steps, next: RefCell::new(0), pos: std::cell::Cell::new((0.0, 0.0)), + mock_im: mock_im::MockIm::default(), }); // Wait for the first surface to be ready (realized, core initialized). glib::timeout_add_local(Duration::from_millis(100), move || { @@ -258,13 +324,15 @@ struct Runner { next: RefCell, /// Last pointer position (logical px). pos: std::cell::Cell<(f64, f64)>, + /// Stands in for the input method context as the signal emitter. + mock_im: mock_im::MockIm, } /// Render the whole widget tree of `win` (GL area content may be blank, but /// dialogs and popovers are included) to a PNG. -fn window_screenshot(win: >k::Window, path: &str) { +fn window_screenshot(win: &impl IsA, path: &str) { let (w, h) = (win.width(), win.height()); - let paintable = gtk::WidgetPaintable::new(Some(win)); + let paintable = gtk::WidgetPaintable::new(Some(win.as_ref())); let snapshot = gtk::Snapshot::new(); paintable.snapshot( snapshot.upcast_ref::(), @@ -301,6 +369,64 @@ fn dump_tree(w: >k::Widget, depth: usize) { } } +/// Log what the compositor-facing state of a window is (layer-shell role for +/// the quick terminal). +fn dump_window_state(win: &Window) { + use gtk4_layer_shell::LayerShell; + let gw: >k::Window = win.upcast_ref(); + let layer = gw.is_layer_window().then(|| { + let edges = [ + gtk4_layer_shell::Edge::Left, + gtk4_layer_shell::Edge::Right, + gtk4_layer_shell::Edge::Top, + gtk4_layer_shell::Edge::Bottom, + ] + .map(|e| gw.is_anchor(e)); + format!( + "layer={:?} keyboard={:?} namespace={:?} anchors(l,r,t,b)={edges:?} exclusive={}", + gw.layer(), + gw.keyboard_mode(), + gw.namespace(), + gw.exclusive_zone() + ) + }); + log::warn!( + "debug input: window quick_terminal={} visible={} mapped={} active={} size={}x{} layer_shell={:?}", + win.is_quick_terminal(), + gw.is_visible(), + gw.is_mapped(), + gw.is_active(), + gw.width(), + gw.height(), + layer + ); +} + +fn collect_descendants>(w: >k::Widget, out: &mut Vec) { + let mut c = w.first_child(); + while let Some(child) = c { + if let Ok(t) = child.clone().downcast::() { + out.push(t); + } + collect_descendants(&child, out); + c = child.next_sibling(); + } +} + +fn find_descendant>(w: >k::Widget) -> Option { + if let Ok(t) = w.clone().downcast::() { + return Some(t); + } + let mut c = w.first_child(); + while let Some(child) = c { + if let Some(t) = find_descendant(&child) { + return Some(t); + } + c = child.next_sibling(); + } + None +} + fn parse_mods(spec: &str) -> gdk::ModifierType { let mut state = gdk::ModifierType::empty(); for m in spec.split('+').filter(|m| !m.is_empty()) { @@ -325,12 +451,29 @@ fn button_mask(button: u32) -> gdk::ModifierType { } impl Runner { + /// The window the replay acts on: the application's active window, or, + /// if that is hidden (e.g. an autohidden quick terminal that GTK still + /// reports as active), the first visible Ghostty window. + fn active_window(&self) -> Option { + let active = self + .app + .active_window() + .and_then(|w| w.downcast::().ok()); + if active + .as_ref() + .is_some_and(gtk::prelude::WidgetExt::is_visible) + { + return active; + } + gtk::Window::list_toplevels() + .into_iter() + .filter_map(|w| w.downcast::().ok()) + .find(gtk::prelude::WidgetExt::is_visible) + .or(active) + } + fn active_surface(&self) -> Option { - self.app - .active_window()? - .downcast::() - .ok()? - .get_active_surface() + self.active_window()?.get_active_surface() } /// Run steps until the next `wait`, which reschedules us. @@ -386,6 +529,22 @@ impl Runner { s.debug_scroll(dy); } } + Step::HScroll(dx) => { + if let Some(s) = self.active_surface() { + s.debug_hscroll(dx); + } + } + Step::TabsDump => { + if let Some(win) = self + .app + .active_window() + .and_then(|w| w.downcast::().ok()) + { + let tv = win.tab_view(); + let selected = tv.selected_page().map(|p| tv.page_position(&p)); + log::warn!("debug input: tabs n={} selected={selected:?}", tv.n_pages()); + } + } Step::Resize(w, h) => { if let Some(win) = self.app.active_window() { win.set_default_size(w, h); @@ -406,6 +565,9 @@ impl Runner { dump_tree(win.upcast_ref(), 0); } for w in gtk::Window::list_toplevels() { + if let Some(win) = w.downcast_ref::() { + dump_window_state(win); + } log::warn!( "debug input: toplevel {} title={:?} visible={}", w.type_().name(), @@ -420,6 +582,61 @@ impl Runner { s.debug_clipboard_dump(); } } + Step::ImePreedit(text) => { + if let Some(s) = self.active_surface() { + self.mock_im.set_preedit(&text); + if !s.debug_im_composing() { + s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditStart); + } + s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditChanged); + } + } + Step::ImePreeditEnd => { + if let Some(s) = self.active_surface() { + self.mock_im.set_preedit(""); + s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditEnd); + } + } + Step::ImeCommit(text) => { + if let Some(s) = self.active_surface() { + s.debug_im(self.mock_im.upcast_ref(), DebugIm::Commit(text)); + } + } + Step::ImeState => { + if let Some(s) = self.active_surface() { + log::warn!("debug input: composing={}", s.debug_im_composing()); + } + } + Step::Animations(on) => { + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(on); + } + } + Step::DialogInfo => self.dialog_info(), + Step::QuickFocusOut => { + let quick = gtk::Window::list_toplevels() + .into_iter() + .filter_map(|w| w.downcast::().ok()) + .find(Window::is_quick_terminal); + match quick { + // The window is not active, so this is exactly what + // GTK emits when a quick terminal loses focus. + Some(w) => w.notify("is-active"), + None => log::warn!("debug input: no quick terminal window"), + } + } + Step::DialogRemember => match self.find_dialog() { + Some(d) => { + if let Some(row) = find_descendant::(d.upcast_ref()) { + row.set_active(true); + } else { + log::warn!("debug input: the dialog has no switch"); + } + } + None => log::warn!("debug input: no dialog is open"), + }, + Step::DialogClick(label) => self.dialog_click(&label), + Step::ImeKey(mode, spec, text) => self.ime_key(&mode, &spec, &text), Step::Text(text) => match self.active_surface() { Some(s) => s.debug_commit_text(&text), None => log::warn!("debug input: no active surface"), @@ -468,6 +685,122 @@ impl Runner { s.debug_mouse_button(press, button, x, y, m); } + /// The open alert dialog of the active window, if any. + fn find_dialog(&self) -> Option { + find_descendant::(self.app.active_window()?.upcast_ref()) + } + + fn dialog_info(&self) { + let Some(d) = self.find_dialog() else { + log::warn!("debug input: no dialog is open"); + return; + }; + log::warn!( + "debug input: dialog {} heading={:?} body={:?} remember_switch={}", + d.type_().name(), + d.heading(), + d.body(), + find_descendant::(d.upcast_ref()).is_some() + ); + let mut chain = Vec::new(); + let mut w = Some(d.clone().upcast::()); + while let Some(x) = w { + chain.push(format!("{}(mapped={})", x.type_().name(), x.is_mapped())); + w = x.parent(); + } + log::warn!( + "debug input: dialog visible={} realized={} chain={}", + d.is_visible(), + d.is_realized(), + chain.join(" < ") + ); + for id in ["cancel", "ok", "close", "confirm", "deny"] { + if d.has_response(id) { + log::warn!( + "debug input: dialog response {id} label={:?}", + d.response_label(id) + ); + } + } + if let Some(view) = find_descendant::(d.upcast_ref()) { + let buf = view.buffer(); + log::warn!( + "debug input: dialog contents={:?}", + buf.text(&buf.start_iter(), &buf.end_iter(), false) + ); + } + } + + /// Click the dialog's button labelled `label` (the real GtkButton, so the + /// AdwAlertDialog response machinery runs as for a user click). + fn dialog_click(&self, label: &str) { + let Some(d) = self.find_dialog() else { + log::warn!("debug input: no dialog is open"); + return; + }; + let mut buttons = Vec::new(); + collect_descendants::(d.upcast_ref(), &mut buttons); + match buttons + .iter() + .find(|b| b.label().is_some_and(|l| l.eq_ignore_ascii_case(label))) + { + Some(b) => b.emit_clicked(), + None => log::warn!( + "debug input: no dialog button {label:?}; have {:?}", + buttons.iter().map(gtk::Button::label).collect::>() + ), + } + } + + fn ime_key(&self, mode: &str, spec: &str, text: &str) { + let Some(surface) = self.active_surface() else { + log::warn!("debug input: no active surface"); + return; + }; + let (key, state) = match parse_key(spec) { + Ok(v) => v, + Err(err) => { + log::error!("debug input: {err}"); + return; + } + }; + let Some(keycode) = surface + .display() + .map_keyval(key) + .and_then(|keys| keys.first().map(gdk::KeymapKey::keycode)) + else { + log::error!("debug input: no keycode for {spec:?}"); + return; + }; + let sim = match mode { + "preedit" => { + self.mock_im.set_preedit(text); + DebugImKey::Preedit + } + "commit" => { + self.mock_im.set_preedit(""); + DebugImKey::Commit(text.to_owned()) + } + "commit-simple" => { + self.mock_im.set_preedit(""); + DebugImKey::CommitSimple(text.to_owned()) + } + "handled-commit" => DebugImKey::HandledCommit(text.to_owned()), + _ => DebugImKey::Pass, + }; + let consumed = surface.debug_im_key( + self.mock_im.upcast_ref(), + &sim, + key.into_glib(), + keycode, + state, + ); + log::warn!( + "debug input: ime-key consumed={consumed} composing={}", + surface.debug_im_composing() + ); + } + fn key(&self, spec: &str) { let Some(surface) = self.active_surface() else { log::warn!("debug input: no active surface"); @@ -496,6 +829,64 @@ impl Runner { } } +/// A stand-in `GtkIMContext` whose preedit string and attributes the replay +/// controls; the surface's real signal handlers read it through +/// `preedit_string()` exactly as they would from an input method. +pub mod mock_im { + use gtk::{glib, subclass::prelude::*}; + + mod imp { + use std::cell::RefCell; + + use gtk::{glib, pango, subclass::prelude::*}; + + #[derive(Default)] + pub struct MockIm { + pub preedit: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for MockIm { + const NAME: &'static str = "GhosttyDebugMockIm"; + type Type = super::MockIm; + type ParentType = gtk::IMContext; + } + + impl ObjectImpl for MockIm {} + + impl IMContextImpl for MockIm { + fn preedit_string(&self) -> (glib::GString, pango::AttrList, i32) { + let text = self.preedit.borrow().clone(); + // What a real input method sends: an underline over the + // whole preedit and the cursor at its end. + let attrs = pango::AttrList::new(); + let mut underline = pango::AttrInt::new_underline(pango::Underline::Single); + underline.set_start_index(0); + underline.set_end_index(text.len() as u32); + attrs.insert(underline); + let cursor = text.chars().count() as i32; + (text.as_str().into(), attrs, cursor) + } + } + } + + glib::wrapper! { + pub struct MockIm(ObjectSubclass) @extends gtk::IMContext; + } + + impl Default for MockIm { + fn default() -> Self { + glib::Object::new() + } + } + + impl MockIm { + pub fn set_preedit(&self, text: &str) { + self.imp().preedit.replace(text.to_owned()); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -520,6 +911,33 @@ mod tests { assert!(parse("bogus").is_err()); } + #[test] + fn parse_ime_steps() { + let s = "ime-preedit ´a\nime-preedit-end\nime-commit á\nime-key preedit apostrophe ´\nime-key pass a\nime-state\n"; + assert_eq!( + parse(s).expect("valid script"), + vec![ + Step::ImePreedit("´a".into()), + Step::ImePreeditEnd, + Step::ImeCommit("á".into()), + Step::ImeKey("preedit".into(), "apostrophe".into(), "´".into()), + Step::ImeKey("pass".into(), "a".into(), String::new()), + Step::ImeState, + ] + ); + assert_eq!( + parse("dialog-info\ndialog-remember\ndialog-click Allow\n").expect("valid"), + vec![ + Step::DialogInfo, + Step::DialogRemember, + Step::DialogClick("Allow".into()) + ] + ); + assert!(parse("dialog-click").is_err()); + assert!(parse("ime-key bogus a").is_err()); + assert!(parse("ime-key commit").is_err()); + } + #[test] fn parse_mouse_steps() { let s = "mouse-move 1 2\nmouse-move 1.5 2 ctrl+shift\nmouse-press 1\nmouse-release 3 alt\nclick 2\ndrag 1 2 3 4\nscroll 0 -3\nresize 600 400\nclipboard-set a\\nb\nclipboard-dump\nwindows-dump\nwindow-screenshot /tmp/w.png\n"; @@ -540,6 +958,11 @@ mod tests { Step::WindowScreenshot("/tmp/w.png".into()), ] ); + assert_eq!( + parse("hscroll 130\ntabs-dump").expect("valid"), + vec![Step::HScroll(130.0), Step::TabsDump] + ); + assert!(parse("hscroll").is_err()); assert!(parse("mouse-move 1").is_err()); assert!(parse("drag 1 2 3").is_err()); assert!(parse("click x").is_err()); diff --git a/crates/ghostty-os/src/flatpak.rs b/crates/ghostty-os/src/flatpak.rs index 8fe0ff9..ef40ce7 100644 --- a/crates/ghostty-os/src/flatpak.rs +++ b/crates/ghostty-os/src/flatpak.rs @@ -17,7 +17,23 @@ const INTERFACE: &str = "org.freedesktop.Flatpak.Development"; /// Returns true if we're running in a Flatpak environment. pub fn is_flatpak() -> bool { - std::path::Path::new("/.flatpak-info").exists() + std::path::Path::new( + FLATPAK_INFO_OVERRIDE + .get() + .map_or(std::path::Path::new(FLATPAK_INFO), |p| p.as_path()), + ) + .exists() +} + +const FLATPAK_INFO: &str = "/.flatpak-info"; + +static FLATPAK_INFO_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Injection seam for integration tests: make [`is_flatpak`] look at `path` +/// instead of `/.flatpak-info`. Can be set once per process; returns false if +/// it was already set. Not used by the application. +pub fn set_flatpak_info_path(path: impl Into) -> bool { + FLATPAK_INFO_OVERRIDE.set(path.into()).is_ok() } /// Errors that are possible from us. @@ -76,8 +92,8 @@ impl Shared { /// pipes, /dev/null, etc.) but this was purpose built for a specific need so /// it doesn't have all of those. /// -/// NOTE: this can't be exercised in the test environment (needs a Flatpak -/// session bus with the Development interface); it is a best-effort port. +/// The DBus path is exercised against a mock `org.freedesktop.Flatpak` +/// service in `tests/flatpak_mock.rs` (run under `dbus-run-session`). pub struct FlatpakHostCommand { /// Argv are the arguments to call on the host with argv[0] being /// the command to execute. diff --git a/crates/ghostty-os/tests/common/flatpak_mock.rs b/crates/ghostty-os/tests/common/flatpak_mock.rs new file mode 100644 index 0000000..78f28d1 --- /dev/null +++ b/crates/ghostty-os/tests/common/flatpak_mock.rs @@ -0,0 +1,204 @@ +//! A mock `org.freedesktop.Flatpak` session bus service implementing the +//! `org.freedesktop.Flatpak.Development` interface (HostCommand, +//! HostCommandSignal, HostCommandExited), used by the Flatpak host command +//! integration tests. It really runs the requested command locally (with the +//! received stdio fds), so the whole path can be observed end to end. +//! +//! Shared by `ghostty-os` and `ghostty-termio` tests via `#[path]`. +#![allow(dead_code, clippy::unwrap_used, clippy::needless_pass_by_value)] + +use std::{ + collections::HashMap, + ffi::OsStr, + os::{ + fd::OwnedFd, + unix::{ + ffi::OsStrExt, + process::{CommandExt, ExitStatusExt}, + }, + }, + process::{Command, Stdio}, + sync::{Arc, Mutex}, +}; + +use zbus::{blocking::Connection, zvariant}; + +pub const BUS_NAME: &str = "org.freedesktop.Flatpak"; +pub const OBJECT_PATH: &str = "/org/freedesktop/Flatpak/Development"; +pub const INTERFACE: &str = "org.freedesktop.Flatpak.Development"; + +/// What the mock saw. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Spawned { + pub pid: u32, + pub argv: Vec, + pub cwd: String, + pub env: HashMap, + pub fds: Vec, +} + +#[derive(Default)] +pub struct Record { + pub spawned: Vec, + /// (pid, signal, to_process_group) + pub signals: Vec<(u32, u32, bool)>, +} + +pub struct Service { + pub record: Arc>, + // Keeps the bus name and the object server alive. + _conn: Connection, +} + +struct Dev { + record: Arc>, + conn: Connection, +} + +#[zbus::interface(name = "org.freedesktop.Flatpak.Development")] +impl Dev { + fn host_command( + &self, + cwd_path: Vec, + argv: Vec>, + fds: HashMap, + envs: HashMap, + _flags: u32, + ) -> zbus::fdo::Result { + let strip = |v: &[u8]| -> String { + let v = v.strip_suffix(&[0]).unwrap_or(v); + String::from_utf8_lossy(v).into_owned() + }; + let argv: Vec = argv.iter().map(|a| strip(a)).collect(); + let cwd = strip(&cwd_path); + let fd_keys: Vec = { + let mut k: Vec = fds.keys().copied().collect(); + k.sort_unstable(); + k + }; + let mut fds = fds; + let mut take = |i: u32| -> Stdio { + fds.remove(&i) + .map_or_else(Stdio::null, |fd| Stdio::from(OwnedFd::from(fd))) + }; + + let mut cmd = Command::new(OsStr::from_bytes(argv[0].as_bytes())); + cmd.args(&argv[1..]) + .env_clear() + .envs(&envs) + .current_dir(&cwd) + .stdin(take(0)) + .stdout(take(1)) + .stderr(take(2)) + // The real service runs the command in its own process group + // so that `to_process_group` signals reach the whole group. + .process_group(0); + let mut child = cmd + .spawn() + .map_err(|e| zbus::fdo::Error::Failed(format!("spawn {}: {e}", argv[0])))?; + let pid = child.id(); + self.record.lock().unwrap().spawned.push(Spawned { + pid, + argv, + cwd, + env: envs, + fds: fd_keys, + }); + + // Report the exit like the portal: raw wait status. + let conn = self.conn.clone(); + std::thread::spawn(move || { + let status = child.wait().unwrap(); + let _ = conn.emit_signal( + None::<&str>, + OBJECT_PATH, + INTERFACE, + "HostCommandExited", + &(pid, status.into_raw() as u32), + ); + }); + Ok(pid) + } + + fn host_command_signal(&self, pid: u32, signal: u32, to_process_group: bool) { + self.record + .lock() + .unwrap() + .signals + .push((pid, signal, to_process_group)); + let (Some(rpid), Some(sig)) = ( + rustix::process::Pid::from_raw(pid as i32), + rustix::process::Signal::from_named_raw(signal as i32), + ) else { + return; + }; + // The child leads its own group (see above). + let _ = if to_process_group { + rustix::process::kill_process_group(rpid, sig) + } else { + rustix::process::kill_process(rpid, sig) + }; + } +} + +impl Service { + /// Claim `org.freedesktop.Flatpak` on the session bus. + pub fn start() -> Self { + let record = Arc::new(Mutex::new(Record::default())); + let conn = zbus::blocking::connection::Builder::session() + .unwrap() + .name(BUS_NAME) + .unwrap() + .build() + .unwrap(); + let dev = Dev { + record: Arc::clone(&record), + conn: conn.clone(), + }; + conn.object_server().at(OBJECT_PATH, dev).unwrap(); + Self { + record, + _conn: conn, + } + } + + pub fn signals(&self) -> Vec<(u32, u32, bool)> { + self.record.lock().unwrap().signals.clone() + } + + pub fn spawned(&self) -> Vec { + self.record.lock().unwrap().spawned.clone() + } +} + +/// Run `test_name` of the current test binary under `dbus-run-session` +/// (once; the inner run sees `GHOSTTY_FLATPAK_MOCK_INNER=1`). Returns true if +/// the caller is the inner process and should run the real test body. +/// Returns false when the outer process ran the inner one (and asserted it +/// passed) or when `dbus-run-session` is unavailable. +pub fn under_dbus_session(test_name: &str) -> bool { + if std::env::var_os("GHOSTTY_FLATPAK_MOCK_INNER").is_some() { + return true; + } + let exe = std::env::current_exe().unwrap(); + let out = match Command::new("dbus-run-session") + .arg("--") + .arg(exe) + .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) + .env("GHOSTTY_FLATPAK_MOCK_INNER", "1") + .output() + { + Ok(o) => o, + Err(e) => { + eprintln!("dbus-run-session unavailable ({e}); skipping {test_name}"); + return false; + } + }; + assert!( + out.status.success(), + "inner test failed\n--- stdout\n{}\n--- stderr\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + false +} diff --git a/crates/ghostty-os/tests/flatpak_mock.rs b/crates/ghostty-os/tests/flatpak_mock.rs new file mode 100644 index 0000000..32de516 --- /dev/null +++ b/crates/ghostty-os/tests/flatpak_mock.rs @@ -0,0 +1,130 @@ +#![cfg(unix)] +#![allow(clippy::unwrap_used)] +//! `FlatpakHostCommand` against a mock `org.freedesktop.Flatpak.Development` +//! DBus service on a private session bus (`dbus-run-session`). + +mod common { + pub mod flatpak_mock; +} + +use std::{ + io::Read, + os::fd::OwnedFd, + time::{Duration, Instant}, +}; + +use common::flatpak_mock::Service; +use ghostty_os::flatpak::{self, Error, FlatpakHostCommand}; + +fn devnull() -> OwnedFd { + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/null") + .unwrap() + .into() +} + +#[test] +fn host_command_spawn_exit_status_and_signal() { + if !common::flatpak_mock::under_dbus_session("host_command_spawn_exit_status_and_signal") { + return; + } + + // The injection seam: pretend /.flatpak-info exists. + let info = std::env::temp_dir().join(format!("flatpak-info-{}", std::process::id())); + std::fs::write(&info, "[Application]\nname=com.mitchellh.ghostty\n").unwrap(); + assert!(flatpak::set_flatpak_info_path(&info)); + assert!(flatpak::is_flatpak()); + + let svc = Service::start(); + + // 1. Spawn returns the host pid; stdout goes through the passed fd; + // cwd and env arrive; the exit status is propagated. + { + let (r, w) = ghostty_os::pipe::pipe().unwrap(); + let mut cmd = FlatpakHostCommand::new( + vec![ + "/bin/sh".into(), + "-c".into(), + "echo \"$FOO:$(pwd)\"; exit 7".into(), + ], + devnull(), + w, + devnull(), + ); + cmd.cwd = Some("/".into()); + let mut env = ghostty_os::env::EnvMap::default(); + env.insert("FOO", "bar"); + cmd.env = Some(env); + let pid = cmd.spawn().unwrap(); + assert!(pid > 1); + assert_eq!(cmd.wait(), Ok(7)); + // wait() resets the state: a second wait is an error. + assert_eq!(cmd.wait(), Err(Error::FlatpakMustBeStarted)); + drop(cmd); // closes our write end so the read below sees EOF + let mut out = String::new(); + std::fs::File::from(r).read_to_string(&mut out).unwrap(); + assert_eq!(out, "bar:/\n"); + + let spawned = svc.spawned(); + assert_eq!(spawned.len(), 1); + assert_eq!(spawned[0].pid, pid); + assert_eq!(spawned[0].argv[0], "/bin/sh"); + assert_eq!(spawned[0].cwd, "/"); + assert_eq!(spawned[0].env.get("FOO").map(String::as_str), Some("bar")); + assert_eq!(spawned[0].fds, vec![0, 1, 2]); + } + + // 2. Exit code 0 and the async wait path. + { + let mut cmd = FlatpakHostCommand::new( + vec!["/bin/sh".into(), "-c".into(), ":".into()], + devnull(), + devnull(), + devnull(), + ); + cmd.cwd = Some("/".into()); + cmd.spawn().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + cmd.wait_callback(move |r| tx.send(r).unwrap()).unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), Ok(0)); + } + + // 3. Spawn failure on the host is reported. + { + let mut cmd = FlatpakHostCommand::new( + vec!["/nonexistent/binary".into()], + devnull(), + devnull(), + devnull(), + ); + cmd.cwd = Some("/".into()); + assert_eq!(cmd.spawn(), Err(Error::FlatpakSpawnFail)); + } + + // 4. SIGHUP to the process group (what Exec::stop sends) reaches the + // host command, which then exits; the signal handle works after the + // command moved into the exit watcher. + { + let mut cmd = FlatpakHostCommand::new( + vec!["/bin/sleep".into(), "30".into()], + devnull(), + devnull(), + devnull(), + ); + cmd.cwd = Some("/".into()); + let pid = cmd.spawn().unwrap(); + let handle = cmd.signal_handle(); + let (tx, rx) = std::sync::mpsc::channel(); + cmd.wait_callback(move |r| tx.send(r).unwrap()).unwrap(); + let start = Instant::now(); + assert_eq!(handle.signal(1, true), Ok(())); + let r = rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(r.is_ok(), "{r:?}"); + assert!(start.elapsed() < Duration::from_secs(10)); + assert_eq!(svc.signals(), vec![(pid, 1, true)]); + } + + let _ = std::fs::remove_file(&info); +} diff --git a/crates/ghostty-termio/tests/flatpak_exec.rs b/crates/ghostty-termio/tests/flatpak_exec.rs new file mode 100644 index 0000000..eb9aad6 --- /dev/null +++ b/crates/ghostty-termio/tests/flatpak_exec.rs @@ -0,0 +1,281 @@ +#![cfg(unix)] +#![allow(clippy::unwrap_used)] +//! Exec's Flatpak branch (`ghostty_os::flatpak::is_flatpak()` true) against a +//! mock `org.freedesktop.Flatpak.Development` service on a private session +//! bus (`dbus-run-session`): spawn through HostCommand, output via the passed +//! pty fds, exit status propagation and SIGHUP of the process group on stop. +//! +//! One test only: the flatpak override is process-global. + +use std::{ + sync::{ + Arc, + Mutex, + atomic::{AtomicUsize, Ordering}, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +use crossbeam_channel::{Receiver, Sender, unbounded}; +use ghostty_apprt::surface::Message as SurfaceMessage; +use ghostty_config::{ + command::Command as ConfigCommand, + config::Config, + types_b::{LinuxCgroup, ShellIntegration}, +}; +use ghostty_datastruct::blocking_queue::Timeout; +use ghostty_renderer::{ + message::Message as RendererMessage, + size::{CellSize, Padding, ScreenSize, Size}, +}; +use ghostty_termio::{ + Mailbox, + Options, + RendererMailbox, + SharedState, + SharedStateExt, + SurfaceMailbox, + Termio, + Thread, + Wakeup, + backend::{self, Backend}, + exec, + message::Message, + termio::DerivedConfig, +}; + +#[path = "../../ghostty-os/tests/common/flatpak_mock.rs"] +mod flatpak_mock; + +struct SurfaceRec(Sender); + +impl SurfaceMailbox for SurfaceRec { + fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize { + let _ = self.0.send(msg); + 1 + } +} + +#[derive(Default)] +struct RendererRec { + resizes: Mutex>, + reset_blinks: AtomicUsize, +} + +impl RendererMailbox for RendererRec { + fn push(&self, msg: RendererMessage, _t: Timeout) -> usize { + match msg { + RendererMessage::Resize(s) => self.resizes.lock().unwrap().push(s), + RendererMessage::ResetCursorBlink => { + self.reset_blinks.fetch_add(1, Ordering::SeqCst); + } + _ => {} + } + 1 + } +} + +fn size(cols: u32, rows: u32) -> Size { + Size { + screen: ScreenSize { + width: cols * 10, + height: rows * 20, + }, + cell: CellSize { + width: 10, + height: 20, + }, + padding: Padding::default(), + } +} + +struct Fixture { + state: SharedState, + mailbox: Mailbox, + stop: Wakeup, + thread: Option>, + surface: Receiver, + _renderer: Arc, + _renderer_wakeup: Wakeup, +} + +fn base_config() -> Config { + Config::default() +} + +fn start(command: ConfigCommand, tweak: impl FnOnce(&mut Config)) -> Fixture { + let mut config = base_config(); + tweak(&mut config); + + let mut ecfg = exec::Config::new(ghostty_os::env::get_env_map(), "xterm-256color"); + ecfg.command = Some(command); + ecfg.shell_integration = ShellIntegration::None; + // The default config has `linux-cgroup = single-instance` + + // `gtk-single-instance = detect` which only makes sense after the apprt + // resolved it. + ecfg.rt_pre_exec_info.linux_cgroup = LinuxCgroup::Never; + ecfg.rt_post_fork_info.linux_cgroup = LinuxCgroup::Never; + + let mailbox = Mailbox::new().unwrap(); + let (tx, surface) = unbounded(); + let renderer = Arc::new(RendererRec::default()); + let renderer_wakeup = Wakeup::new().unwrap(); + let termio = Termio::init(Options { + size: size(80, 30), + full_config: &config, + config: DerivedConfig::new(&config), + backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(), + mailbox: mailbox.clone(), + renderer_wakeup: renderer_wakeup.clone(), + renderer_mailbox: renderer.clone(), + surface_mailbox: Arc::new(SurfaceRec(tx)), + }) + .unwrap(); + let state = Arc::clone(termio.renderer_state()); + + let thread = Thread::new().unwrap(); + let stop = thread.stop_handle(); + let handle = std::thread::Builder::new() + .name("io".into()) + .spawn(move || thread.thread_main(termio)) + .unwrap(); + + Fixture { + state, + mailbox, + stop, + thread: Some(handle), + surface, + _renderer: renderer, + _renderer_wakeup: renderer_wakeup, + } +} + +fn sh(script: &str) -> ConfigCommand { + ConfigCommand::Direct(vec!["/bin/sh".into(), "-c".into(), script.into()]) +} + +impl Fixture { + fn text(&self) -> String { + self.state.lock_terminal().plain_string().unwrap() + } + + fn send(&self, msg: Message) { + self.mailbox.send(msg); + self.mailbox.notify(); + } + + fn write(&self, data: &[u8]) { + self.send(Message::write_req(data)); + } + + /// Wait until `f` returns Some or time out (panics). + fn wait_for(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option) -> T { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(v) = f(self) { + return v; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {what}; terminal text = {:?}", + self.text() + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn wait_text(&self, needle: &str) { + self.wait_for(&format!("text {needle:?}"), |f| { + f.text().contains(needle).then_some(()) + }); + } + + /// Wait for the ChildExited surface message, returning all messages + /// received up to and including it. + fn wait_exit(&self) -> (u32, u64, Vec) { + let deadline = Instant::now() + Duration::from_secs(10); + let mut seen = Vec::new(); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + let msg = self + .surface + .recv_timeout(left) + .unwrap_or_else(|_| panic!("timed out waiting for child exit; seen={seen:?}")); + if let SurfaceMessage::ChildExited(c) = msg { + return (c.exit_code, c.runtime_ms, seen); + } + seen.push(msg); + } + } + + fn stop(&mut self) { + self.stop.notify().unwrap(); + if let Some(t) = self.thread.take() { + t.join().unwrap(); + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = self.stop.notify(); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} +#[test] +fn flatpak_exec_spawn_exit_status_and_sighup_on_stop() { + if !flatpak_mock::under_dbus_session("flatpak_exec_spawn_exit_status_and_sighup_on_stop") { + return; + } + let info = std::env::temp_dir().join(format!("flatpak-info-termio-{}", std::process::id())); + std::fs::write(&info, "[Application]\nname=com.mitchellh.ghostty\n").unwrap(); + assert!(ghostty_os::flatpak::set_flatpak_info_path(&info)); + assert!(ghostty_os::flatpak::is_flatpak()); + + let svc = flatpak_mock::Service::start(); + + // Spawn + output + exit status. + let mut f = start(sh("echo from-host; exit 5"), |_| {}); + let (code, _, _) = f.wait_exit(); + assert_eq!(code, 5); + f.wait_text("from-host"); + f.stop(); + let spawned = svc.spawned(); + assert_eq!(spawned.len(), 1, "{spawned:?}"); + assert_eq!(spawned[0].argv[0], "/bin/sh"); + assert_eq!(spawned[0].fds, vec![0, 1, 2]); + assert!(spawned[0].env.contains_key("TERM"), "{:?}", spawned[0].env); + + // Input reaches the host command through the pty. + let mut f = start(sh("read x; echo got:$x"), |_| {}); + f.write(b"abc\n"); + f.wait_text("got:abc"); + let (code, _, _) = f.wait_exit(); + assert_eq!(code, 0); + f.stop(); + + // Stopping the termio SIGHUPs the host process group. + let mut f = start(sh("echo up; exec sleep 30"), |_| {}); + f.wait_text("up"); + let pid = svc.spawned().last().unwrap().pid; + assert_eq!(svc.signals(), vec![]); + f.stop(); + let deadline = Instant::now() + Duration::from_secs(10); + while !svc.signals().contains(&(pid, 1, true)) { + assert!(Instant::now() < deadline, "no SIGHUP: {:?}", svc.signals()); + std::thread::sleep(Duration::from_millis(20)); + } + // ... and the host process is gone. + let deadline = Instant::now() + Duration::from_secs(10); + while std::path::Path::new(&format!("/proc/{pid}")).exists() + && !std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|s| s.contains(") Z")) + { + assert!(Instant::now() < deadline, "host command still alive"); + std::thread::sleep(Duration::from_millis(20)); + } + let _ = std::fs::remove_file(&info); +} diff --git a/docs/rewrite/notes/verification-VR.md b/docs/rewrite/notes/verification-VR.md new file mode 100644 index 0000000..157bcf1 --- /dev/null +++ b/docs/rewrite/notes/verification-VR.md @@ -0,0 +1,75 @@ +# Verification VR (IME, dialogs, quick terminal, flatpak host command, hscroll) + +## Replay hook additions (`crates/ghostty-apprt-gtk/src/debug_input.rs`, `class/surface.rs`) +- IME: `ime-preedit `, `ime-preedit-end`, `ime-commit `, `ime-state`, + `ime-key [text]`. + The steps call the surface's real template callbacks (`im_preedit_start/changed/end`, `im_commit`, now `pub(super)` + and taking `>k::IMContext`) with a `mock_im::MockIm` (a real `GtkIMContext` subclass whose `preedit_string()` + returns text + underline attrs + cursor) as emitter. `ime-key` runs the real `in_keyevent`/`im_composing` + bookkeeping: the IM half of `Surface::key_event` was extracted into `Surface::im_filter(filter)` (behaviour unchanged, + `key_event` passes `filter_keypress`). Orders covered: ibus (commit then preedit-end), simple (preedit-end then commit), + dead key (handled while composing -> not encoded), plain translation commit (handled, encoded with the key), pass. + Zig has no `retrieve-surrounding`/`delete-surrounding` handlers (checked surface.zig), so there is nothing to port. +- Dialogs: `dialog-info`, `dialog-remember`, `dialog-click