linux: handle 11 more libghostty actions#238
Conversation
…reen, cursor) Wire NEW_TAB, NEW_WINDOW, GOTO_TAB, CLOSE_TAB, DESKTOP_NOTIFICATION, OPEN_URL, TOGGLE_FULLSCREEN, RING_BELL, RENDER, MOUSE_SHAPE, and MOUSE_VISIBILITY to the existing tab manager, notification system, and GTK window APIs. Brings handled action count from 2 to 13.
Wire onReadClipboard, onConfirmReadClipboard, and onWriteClipboard to GTK4's async clipboard API. Read uses gdk_clipboard_read_text_async with a completion callback that feeds text back to libghostty via ghostty_surface_complete_clipboard_request. Write sets clipboard text via gdk_clipboard_set_text. Supports both standard and selection (primary) clipboards.
Greptile SummaryThis PR adds 11 libghostty action handlers (tab navigation, notifications, fullscreen, cursor shape/visibility, bell, render) and functional clipboard read/write via GTK4's Confidence Score: 3/5Mergeable for functionality, but the async clipboard UAF and two previously flagged silent-failure paths should be addressed before shipping to users. Two prior P1 findings (silent bell, URL error discarding) remain open, and the new clipboard path introduces a use-after-free if a surface is destroyed while a paste is in flight. The UAF is low-probability but carries crash risk on the main path (Ctrl+Shift+V then closing a tab). cmux-linux/src/app.zig — specifically the onReadClipboard/onClipboardReadComplete pair and the handleBell fallback returns. Important Files Changed
Sequence DiagramsequenceDiagram
participant G as libghostty
participant A as app.zig onAction
participant TM as TabManager
participant GTK as GTK4/GDK
G->>A: onAction(NEW_TAB / NEW_WINDOW)
A->>TM: createWorkspace()
TM-->>A: ok
A->>GTK: sidebar.refresh()
G->>A: onAction(GOTO_TAB prev/next/idx)
A->>TM: selectWorkspace(idx)
G->>A: onAction(CLOSE_TAB)
A->>TM: closeWorkspace(idx) [guards last workspace]
G->>A: onAction(DESKTOP_NOTIFICATION)
A->>GTK: sendNotification (GNotification)
G->>A: onAction(RING_BELL)
A->>TM: panel.flash_count +|= 1
note over A,GTK: fallback: gdk_surface_beep
G->>A: onAction(MOUSE_SHAPE / MOUSE_VISIBILITY)
A->>GTK: gdk_cursor_new_from_name + gtk_widget_set_cursor
G->>A: onReadClipboard(surface, type, ctx)
A->>GTK: gdk_clipboard_read_text_async(cancellable=null)
note over A: No cancellable — UAF if surface closed before callback
GTK-->>A: onClipboardReadComplete(source, result, ctx)
A->>G: ghostty_surface_complete_clipboard_request
G->>A: onWriteClipboard(type, contents, count)
A->>GTK: gdk_clipboard_set_text(contents[0].data)
note over A: comment says text/plain but no MIME filter
Reviews (2): Last reviewed commit: "fix: use @intCast for C enum types in ac..." | Re-trigger Greptile |
| _ = c.gtk.g_app_info_launch_default_for_uri(url_z.ptr, null, null); | ||
| return true; |
There was a problem hiding this comment.
g_app_info_launch_default_for_uri returns a gboolean indicating success, and accepts a GError** parameter (currently null) that captures the failure reason. Discarding both means the function always returns true even when the system has no handler or the URI is malformed — the user gets no feedback and ghostty is told the action was handled.
| _ = c.gtk.g_app_info_launch_default_for_uri(url_z.ptr, null, null); | |
| return true; | |
| var g_error: ?*c.gtk.GError = null; | |
| const launched = c.gtk.g_app_info_launch_default_for_uri(url_z.ptr, null, &g_error); | |
| if (g_error) |e| { | |
| log.warn("Failed to open URL: {s}", .{e.message}); | |
| c.gtk.g_error_free(e); | |
| } | |
| return launched != 0; |
| // Fall back to system bell | ||
| const gtk_app = c.gtk.g_application_get_default() orelse return true; | ||
| const win = c.gtk.gtk_application_get_active_window(@ptrCast(@alignCast(gtk_app))) orelse return true; | ||
| const gdk_surface = c.gtk.gtk_native_get_surface(@ptrCast(win)); | ||
| if (gdk_surface) |s| c.gtk.gdk_surface_beep(s); | ||
| return true; |
There was a problem hiding this comment.
Bell swallowed when window unavailable
The fallback path returns true even when neither gtk_application_get_active_window nor gtk_native_get_surface succeed, telling ghostty the bell was handled while nothing was played. The same pattern appears in the early orelse block at line 180. Returning false in the window-not-found cases allows ghostty to invoke its own fallback rather than silently dropping the bell.
| // Fall back to system bell | |
| const gtk_app = c.gtk.g_application_get_default() orelse return true; | |
| const win = c.gtk.gtk_application_get_active_window(@ptrCast(@alignCast(gtk_app))) orelse return true; | |
| const gdk_surface = c.gtk.gtk_native_get_surface(@ptrCast(win)); | |
| if (gdk_surface) |s| c.gtk.gdk_surface_beep(s); | |
| return true; | |
| // Fall back to system bell | |
| const gtk_app2 = c.gtk.g_application_get_default() orelse return false; | |
| const win2 = c.gtk.gtk_application_get_active_window(@ptrCast(@alignCast(gtk_app2))) orelse return false; | |
| const gdk_surface2 = c.gtk.gtk_native_get_surface(@ptrCast(win2)); | |
| if (gdk_surface2) |s| c.gtk.gdk_surface_beep(s); | |
| return gdk_surface2 != null; |
Zig C translator maps C enums to c_int/c_uint, not Zig enums. Replace @intFromEnum with @intcast for goto_tab, and remove @enumFromInt for clipboard_type — compare against constants directly.
| c.gtk.gdk_clipboard_read_text_async(clipboard, null, &onClipboardReadComplete, ctx); | ||
| return true; |
There was a problem hiding this comment.
Stale surface handle in async clipboard callback
ClipboardReadContext stores a raw ghostty_surface_t by value. If onCloseSurface runs while gdk_clipboard_read_text_async is still pending (e.g. the user closes the tab mid-paste), onClipboardReadComplete fires with a freed surface handle and calls ghostty_surface_complete_clipboard_request on it — a use-after-free.
Pass a GCancellable* so the pending read can be cancelled when the surface is torn down, or store a weak/refcounted reference instead of the raw handle:
// Allocate context for the async callback
const alloc = std.heap.c_allocator;
const cancellable = c.gtk.g_cancellable_new(); // caller must cancel on surface close
const ctx = alloc.create(ClipboardReadContext) catch {
c.gtk.g_object_unref(cancellable);
return false;
};
ctx.* = .{
.surface = ghostty_surface,
.completion_context = context,
.cancellable = cancellable,
};
c.gtk.gdk_clipboard_read_text_async(clipboard, cancellable, &onClipboardReadComplete, ctx);
return true;onCloseSurface would then call g_cancellable_cancel(surface_data.clipboard_cancellable) before freeing the surface, ensuring the callback either never fires or detects cancellation via g_cancellable_is_cancelled.
Summary
Commit 1: Action handlers (11 new)
NEW_TAB,NEW_WINDOW(→ new workspace),GOTO_TAB(previous/next/last/direct),CLOSE_TABDESKTOP_NOTIFICATION(→ GNotification),OPEN_URL(→ xdg-open via GLib),TOGGLE_FULLSCREENRING_BELL(visual flash counter + GDK beep fallback),RENDER(queue GL redraw)MOUSE_SHAPE(28 CSS cursor names),MOUSE_VISIBILITY(hide/show)Commit 2: Clipboard support
onReadClipboard: async GDK clipboard read →ghostty_surface_complete_clipboard_requestonWriteClipboard: set GDK clipboard text from terminal selection/OSC 52onConfirmReadClipboard: auto-confirm (dialog can be added later)Brings handled action count from 2 → 13 and clipboard from stub → functional.
Builds on PR #237 which added SET_TITLE, PWD, and onCloseSurface.
Test plan
GOTO_TABwraps correctly at boundaries (previous on first → last, next on last → first)CLOSE_TABprevents closing the last workspaceprintf '\e]9;test notification\e\\'🤖 Generated with Claude Code