test: verify IME, clipboard dialogs, quick terminal and Flatpak paths

Exercises the runtime paths that had never run (details in
docs/rewrite/notes/verification-VR.md):
- IME: replay steps drive the surface's real GtkIMContext handlers with
  a real preedit + attributes; preedit renders under the cursor and
  commits reach the pty, incl. dead-key and compose orderings.
- Paste and OSC 52 read/write confirmation dialogs: allow, deny and
  remember flows.
- Quick terminal on niri via wlr-layer-shell: positions, sizes, toggle
  and autohide. Fixes a real bug: link order could put libwayland-client
  ahead of libgtk4-layer-shell, silently disabling the quick terminal;
  apprt-gtk now loads libwayland-client via wayland-sys dlopen.
- Flatpak host commands against a zbus mock of
  org.freedesktop.Flatpak.Development under dbus-run-session: spawn,
  exit status, cwd/env/fd forwarding and SIGHUP on stop, both directly
  and through termio Exec (flatpak-info path seam added).
- Horizontal-scroll tab switching thresholds and reset.

tests/gtk_replay.rs runs the real binary against the session display
(skips without one).

Test Plan:
- cargo test --test gtk_replay (needs a Wayland/X11 display)
- cargo test -p ghostty-os --test flatpak_mock; -p ghostty-termio
  --test flatpak_exec
This commit is contained in:
ddidderr committed 2026-10-12 06:02:28 +02:00
1 parent b3145ad7c1
commit 84f665cdfb
7 files changed
+1567 -12

No files matched your search

