Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/scripts/package-linux-deb.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Priority: optional
Architecture: ${ARCH}
Maintainer: liuzaisen <[email protected]>
Installed-Size: ${installed_size}
Depends: libgtk-3-0, libwebkit2gtk-4.1-0
Depends: libgtk-3-0, libwebkit2gtk-4.1-0, libxext6
Description: AT Term desktop app
Cross-platform terminal emulator with attachable synced sessions.
CONTROL
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ go run ./cmd/atterm-relay --addr 127.0.0.1:8080 --dev-insecure # 终端 1
cd desktop && wails dev -tags webkit2_41 # 终端 2(macOS/Windows 省略 -tags)
```

依赖 Go 1.23+ / Node 20+ / Wails v2.12.0。Linux 需 `libgtk-3-dev libwebkit2gtk-4.1-dev`。
依赖 Go 1.23+ / Node 20+ / Wails v2.12.0。Linux 需
`libgtk-3-dev libwebkit2gtk-4.1-dev libxext-dev`。

---

Expand Down
1 change: 1 addition & 0 deletions desktop/display_name_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func TestInstallersUseATTermNameAndIcons(t *testing.T) {
`Exec=AT-Term`,
`Icon=AT-Term`,
`/usr/share/icons/hicolor/1024x1024/apps/AT-Term.png`,
`libxext6`,
},
"../.github/scripts/package-macos-dmg.sh": {
`AT Term.app`,
Expand Down
191 changes: 174 additions & 17 deletions desktop/widget_window_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,156 @@
package main

/*
#cgo pkg-config: gtk+-3.0
#cgo pkg-config: gtk+-3.0 xext
#include <gtk/gtk.h>
#include <gdk/gdkx.h>
#include <X11/Xatom.h>
#include <X11/extensions/sync.h>

typedef struct {
GWeakRef widget;
guint odd_samples;
} AttermWidgetFrameWatch;

typedef struct {
GtkWidget *widget;
gint x;
gint y;
gint width;
gint height;
} AttermWidgetRecovery;

static gboolean atterm_widget_clear_attention(gpointer data) {
GtkWidget *widget = GTK_WIDGET(data);
gtk_window_set_urgency_hint(GTK_WINDOW(widget), FALSE);
GdkWindow *window = gtk_widget_get_window(widget);
if (window == NULL || !GDK_IS_X11_WINDOW(window))
return G_SOURCE_REMOVE;

Display *display = GDK_WINDOW_XDISPLAY(window);
XEvent event = {0};
event.xclient.type = ClientMessage;
event.xclient.window = GDK_WINDOW_XID(window);
event.xclient.message_type = XInternAtom(display, "_NET_WM_STATE", False);
event.xclient.format = 32;
event.xclient.data.l[0] = 0; // _NET_WM_STATE_REMOVE
event.xclient.data.l[1] = XInternAtom(display,
"_NET_WM_STATE_DEMANDS_ATTENTION", False);
event.xclient.data.l[3] = 1; // application-originated request
XSendEvent(display, DefaultRootWindow(display), False,
SubstructureRedirectMask | SubstructureNotifyMask, &event);
XFlush(display);
return G_SOURCE_REMOVE;
}

static gboolean atterm_widget_show_after_stall(gpointer data) {
AttermWidgetRecovery *recovery = data;
GtkWindow *gtk_window = GTK_WINDOW(recovery->widget);
gtk_window_move(gtk_window, recovery->x, recovery->y);
gtk_window_resize(gtk_window, recovery->width, recovery->height);
gtk_widget_show(recovery->widget);
// Mutter may treat a background remap as a request for attention and may
// choose a fresh placement. This recovery is invisible maintenance, so
// restore the user's geometry and clear that hint after mapping too.
gtk_window_move(gtk_window, recovery->x, recovery->y);
gtk_window_set_urgency_hint(gtk_window, FALSE);
// Mutter adds DEMANDS_ATTENTION asynchronously after MapNotify when a
// background application remaps a window. Remove it after that WM turn.
g_timeout_add_full(G_PRIORITY_DEFAULT, 250,
atterm_widget_clear_attention, g_object_ref(recovery->widget), g_object_unref);
GdkWindow *window = gtk_widget_get_window(recovery->widget);
if (window != NULL && GDK_IS_X11_WINDOW(window))
gdk_x11_window_set_frame_sync_enabled(window, FALSE);
return G_SOURCE_REMOVE;
}

static void atterm_widget_recovery_free(gpointer data) {
AttermWidgetRecovery *recovery = data;
g_object_unref(recovery->widget);
g_free(recovery);
}

static gboolean atterm_widget_extended_counter_is_odd(GdkWindow *window,
gboolean *is_odd) {
Display *display = GDK_WINDOW_XDISPLAY(window);
Atom property = XInternAtom(display, "_NET_WM_SYNC_REQUEST_COUNTER", False);
Atom actual_type = None;
int actual_format = 0;
unsigned long item_count = 0;
unsigned long bytes_after = 0;
unsigned char *raw = NULL;

int status = XGetWindowProperty(display, GDK_WINDOW_XID(window), property,
0, 2, False, XA_CARDINAL, &actual_type, &actual_format,
&item_count, &bytes_after, &raw);
if (status != Success || actual_type != XA_CARDINAL ||
actual_format != 32 || item_count < 2 || raw == NULL) {
if (raw != NULL)
XFree(raw);
return FALSE;
}

XSyncCounter extended = (XSyncCounter)((unsigned long *)raw)[1];
XFree(raw);
XSyncValue value;
if (!XSyncQueryCounter(display, extended, &value))
return FALSE;
*is_odd = (XSyncValueLow32(value) & 1U) != 0;
return TRUE;
}

static gboolean atterm_widget_watch_frames(gpointer data) {
AttermWidgetFrameWatch *watch = data;
GtkWidget *widget = g_weak_ref_get(&watch->widget);
if (widget == NULL)
return G_SOURCE_REMOVE;

GdkWindow *window = gtk_widget_get_window(widget);
gboolean odd = FALSE;
if (!gtk_widget_get_mapped(widget) || window == NULL ||
!GDK_IS_X11_WINDOW(window) ||
!atterm_widget_extended_counter_is_odd(window, &odd)) {
watch->odd_samples = 0;
g_object_unref(widget);
return G_SOURCE_CONTINUE;
}

watch->odd_samples = odd ? watch->odd_samples + 1 : 0;
if (watch->odd_samples >= 3) {
watch->odd_samples = 0;
AttermWidgetRecovery *recovery = g_new0(AttermWidgetRecovery, 1);
recovery->widget = g_object_ref(widget);
gtk_window_get_position(GTK_WINDOW(widget), &recovery->x, &recovery->y);
gtk_window_get_size(GTK_WINDOW(widget), &recovery->width, &recovery->height);
// GTK clears frame_pending and thaws the frame clock from its
// UnmapNotify handler. Showing on a later main-loop turn ensures the
// unmap is observed before another frame is requested.
gtk_widget_hide(widget);
g_timeout_add_full(G_PRIORITY_DEFAULT, 100,
atterm_widget_show_after_stall, recovery, atterm_widget_recovery_free);
}

g_object_unref(widget);
return G_SOURCE_CONTINUE;
}

static void atterm_widget_frame_watch_free(gpointer data) {
AttermWidgetFrameWatch *watch = data;
g_weak_ref_clear(&watch->widget);
g_free(watch);
}

static void atterm_widget_ensure_frame_watch(GtkWidget *widget) {
static const char *watch_key = "atterm-widget-frame-watch";
if (g_object_get_data(G_OBJECT(widget), watch_key) != NULL)
return;

AttermWidgetFrameWatch *watch = g_new0(AttermWidgetFrameWatch, 1);
g_weak_ref_init(&watch->widget, G_OBJECT(widget));
g_object_set_data(G_OBJECT(widget), watch_key, GINT_TO_POINTER(1));
g_timeout_add_seconds_full(G_PRIORITY_DEFAULT, 1,
atterm_widget_watch_frames, watch, atterm_widget_frame_watch_free);
}

static gboolean atterm_widget_disable_frame_sync(gpointer data) {
guint *attempts = data;
Expand All @@ -14,9 +161,13 @@ static gboolean atterm_widget_disable_frame_sync(gpointer data) {
gboolean applied = FALSE;
for (GList *item = windows; item != NULL; item = item->next) {
GtkWidget *widget = GTK_WIDGET(item->data);
if (!GTK_IS_WINDOW(widget) ||
g_strcmp0(gtk_window_get_title(GTK_WINDOW(widget)), "AT Term Widget") != 0)
continue;
GdkWindow *window = gtk_widget_get_window(widget);
if (window != NULL && GDK_IS_X11_WINDOW(window)) {
gdk_x11_window_set_frame_sync_enabled(window, FALSE);
atterm_widget_ensure_frame_watch(widget);
applied = TRUE;
}
}
Expand All @@ -29,14 +180,19 @@ static gboolean atterm_widget_disable_frame_sync(gpointer data) {
g_free(attempts);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
// Keep every retry one-shot. A high-priority idle source that returned
// CONTINUE here would spin and starve the GTK realization it is waiting for.
g_timeout_add_full(G_PRIORITY_DEFAULT, 10,
atterm_widget_disable_frame_sync, attempts, NULL);
return G_SOURCE_REMOVE;
}

static void atterm_widget_schedule_disable_frame_sync(void) {
// An idle callback can spin if the window has not been realized yet. A
// short timeout retries without consuming a core and is cancelled as soon
// as the first X11 toplevel is updated.
g_timeout_add(50, atterm_widget_disable_frame_sync, g_new0(guint, 1));
// Run before GTK's redraw idles. Disabling after the first synchronized frame
// is already pending only prevents the next freeze; it does not thaw the
// frame clock that is currently waiting for Mutter's acknowledgement.
g_idle_add_full(G_PRIORITY_DEFAULT, atterm_widget_disable_frame_sync,
g_new0(guint, 1), NULL);
}
*/
import "C"
Expand Down Expand Up @@ -65,17 +221,18 @@ func applyWidgetPlatformOptions(opts *options.App) {
}
}

// applyWidgetPostStartup intentionally does nothing on Linux. The GTK window
// may not be realized when Wails invokes OnStartup, so the X11 frame-sync
// operation is deferred to applyWidgetPostReady below.
func applyWidgetPostStartup(_ context.Context) {}

// applyWidgetPostReady runs after the frontend has mounted. Wails invokes the
// bound method on a Go goroutine, so GTK work is queued onto the main loop.
// Mutter's X11 frame-sync
// protocol can stop acknowledging a window after its first frame; GTK then
// keeps the process responsive but suppresses every subsequent draw. The
// widget does not need compositor pacing, so opt its sole toplevel out.
// applyWidgetPostStartup starts the bounded realization retry before the hidden
// webview paints its first frame. It also installs the XSync watchdog that
// recovers the runtime stall where Mutter leaves a frame's extended update
// counter odd indefinitely.
func applyWidgetPostStartup(_ context.Context) {
C.atterm_widget_schedule_disable_frame_sync()
}

// applyWidgetPostReady is a second bounded attempt after the frontend has
// mounted. It covers unusual startup ordering where OnStartup's retry budget
// expires before Wails realizes the native window. Wails invokes both hooks on
// Go goroutines, so GTK work is always queued onto the main loop.
func applyWidgetPostReady(_ context.Context) {
C.atterm_widget_schedule_disable_frame_sync()
}
21 changes: 16 additions & 5 deletions docs/superpowers/specs/2026-08-10-desk-widget-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,22 @@ Wails IPC 和 WebKit DOM 此时都仍然存活,所以新的状态事件不会
2. 不含 WebKit 的纯 GTK label 程序也复现:GLib tick 持续,`draw` 只在启动时发生。
3. 同一程序在无 Mutter 的 Xephyr 中持续出帧。
4. 只关闭该窗口的 GDK X11 frame sync 后,宿主会话中每个 tick 都重新触发 `draw`。

修复位于 `desktop/widget_window_linux.go::applyWidgetPostStartup`。Wails 从 Go goroutine 调
`OnStartup`,因此必须用 `g_idle_add` 把 GTK 操作派发到主循环;挂件是独立子进程,只枚举并
修改该进程的唯一顶层窗口,不影响 AT Term 主窗口。不要用定时重启、WebKit reload、GPU flag
或强制 queue-draw 掩盖:frame clock 仍在等待时,这些操作都不能完成新帧。
5. 长时间运行后再次冻结的现场里,`_NET_WM_SYNC_REQUEST_COUNTER` 的 extended counter 停在
奇数且不再变化;同进程主窗口的 counter 持续以偶数递增。对挂件做一次 unmap/remap 后,
counter 立即恢复递增且动画继续,证明进程、stdin、Vue 都没死,卡住的是单帧生命周期。

修复位于 `desktop/widget_window_linux.go::applyWidgetPostStartup` + `applyWidgetPostReady`。
Wails 从 Go goroutine 调这两个 hook,因此必须把 GTK 操作派发到主循环;挂件是独立子进程,
只修改标题为 `AT Term Widget` 的顶层窗口,不影响 AT Term 主窗口。`OnStartup` 的有界重试是
主路径,必须赶在隐藏 WebView 的首帧前关闭同步:`gdk_x11_window_set_frame_sync_enabled(FALSE)`
只影响后续帧,不能解冻已经在等 `_NET_WM_FRAME_DRAWN` 的 frame clock。`Ready` 再跑一次仅作
native window 延迟 realization 的兜底。运行期另有 XSync watchdog 每秒读取 extended counter;
正常帧的奇数阶段只持续数毫秒,只有连续 3 次采样仍为奇数才执行一次 GTK hide/show。GTK 的
`UnmapNotify` 路径会清 `frame_pending` 并 thaw frame clock,下一 main-loop turn 映射窗口前再
关闭 frame sync。恢复前必须保存窗口位置和尺寸、remap 后还原并清 urgency hint;否则 Mutter
可能把挂件重新摆放并加 `_NET_WM_STATE_DEMANDS_ATTENTION`。后者由 Mutter 在 MapNotify 后异步
添加,必须延后一轮通过标准 EWMH `_NET_WM_STATE_REMOVE` 消息清掉。不要用定时重启、WebKit
reload、GPU flag 或单纯 queue-draw 掩盖:frame clock 仍在等待时,这些操作都不能完成新帧。

## Interaction

Expand Down
Loading