diff --git a/docs/subsystems/gui-shell.md b/docs/subsystems/gui-shell.md index 5fd30a15..e64746ed 100644 --- a/docs/subsystems/gui-shell.md +++ b/docs/subsystems/gui-shell.md @@ -121,6 +121,7 @@ axis controls. - The Usage page's cards and the tray panel's Allowances tab share one order, `settings.UsageOrder` (`byUsageOrder`). The panel's *Arrange* (`panelArrange`) moves rows in it and hides subscriptions from that tab alone (`settings.PanelUsageHidden`); both save through `POST /api/usage/arrange`, which changes only the field it is sent, and the Settings save keeps both. Hiding is display only: a menu bar cell's card shows though hidden (`panelPeek`). See `TestUsageArrangePanelHidden` and `panel-arrange.test.cjs`. - The page must work in Chromium (Windows' WebView2) and WebKit (macOS, and WebKitGTK on Linux). GUI tests run in both engines. - On Linux the panel is frameless and the main window has a hidden title bar (`plainTitlebar`). GTK 3 asks KDE's Wayland session (KWin's decoration protocol) for a frame drawn by KWin on any window without client-side decorations, an undecorated one too, so `ownFrame` tells KWin the panel draws its own on each realize (#1283). GTK 4 builds ask for none themselves. +- On GTK 3 builds, the app gives GDK a font DPI before the first webview when it has none (`fontDPI`, [`fontdpi_gtk3.h`](../../internal/gui/fontdpi_gtk3.h)). GDK's unset value is -1, and WebKitGTK 2.54's GTK 3 settings reader multiplies it by 1024 unchecked, so every page lays out at a negative width with a 9000000px font (#1371). An unset DPI takes `gtk-xft-dpi`, or 96, times a valid `GDK_DPI_SCALE`; a positive one is left alone. Wails initialises GTK (its GtkApplication) only inside `Run` and makes each window from a default-idle-priority callback on the main loop, so `fontDPI`, called just before `Run`, queues the check as a `G_PRIORITY_HIGH` idle: it runs on the main thread after GTK's own start, before the first window. `TestFontDPIUnset` opens a real WebKitGTK page through `magpie_font_dpi` and needs a display (Xvfb). - Tests never touch a live agent config. The package's `TestMain` runs under `testenv`'s home of its own. A macOS test that shows windows runs them in a process of its own through `runAppKit` ([`appkit_darwin_test.go`](../../internal/gui/appkit_darwin_test.go)). AppKit and WebKit take their home from the account, not from HOME, so `runAppKit` gives them a temporary one with `CFFIXED_USER_HOME`; appearance, contrast and languages still come from the account. The test fails when that process leaves a folder in the real `~/Library/WebKit` or `~/Library/Caches`. Playwright tests serve `assets/` with isolated `/api` fixtures. - `POST /api/settings/codex-auto-review` accepts an empty value (Codex's own choice) or an exact model/group ID in `provider.Served`, including unlisted providers. A known provider with an unknown or empty model name is rejected without changing the saved reviewer or catalog tag. Generic gateway request resolution remains permissive; it is not the validator for this setting. See `Handler` in [`api.go`](../../internal/gui/api.go) and `TestCodexAutoReviewSetting` in [`codex_auto_review_test.go`](../../internal/gui/codex_auto_review_test.go). diff --git a/internal/gui/app.go b/internal/gui/app.go index 74ae4b55..17e7dfbc 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -513,6 +513,9 @@ func Run(version string, showMain bool, link string) error { h.Import(u) } }) + // GTK 3 with no font DPI set would lay every page out at a negative + // width (#1371); give it one before the first webview is made. + fontDPI() return h.app.Run() } diff --git a/internal/gui/fontdpi_gtk3.go b/internal/gui/fontdpi_gtk3.go new file mode 100644 index 00000000..5817fe81 --- /dev/null +++ b/internal/gui/fontdpi_gtk3.go @@ -0,0 +1,17 @@ +//go:build linux && cgo && gtk3 && !nogui + +package gui + +/* +#cgo pkg-config: gtk+-3.0 +#include "fontdpi_gtk3.h" +*/ +import "C" + +// fontDPI gives GTK 3 a font resolution before WebKitGTK makes the first +// webview, when GDK has none (#1371, see magpie_font_dpi). Wails makes its +// GtkApplication, and so initialises GTK, only in Run, and makes the +// windows from the main loop; this queues the check to run first on that +// loop, so GTK's own start is left as it was. A user's own positive DPI is +// kept. +func fontDPI() { C.magpie_font_dpi_first() } diff --git a/internal/gui/fontdpi_gtk3.h b/internal/gui/fontdpi_gtk3.h new file mode 100644 index 00000000..bcfffee9 --- /dev/null +++ b/internal/gui/fontdpi_gtk3.h @@ -0,0 +1,52 @@ +// fontdpi_gtk3.h: the font DPI guard for GTK 3 builds, in C so that the +// native probe in testdata compiles the same code the app runs. +#include +#include +#include + +// magpie_font_dpi gives GDK a font resolution when it has none. GDK says +// -1 for an unset one, and WebKitGTK 2.54.1's GTK 3 settings reader +// (SystemSettingsManagerProxy::xftDPI) multiplies it by 1024 without a +// check: its pages then lay out with a viewport of -91008px and a body +// font of 9000000px (#1371). A positive resolution is left as it is. An +// unset one takes the GTK setting gtk-xft-dpi, or 96, times a valid +// GDK_DPI_SCALE, as GTK itself does with a positive gtk-xft-dpi. +// Returns -1 when GTK has no display, 0 when the resolution was kept, +// 1 when it was set. +static int magpie_font_dpi(void) { + if (!gtk_init_check(NULL, NULL)) + return -1; + GdkScreen *screen = gdk_screen_get_default(); + if (screen == NULL) + return -1; + double dpi = gdk_screen_get_resolution(screen); + if (isfinite(dpi) && dpi > 0) + return 0; + gint xft = -1; + g_object_get(gtk_settings_get_default(), "gtk-xft-dpi", &xft, NULL); + dpi = xft > 0 ? xft / 1024.0 : 96.0; + const char *env = g_getenv("GDK_DPI_SCALE"); + if (env != NULL) { + char *end; + double scale = g_ascii_strtod(env, &end); + if (end != env && *end == '\0' && isfinite(scale) && scale > 0 && isfinite(dpi * scale)) + dpi *= scale; + } + gdk_screen_set_resolution(screen, dpi); + return 1; +} + +static gboolean magpie_font_dpi_idle(gpointer data) { + if (magpie_font_dpi() == 1) + g_message("magpie: GTK has no font DPI set; using %g (#1371)", + gdk_screen_get_resolution(gdk_screen_get_default())); + return G_SOURCE_REMOVE; +} + +// magpie_font_dpi_first runs magpie_font_dpi as the GTK main loop starts. +// GtkApplication has initialised GTK by then, with the program name Wails +// set, and Wails makes each window from an idle callback of default idle +// priority; one of G_PRIORITY_HIGH runs before all of them. +static void magpie_font_dpi_first(void) { + g_idle_add_full(G_PRIORITY_HIGH, magpie_font_dpi_idle, NULL, NULL); +} diff --git a/internal/gui/fontdpi_gtk3_test.go b/internal/gui/fontdpi_gtk3_test.go new file mode 100644 index 00000000..812fe3b7 --- /dev/null +++ b/internal/gui/fontdpi_gtk3_test.go @@ -0,0 +1,75 @@ +//go:build linux && cgo && gtk3 && !nogui + +package gui + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestFontDPIUnset builds testdata/fontdpi_probe.c with the guard the app +// runs (fontdpi_gtk3.h) and opens a real WebKitGTK page with GDK's font +// DPI unset, as on #1371's NixOS/Niri desktop. Without the guard WebKitGTK +// 2.54.1 lays it out at viewport -40320 and font 9000000px. A positive DPI +// is the user's own and is kept. It needs a display (Xvfb will do). +func TestFontDPIUnset(t *testing.T) { + if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { + t.Skip("needs a display") + } + flags, err := exec.Command("pkg-config", "--cflags", "--libs", "gtk+-3.0", "webkit2gtk-4.1").Output() + if err != nil { + t.Skip("no GTK 3 / WebKitGTK 4.1 headers:", err) + } + here, _ := filepath.Abs(".") + bin := filepath.Join(t.TempDir(), "probe") + args := append([]string{"testdata/fontdpi_probe.c", "-DMAGPIE_FONT_DPI", "-I" + here, "-o", bin}, strings.Fields(string(flags))...) + if out, err := exec.Command("cc", args...).CombinedOutput(); err != nil { + t.Fatalf("build probe: %v\n%s", err, out) + } + home := t.TempDir() + for _, c := range []struct { + name, dpi, scale string + want float64 + }{ + {"unset", "-1", "", 96}, + {"unset, font scale", "-1", "1.25", 120}, + {"unset, bad font scale", "-1", "nan", 96}, + {"the user's own", "144", "", 144}, + } { + t.Run(c.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, c.dpi) + cmd.Env = append(os.Environ(), "HOME="+home, "XDG_CONFIG_HOME="+filepath.Join(home, ".config"), + "XDG_CACHE_HOME="+filepath.Join(home, ".cache"), "XDG_DATA_HOME="+filepath.Join(home, ".local/share"), + "GSETTINGS_BACKEND=memory", "GDK_DPI_SCALE="+c.scale, "WEBKIT_DISABLE_COMPOSITING_MODE=1") + out, err := cmd.Output() + if err != nil { + t.Fatalf("probe: %v\n%s", err, out) + } + var dpi, viewport, font float64 + var allocated int + for _, l := range strings.Split(string(out), "\n") { + if strings.HasPrefix(l, "layout ") { + fmt.Sscanf(l, "layout dpi=%g allocated=%d viewport=%g font=%g", &dpi, &allocated, &viewport, &font) + } + } + t.Logf("%s", out) + if dpi != c.want { + t.Errorf("font DPI %g, want %g", dpi, c.want) + } + if viewport <= 0 || font != 16 { + t.Errorf("page laid out at viewport %g, body font %gpx; want a positive viewport and 16px", viewport, font) + } + if want := float64(allocated) * 96 / c.want; viewport < want-2 || viewport > want+2 { + t.Errorf("viewport %g, want %g for %dpx at %g DPI", viewport, want, allocated, c.want) + } + }) + } +} diff --git a/internal/gui/fontdpi_other.go b/internal/gui/fontdpi_other.go new file mode 100644 index 00000000..971265c4 --- /dev/null +++ b/internal/gui/fontdpi_other.go @@ -0,0 +1,6 @@ +//go:build !linux || !cgo || !gtk3 || nogui + +package gui + +// fontDPI: only GTK 3's WebKitGTK reads GDK's font resolution (#1371). +func fontDPI() {} diff --git a/internal/gui/testdata/fontdpi_probe.c b/internal/gui/testdata/fontdpi_probe.c new file mode 100644 index 00000000..7fb4019d --- /dev/null +++ b/internal/gui/testdata/fontdpi_probe.c @@ -0,0 +1,67 @@ +// fontdpi_probe opens a GTK 3 window with a WebKitGTK page and prints what +// the page measures. argv[1] is the font DPI GDK is left with before the +// first webview (-1 is GDK's unset value, #1371). Built with +// -DMAGPIE_FONT_DPI it runs magpie's guard first, as the app does. +#include +#include +#include +#include +#ifdef MAGPIE_FONT_DPI +#include "fontdpi_gtk3.h" +#endif + +static GMainLoop *loop; +static int status = 1; + +static void measured(GObject *view, GAsyncResult *res, gpointer data) { + GError *err = NULL; + JSCValue *v = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(view), res, &err); + if (v == NULL) { + fprintf(stderr, "js: %s\n", err ? err->message : "no result"); + } else { + char *s = jsc_value_to_string(v); + printf("layout dpi=%g allocated=%d %s\n", gdk_screen_get_resolution(gdk_screen_get_default()), + gtk_widget_get_allocated_width(GTK_WIDGET(view)), s); + g_free(s); + g_object_unref(v); + status = 0; + } + g_main_loop_quit(loop); +} + +static void loaded(WebKitWebView *view, WebKitLoadEvent ev, gpointer data) { + if (ev == WEBKIT_LOAD_FINISHED) + webkit_web_view_evaluate_javascript(view, + "'viewport=' + innerWidth + ' font=' + parseFloat(getComputedStyle(document.body).fontSize)", + -1, NULL, NULL, NULL, measured, NULL); +} + +static gboolean timeout(gpointer data) { + fprintf(stderr, "timed out\n"); + g_main_loop_quit(loop); + return G_SOURCE_REMOVE; +} + +int main(int argc, char **argv) { + if (argc != 2) + return 2; + if (!gtk_init_check(NULL, NULL)) + return 3; + printf("initial dpi=%g\n", gdk_screen_get_resolution(gdk_screen_get_default())); + gdk_screen_set_resolution(gdk_screen_get_default(), g_ascii_strtod(argv[1], NULL)); +#ifdef MAGPIE_FONT_DPI + printf("guard=%d\n", magpie_font_dpi()); +#endif + loop = g_main_loop_new(NULL, FALSE); + GtkWidget *w = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_default_size(GTK_WINDOW(w), 420, 520); + GtkWidget *view = webkit_web_view_new(); + gtk_container_add(GTK_CONTAINER(w), view); + g_signal_connect(view, "load-changed", G_CALLBACK(loaded), NULL); + gtk_widget_show_all(w); + webkit_web_view_load_html(WEBKIT_WEB_VIEW(view), + "

magpie

", NULL); + g_timeout_add_seconds(20, timeout, NULL); + g_main_loop_run(loop); + return status; +}