+432 -9
View File
@@ -24,11 +24,25 @@
//! click 1 [mods] # press+release; repeat quickly for multi-click //! click 1 [mods] # press+release; repeat quickly for multi-click
//! drag 10 10 200 10 [mods] # move, press left, move in steps, release //! drag 10 10 200 10 [mods] # move, press left, move in steps, release
//! scroll 0 -3 # wheel (dx dy, in ticks) //! 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) //! resize 600 400 # set the window default size (logical px)
//! clipboard-set a\nb # set the GDK clipboard (\n and \\ escapes) //! clipboard-set a\nb # set the GDK clipboard (\n and \\ escapes)
//! window-screenshot /abs/out.png # GTK render of the whole window (dialogs included) //! window-screenshot /abs/out.png # GTK render of the whole window (dialogs included)
//! windows-dump # log every toplevel window / dialog //! windows-dump # log every toplevel window / dialog
//! clipboard-dump # log the GDK clipboard and primary text //! 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 <mode> <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 //! quit # quit the application
//! ``` //! ```
//! //!
@@ -38,9 +52,14 @@
use std::{cell::RefCell, rc::Rc, time::Duration}; 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. /// One parsed command.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -58,11 +77,23 @@ pub enum Step {
Click(u32, String), Click(u32, String),
Drag(f64, f64, f64, f64, String), Drag(f64, f64, f64, f64, String),
Scroll(f64, f64), Scroll(f64, f64),
HScroll(f64),
TabsDump,
ClipboardDump, ClipboardDump,
Resize(i32, i32), Resize(i32, i32),
ClipboardSet(String), ClipboardSet(String),
WindowsDump, WindowsDump,
WindowScreenshot(String), WindowScreenshot(String),
ImePreedit(String),
ImePreeditEnd,
ImeCommit(String),
ImeKey(String, String, String),
ImeState,
Animations(bool),
DialogInfo,
QuickFocusOut,
DialogRemember,
DialogClick(String),
Quit, Quit,
} }
@@ -149,6 +180,8 @@ pub fn parse(script: &str) -> Result<Vec<Step>, String> {
}; };
Step::Scroll(dx, dy) Step::Scroll(dx, dy)
} }
"hscroll" => Step::HScroll(rest.parse().map_err(|_| err("hscroll needs dx"))?),
"tabs-dump" => Step::TabsDump,
"clipboard-dump" => Step::ClipboardDump, "clipboard-dump" => Step::ClipboardDump,
"windows-dump" => Step::WindowsDump, "windows-dump" => Step::WindowsDump,
"window-screenshot" if !rest.is_empty() => Step::WindowScreenshot(rest.to_owned()), "window-screenshot" if !rest.is_empty() => Step::WindowScreenshot(rest.to_owned()),
@@ -164,6 +197,38 @@ pub fn parse(script: &str) -> Result<Vec<Step>, String> {
}; };
Step::Resize(w, h) 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 <mode> <key> [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, "quit" => Step::Quit,
_ => return Err(err(&format!("bad command {line:?}"))), _ => return Err(err(&format!("bad command {line:?}"))),
}); });
@@ -240,6 +305,7 @@ pub fn start(app: &Application) {
steps, steps,
next: RefCell::new(0), next: RefCell::new(0),
pos: std::cell::Cell::new((0.0, 0.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). // Wait for the first surface to be ready (realized, core initialized).
glib::timeout_add_local(Duration::from_millis(100), move || { glib::timeout_add_local(Duration::from_millis(100), move || {
@@ -258,13 +324,15 @@ struct Runner {
next: RefCell<usize>, next: RefCell<usize>,
/// Last pointer position (logical px). /// Last pointer position (logical px).
pos: std::cell::Cell<(f64, f64)>, 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 /// Render the whole widget tree of `win` (GL area content may be blank, but
/// dialogs and popovers are included) to a PNG. /// dialogs and popovers are included) to a PNG.
fn window_screenshot(win: &gtk::Window, path: &str) { fn window_screenshot(win: &impl IsA<gtk::Widget>, path: &str) {
let (w, h) = (win.width(), win.height()); 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(); let snapshot = gtk::Snapshot::new();
paintable.snapshot( paintable.snapshot(
snapshot.upcast_ref::<gdk::Snapshot>(), snapshot.upcast_ref::<gdk::Snapshot>(),
@@ -301,6 +369,64 @@ fn dump_tree(w: &gtk::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: &gtk::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<T: IsA<gtk::Widget>>(w: &gtk::Widget, out: &mut Vec<T>) {
let mut c = w.first_child();
while let Some(child) = c {
if let Ok(t) = child.clone().downcast::<T>() {
out.push(t);
}
collect_descendants(&child, out);
c = child.next_sibling();
}
}
fn find_descendant<T: IsA<gtk::Widget>>(w: &gtk::Widget) -> Option<T> {
if let Ok(t) = w.clone().downcast::<T>() {
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 { fn parse_mods(spec: &str) -> gdk::ModifierType {
let mut state = gdk::ModifierType::empty(); let mut state = gdk::ModifierType::empty();
for m in spec.split('+').filter(|m| !m.is_empty()) { for m in spec.split('+').filter(|m| !m.is_empty()) {
@@ -325,12 +451,29 @@ fn button_mask(button: u32) -> gdk::ModifierType {
} }
impl Runner { 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<Window> {
let active = self
.app
.active_window()
.and_then(|w| w.downcast::<Window>().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::<Window>().ok())
.find(gtk::prelude::WidgetExt::is_visible)
.or(active)
}
fn active_surface(&self) -> Option<Surface> { fn active_surface(&self) -> Option<Surface> {
self.app self.active_window()?.get_active_surface()
.active_window()?
.downcast::<Window>()
.ok()?
.get_active_surface()
} }
/// Run steps until the next `wait`, which reschedules us. /// Run steps until the next `wait`, which reschedules us.
@@ -386,6 +529,22 @@ impl Runner {
s.debug_scroll(dy); 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::<Window>().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) => { Step::Resize(w, h) => {
if let Some(win) = self.app.active_window() { if let Some(win) = self.app.active_window() {
win.set_default_size(w, h); win.set_default_size(w, h);
@@ -406,6 +565,9 @@ impl Runner {
dump_tree(win.upcast_ref(), 0); dump_tree(win.upcast_ref(), 0);
} }
for w in gtk::Window::list_toplevels() { for w in gtk::Window::list_toplevels() {
if let Some(win) = w.downcast_ref::<Window>() {
dump_window_state(win);
}
log::warn!( log::warn!(
"debug input: toplevel {} title={:?} visible={}", "debug input: toplevel {} title={:?} visible={}",
w.type_().name(), w.type_().name(),
@@ -420,6 +582,61 @@ impl Runner {
s.debug_clipboard_dump(); 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::<Window>().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::<adw::SwitchRow>(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() { Step::Text(text) => match self.active_surface() {
Some(s) => s.debug_commit_text(&text), Some(s) => s.debug_commit_text(&text),
None => log::warn!("debug input: no active surface"), None => log::warn!("debug input: no active surface"),
@@ -468,6 +685,122 @@ impl Runner {
s.debug_mouse_button(press, button, x, y, m); s.debug_mouse_button(press, button, x, y, m);
} }
/// The open alert dialog of the active window, if any.
fn find_dialog(&self) -> Option<adw::AlertDialog> {
find_descendant::<adw::AlertDialog>(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::<adw::SwitchRow>(d.upcast_ref()).is_some()
);
let mut chain = Vec::new();
let mut w = Some(d.clone().upcast::<gtk::Widget>());
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::<gtk::TextView>(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::<gtk::Button>(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::<Vec<_>>()
),
}
}
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) { fn key(&self, spec: &str) {
let Some(surface) = self.active_surface() else { let Some(surface) = self.active_surface() else {
log::warn!("debug input: no active surface"); 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<String>,
}
#[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<imp::MockIm>) @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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -520,6 +911,33 @@ mod tests {
assert!(parse("bogus").is_err()); 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] #[test]
fn parse_mouse_steps() { 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"; 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()), 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("mouse-move 1").is_err());
assert!(parse("drag 1 2 3").is_err()); assert!(parse("drag 1 2 3").is_err());
assert!(parse("click x").is_err()); assert!(parse("click x").is_err());
+19 -3
View File
@@ -17,7 +17,23 @@ const INTERFACE: &str = "org.freedesktop.Flatpak.Development";
/// Returns true if we're running in a Flatpak environment. /// Returns true if we're running in a Flatpak environment.
pub fn is_flatpak() -> bool { 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::path::PathBuf> = 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<std::path::PathBuf>) -> bool {
FLATPAK_INFO_OVERRIDE.set(path.into()).is_ok()
} }
/// Errors that are possible from us. /// 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 /// pipes, /dev/null, etc.) but this was purpose built for a specific need so
/// it doesn't have all of those. /// it doesn't have all of those.
/// ///
/// NOTE: this can't be exercised in the test environment (needs a Flatpak /// The DBus path is exercised against a mock `org.freedesktop.Flatpak`
/// session bus with the Development interface); it is a best-effort port. /// service in `tests/flatpak_mock.rs` (run under `dbus-run-session`).
pub struct FlatpakHostCommand { pub struct FlatpakHostCommand {
/// Argv are the arguments to call on the host with argv[0] being /// Argv are the arguments to call on the host with argv[0] being
/// the command to execute. /// the command to execute.
@@ -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<String>,
pub cwd: String,
pub env: HashMap<String, String>,
pub fds: Vec<u32>,
}
#[derive(Default)]
pub struct Record {
pub spawned: Vec<Spawned>,
/// (pid, signal, to_process_group)
pub signals: Vec<(u32, u32, bool)>,
}
pub struct Service {
pub record: Arc<Mutex<Record>>,
// Keeps the bus name and the object server alive.
_conn: Connection,
}
struct Dev {
record: Arc<Mutex<Record>>,
conn: Connection,
}
#[zbus::interface(name = "org.freedesktop.Flatpak.Development")]
impl Dev {
fn host_command(
&self,
cwd_path: Vec<u8>,
argv: Vec<Vec<u8>>,
fds: HashMap<u32, zvariant::OwnedFd>,
envs: HashMap<String, String>,
_flags: u32,
) -> zbus::fdo::Result<u32> {
let strip = |v: &[u8]| -> String {
let v = v.strip_suffix(&[0]).unwrap_or(v);
String::from_utf8_lossy(v).into_owned()
};
let argv: Vec<String> = argv.iter().map(|a| strip(a)).collect();
let cwd = strip(&cwd_path);
let fd_keys: Vec<u32> = {
let mut k: Vec<u32> = 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<Spawned> {
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
}
+130
View File
@@ -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);
}
+281
View File
@@ -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<SurfaceMessage>);
impl SurfaceMailbox for SurfaceRec {
fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize {
let _ = self.0.send(msg);
1
}
}
#[derive(Default)]
struct RendererRec {
resizes: Mutex<Vec<Size>>,
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<JoinHandle<()>>,
surface: Receiver<SurfaceMessage>,
_renderer: Arc<RendererRec>,
_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<T>(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option<T>) -> 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<SurfaceMessage>) {
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);
}
+75
View File
@@ -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 <text>`, `ime-preedit-end`, `ime-commit <text>`, `ime-state`,
`ime-key <preedit|commit|commit-simple|handled-commit|pass> <key> [text]`.
The steps call the surface's real template callbacks (`im_preedit_start/changed/end`, `im_commit`, now `pub(super)`
and taking `&gtk::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 <label>` (clicks the real GtkButton in the AdwAlertDialog,
so the AdwAlertDialog response -> `confirm`/`deny` signals run). `animations off|on`.
- Quick terminal: `windows-dump` now logs per GhosttyWindow: quick_terminal, visible, mapped, active, size and the
layer-shell state (layer, keyboard mode, namespace, anchors, exclusive zone); `quick-focus-out` emits
`notify::is-active` on the quick window (the window is never compositor-focused here) to run the autohide handler.
- `hscroll <dx>` (the handler body was moved to `Surface::scroll_horizontal_impl`; the controller keeps the unit
check), `tabs-dump`. Replay target window: if GTK's active window is hidden (autohidden quick terminal) the first
visible Ghostty window is used.
## Results
| Area | Result |
|---|---|
| IME preedit | PASS. `ime-preedit かn` renders the underlined preedit at the cursor (screenshot); core preedit cleared on commit/end. |
| IME commit / dead keys | PASS. pty receives `こんáxyé` (`cat -v` also shows the exact UTF-8 bytes); preedit text never reaches the pty; composing/consumed state sequence matches Zig's logic. |
| Unsafe paste dialog | PASS. Dialog (heading, body, Deny/Allow, contents) verified structurally; Deny writes nothing, Allow writes the text once. |
| OSC 52 write=ask | PASS. Deny keeps the clipboard, Allow writes it, Allow+remember (switch) makes later writes silent (`clipboard_write` -> allow). |
| OSC 52 read=ask | PASS. Allow: pty gets `ESC]52;c;c2VjcmV0...`; Deny: nothing sent. |
| Quick terminal | PASS after the link-order fix below: layer surface (visible in `niri msg layers`: Top/Overlay, namespace `ghostty-quick-terminal`, keyboard interactivity), anchors/size for top/bottom/left/right/center match Zig's `quick-terminal-size` and anchor logic, toggle hides and re-shows, autohide on focus loss (default false on Linux, as Zig) hides, `autohide=false` stays. |
| hscroll tabs | PASS (threshold 120, direction, no wrap at the ends, 500 ms accumulator reset). |
| Flatpak host command | PASS against a mock service (see below). |
Not verifiable here: real keyboard focus on the layer surface (`quick-terminal-keyboard-interactivity=exclusive` is
applied, `niri msg layers` shows it, but `GtkWindow::is_active` stays false because the replayed windows are never
compositor-focused; the autohide path was driven with `quick-focus-out`); KDE slide animation (needs the KDE protocol,
niri has none); rendering of AdwDialog in screenshots (`window-screenshot` does not capture the dialog layer; use
`dialog-info`); a real input method (ibus/fcitx are not available).
## Bug found and fixed: gtk4-layer-shell load order
`libgtk4-layer-shell` must be loaded before `libwayland-client`. The Rust binary's DT_NEEDED order depends on how
rustc orders native libs, which flipped between builds (adding an unrelated `gtk4_layer_shell::` path in a file was
enough); with the wrong order `is_supported()` is false and the quick terminal is silently disabled ("compositor does
not support wlr-layer-shell"). Fix: `wayland-sys` with features `client,dlopen` is a direct dependency of apprt-gtk,
so `libwayland-client` is no longer a direct DT_NEEDED of the binary (it is only pulled in transitively by GTK, which
is loaded after all direct libraries, same effect as Zig's explicit link order). Check: `readelf -d target/*/ghostty |
grep NEEDED` has no `libwayland-client`. Regression: `quick_terminal_layer_surface_toggle_and_autohide`.
## Flatpak host command
- Seam: `ghostty_os::flatpak::set_flatpak_info_path(path)` (OnceLock, production code, unused by the app) makes
`is_flatpak()` check that path instead of `/.flatpak-info`.
- Mock service: `crates/ghostty-os/tests/common/flatpak_mock.rs` (zbus, claims `org.freedesktop.Flatpak`, implements
`Development.HostCommand`/`HostCommandSignal`, really runs the command locally with the received stdio fds in its
own process group, emits `HostCommandExited` with the raw wait status, records spawns and signals). Tests re-exec
themselves under `dbus-run-session` (skipped if it is missing).
- `crates/ghostty-os/tests/flatpak_mock.rs`: spawn returns host pid, cwd/env/fds forwarded, stdout via passed fd,
exit status propagated (7), `wait` state reset, `wait_callback`, host spawn failure -> `FlatpakSpawnFail`,
`signal_handle().signal(1, true)` after the command moved into the watcher -> recorded `(pid, 1, true)` and the
host process exits.
- `crates/ghostty-termio/tests/flatpak_exec.rs` (dev-deps: zbus, rustix): termio `Exec` flatpak branch end to end
(output on the pty, exit code 5 propagated as ChildExited, input echoed through the pty, `stop()` sends SIGHUP to
the host process group and the host process dies). One test per binary: the override is process-global.
## Regression tests
- `tests/gtk_replay.rs` (root package, runs the real binary on the current Wayland/X11 session; skips without a
display or with `GHOSTTY_SKIP_GTK_REPLAY`): IME flow, paste deny/confirm, OSC 52 write/read ask (+remember),
quick terminal layer surface/toggle/autohide/no-autohide, horizontal scroll tab switching. ~45 s total, serialized
(they share the session clipboard).
- Parser unit tests for the new steps in `debug_input.rs`.
## Notes
- `screenshot` can be one frame stale right after input (wait >= 1 s); `text` after `key escape` in bash is
meta-prefixed by readline (not a bug).
- Child programs reading the tty from `sh -c` need `timeout --foreground` (job-control SIGTTIN otherwise).
- `quick-terminal-autohide` defaults to false on Linux (as Zig), so the quick terminal does not hide on focus loss by default.
+426
View File
@@ -0,0 +1,426 @@
#![allow(clippy::unwrap_used)]
//! Runs the real GTK application on the current Wayland/X11 session with the
//! `GHOSTTY_DEBUG_INPUT_SCRIPT` replay hook and checks what reaches the pty
//! (a `cat > file` child) and the clipboard. Covers the paths a real user
//! drives with an input method or a confirmation dialog: IME preedit/commit
//! through the surface's real signal handlers, the unsafe-paste dialog and
//! the OSC 52 read/write confirmation dialogs (confirm and deny).
//!
//! Skipped (passes) when there is no display or `GHOSTTY_SKIP_GTK_REPLAY` is
//! set. The tests share the session clipboard, so they run one at a time.
use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
fn serial() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn have_display() -> bool {
std::env::var_os("GHOSTTY_SKIP_GTK_REPLAY").is_none()
&& (std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some())
}
struct Run {
log: String,
}
impl Run {
/// Lines of the replay log (`debug input: ...`) with the prefix removed.
fn lines(&self) -> Vec<&str> {
self.log
.lines()
.filter_map(|l| l.split_once("debug input: ").map(|x| x.1))
.collect()
}
fn has(&self, needle: &str) -> bool {
self.lines().iter().any(|l| l.contains(needle))
}
}
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ghostty-gtk-replay-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
/// Run the app with `script`, `extra` config args and the child command
/// `sh -c <child>`.
fn run(script: &str, extra: &[&str], child: &str) -> Run {
let script_path = scratch("script.txt");
// Distinct file per call so parallel tests never clash.
let script_path = script_path.with_file_name(format!(
"script-{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&script_path, format!("animations off\n{script}\nquit\n")).unwrap();
let mut proc = Command::new(env!("CARGO_BIN_EXE_ghostty"))
.args([
"--config-default-files=false",
"--gtk-single-instance=false",
"--confirm-close-surface=false",
])
.args(extra)
.args(["-e", "sh", "-c", child])
.env("GHOSTTY_DEBUG_INPUT_SCRIPT", &script_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let stderr = proc.stderr.take().unwrap();
let reader = std::thread::spawn(move || {
let mut s = String::new();
let _ = std::io::Read::read_to_string(&mut { stderr }, &mut s);
s
});
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if proc.try_wait().unwrap().is_some() {
break;
}
if Instant::now() > deadline {
let _ = proc.kill();
let _ = proc.wait();
panic!("app did not quit; log:\n{}", reader.join().unwrap());
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = std::fs::remove_file(&script_path);
Run {
log: reader.join().unwrap(),
}
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_default()
}
#[test]
fn ime_preedit_commit_and_dead_key_flow() {
if !have_display() {
return;
}
let _serial = serial();
let out = scratch("ime.out");
let _ = std::fs::remove_file(&out);
let r = run(
"\
wait 800
ime-preedit かn
ime-state
ime-commit こん
ime-state
ime-key preedit apostrophe ´
ime-key commit a á
ime-key handled-commit x x
ime-key pass y
ime-key preedit apostrophe ´
ime-key commit-simple e é
ime-state
key enter
key ctrl+d
wait 800",
&[],
&format!("cat > {}", out.display()),
);
// The preedit never reaches the pty, committed text and plain keys do.
assert_eq!(read(&out), "こんáxyé\n", "log:\n{}", r.log);
// composing: preedit -> true, commit -> false, ... , end -> false.
let state: Vec<&str> = r
.lines()
.into_iter()
.filter(|l| l.starts_with("composing=") || l.starts_with("ime-key consumed="))
.collect();
assert_eq!(
state,
[
"composing=true",
"composing=false",
// dead key: handled while composing -> not encoded
"ime-key consumed=true composing=true",
// commit of the composed char: handled, no key encode
"ime-key consumed=true composing=false",
// plain translation commit: handled but encoded with the key
"ime-key consumed=false composing=false",
// not handled: encoded
"ime-key consumed=false composing=false",
"ime-key consumed=true composing=true",
"ime-key consumed=true composing=false",
"composing=false",
],
"log:\n{}",
r.log
);
}
#[test]
fn unsafe_paste_dialog_deny_then_confirm() {
if !have_display() {
return;
}
let _serial = serial();
let out = scratch("paste.out");
let _ = std::fs::remove_file(&out);
let r = run(
"\
wait 800
clipboard-set rm -rf a\\nsecond\\n
key ctrl+shift+v
wait 800
dialog-info
dialog-click Deny
wait 500
dialog-info
key ctrl+shift+v
wait 800
dialog-click Allow
wait 500
key ctrl+d
wait 800",
&[],
&format!("cat > {}", out.display()),
);
assert!(r.has("Warning: Potentially Unsafe Paste"), "{}", r.log);
assert!(
r.has("dialog contents=\"rm -rf a\\nsecond\\n\""),
"{}",
r.log
);
// Deny closed the dialog (the second dialog-info found none).
assert!(r.has("no dialog is open"), "{}", r.log);
// Exactly one copy: the denied paste wrote nothing.
assert_eq!(read(&out), "rm -rf a\nsecond\n", "{}", r.log);
}
#[test]
fn osc52_write_ask_allow_deny_and_remember() {
if !have_display() {
return;
}
let _serial = serial();
// Deny: clipboard unchanged.
let r = run(
"\
clipboard-set before
wait 1500
dialog-info
dialog-click Deny
wait 500
clipboard-dump
wait 500",
&["--clipboard-write=ask"],
"printf '\\033]52;c;aGVsbG8=\\a'; sleep 8",
);
assert!(
r.has("body=\"An application is attempting to write"),
"{}",
r.log
);
assert!(r.has("dialog contents=\"hello\""), "{}", r.log);
assert!(r.has("clipboard = Some(\"before\")"), "{}", r.log);
// Allow: clipboard written.
let r = run(
"\
clipboard-set before
wait 1500
dialog-click Allow
wait 500
clipboard-dump
wait 500",
&["--clipboard-write=ask"],
"printf '\\033]52;c;aGVsbG8=\\a'; sleep 8",
);
assert!(r.has("clipboard = Some(\"hello\")"), "{}", r.log);
// Allow + remember: the second write does not prompt.
let r = run(
"\
wait 1500
dialog-remember
dialog-click Allow
wait 3000
dialog-info
clipboard-dump
wait 500",
&["--clipboard-write=ask"],
"printf '\\033]52;c;aGVsbG8=\\a'; sleep 2; printf '\\033]52;c;d29ybGQ=\\a'; sleep 8",
);
assert!(r.has("no dialog is open"), "{}", r.log);
assert!(r.has("clipboard = Some(\"world\")"), "{}", r.log);
}
#[test]
fn osc52_read_ask_allow_and_deny() {
if !have_display() {
return;
}
let _serial = serial();
let out = scratch("osc52-read.out");
let child = format!(
"rm -f {o}; stty -icanon -echo; sleep 2; printf '\\033]52;c;?\\a'; timeout --foreground 6 dd bs=1 count=15 of={o} 2>/dev/null; sleep 1",
o = out.display()
);
for (button, expect) in [("Allow", "\x1b]52;c;c2VjcmV0"), ("Deny", "")] {
let _ = std::fs::remove_file(&out);
let r = run(
&format!(
"clipboard-set secret\nwait 3500\ndialog-info\ndialog-click {button}\nwait 6000"
),
&["--clipboard-read=ask"],
&child,
);
assert!(
r.has("body=\"An application is attempting to read"),
"{}",
r.log
);
assert!(r.has("dialog contents=\"secret\""), "{}", r.log);
assert_eq!(read(&out), expect, "button {button}\n{}", r.log);
}
}
/// `libgtk4-layer-shell` must be loaded before `libwayland-client`; if the
/// link order regresses `is_supported()` is false and no layer surface is
/// created on a compositor that does implement wlr-layer-shell.
#[test]
fn quick_terminal_layer_surface_toggle_and_autohide() {
if !have_display() {
return;
}
let _serial = serial();
let script = "\
wait 800
key ctrl+shift+q
wait 1500
windows-dump
quick-focus-out
wait 1000
windows-dump
key ctrl+shift+q
wait 2000
windows-dump";
let r = run(
script,
&[
"--keybind=global:ctrl+shift+q=toggle_quick_terminal",
"--quick-terminal-position=bottom",
"--quick-terminal-size=50%,30%",
"--gtk-quick-terminal-layer=overlay",
"--quick-terminal-autohide=true",
],
"sleep 20",
);
if !r.log.contains("found global zwlr_layer_shell_v1") {
return; // compositor without wlr-layer-shell (or X11)
}
assert!(
!r.log.contains("disabling quick terminal"),
"layer shell not usable (library load order?)\n{}",
r.log
);
let quick: Vec<&str> = r
.lines()
.into_iter()
.filter(|l| l.starts_with("window quick_terminal=true"))
.collect();
assert_eq!(quick.len(), 3, "{}", r.log);
// Shown as a layer surface anchored to the bottom edge only.
assert!(quick[0].contains("visible=true"), "{}", quick[0]);
assert!(
quick[0].contains("layer=Overlay keyboard=OnDemand")
&& quick[0].contains("anchors(l,r,t,b)=[false, false, false, true]"),
"{}",
quick[0]
);
// Losing focus autohides (quick-terminal-autohide is false by default on
// Linux, enabled above).
assert!(quick[1].contains("visible=false"), "{}", quick[1]);
// Toggling shows it again.
assert!(quick[2].contains("visible=true"), "{}", quick[2]);
}
/// Horizontal touchpad scrolling switches tabs at +-120 accumulated units
/// (+ = previous, - = next) and the accumulator resets after 500 ms.
#[test]
fn horizontal_scroll_switches_tabs() {
if !have_display() {
return;
}
let _serial = serial();
let r = run(
"\
wait 800
key ctrl+shift+t
wait 1500
key ctrl+shift+t
wait 1500
tabs-dump
hscroll 70
tabs-dump
hscroll 70
tabs-dump
hscroll -130
tabs-dump
hscroll -130
tabs-dump
hscroll 50
wait 700
hscroll 50
tabs-dump
hscroll 100
tabs-dump",
&[],
"sleep 20",
);
let tabs: Vec<&str> = r
.lines()
.into_iter()
.filter_map(|l| l.strip_prefix("tabs n=3 selected="))
.collect();
assert_eq!(
tabs,
[
"Some(2)", // new tab is selected
"Some(2)", // 70 < 120: accumulating
"Some(1)", // 140: previous
"Some(2)", // -130: next
"Some(2)", // next at the last tab stays
"Some(2)", // 50, timeout, 50: reset in between
"Some(1)", // 50 + 100: previous
],
"{}",
r.log
);
}
#[test]
fn quick_terminal_stays_open_without_autohide() {
if !have_display() {
return;
}
let _serial = serial();
let r = run(
"wait 800\nkey ctrl+shift+q\nwait 1500\nquick-focus-out\nwait 1000\nwindows-dump",
&["--keybind=global:ctrl+shift+q=toggle_quick_terminal"],
"sleep 20",
);
if !r.log.contains("found global zwlr_layer_shell_v1") {
return;
}
assert!(
r.has("window quick_terminal=true visible=true"),
"{}",
r.log
);
}