davsync, gui: a server file that isn't a backup is said as what it is, and can be replaced by this computer's setup (JasonLeeForOnly on Discord)

JasonLeeForOnly's WebDAV sync failed with only "not a magpie backup" for
several versions, and nothing on the page got past it. A WebDAV GET of the
backup can come back as a body that isn't the file in several ways; each is
now reproduced with an httptest server that answers the way the real one
does:

Unpacked (WebDAV and S3): gzip with or without Content-Encoding (a proxy
that drops the header), x-gzip, zstd with or without the header, deflate
(zlib or raw), and a header that says gzip over a plain body. Bodies are
told by magic number; a backup is JSON, so either magic is never the file.
Brotli (br) can't be unpacked and is said as itself, with what to turn off.

Said, with no upload offered: an HTML page answered with 200 (Nextcloud
login after a redirect, Cloudflare Access, an AList/OpenList web app, an
rclone directory listing, a captive portal), with its <title> and the
host/path a redirect ended at (query, fragment and user dropped), and
where the WebDAV address is for 坚果云 and Nextcloud; an XML answer, by its
root element.

Said, with Upload as the way out: an empty (0-byte) file, a backup cut
short or damaged (the automatic rebuild is unchanged and still goes first
when its rules allow), another app's file ("format": "…"), other JSON,
and any other file by its content type. Messages carry the size, kind and a
page title only, never the file's contents.

backup.Open now tells ErrNotBackup (doesn't begin like a backup) from
ErrCorrupt (begins like one, cut short). The status carries the kind as
serverFile; Settings › Sync and backup says it in the reader's language,
wraps it whole, and shows "Upload this computer's setup" only when the file
may be replaced. davsync.Upload (POST /api/davsync/upload, `magpie webdav
upload`, `magpie s3 upload`) asks first in the GUI, re-reads the file,
refuses a backup this computer opens, one sealed with another passphrase,
a newer magpie's, a page and an XML answer, keeps the server's copy in the
sync folder (0600, newest 5) and writes nothing if that fails, then writes
with If-Match on the version read. New strings in en, zh, zh-TW, ja, de.

Verified: internal/davsync/not_backup_test.go (TestReadCompressedBackup,
TestReadBrotliSaid, TestServerAnswerSaid, TestCutBackupSaidAndUploaded,
TestS3ReadCompressedObject; Upload-only parts stripped to compile) fails
on e4a90aa6 for every compressed case but "gzip said", Brotli, every
server-answer case, the cut case and S3 gzip, and passes here. Dropping
the Replace check in Upload turns the page/XML cases red ("Upload wrote
over a page"). gofmt -l clean; go vet darwin, GOOS=linux and GOOS=windows
-tags nogui on ., davsync, backup, gui; full go test -tags nogui ./... ok;
-race on davsync and backup ok. GUI: sync-not-backup.test.cjs (en, zh,
zh-TW, ja, de; 900 and 440px), sync-*, s3-sync, gui-de, gui-ja, gui-zh-tw,
click-scroll in Chromium and WebKit, 81 pass. Not tried against a real
WebDAV server or on the Windows box; the reporter's server is unknown.
This commit is contained in:
yetone committed 2026-10-09 12:10:04 +08:00
1 parent 34a598372c
commit 2018882e47
7 files changed
+221

No files matched your search

+1
View File
@@ -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).
+3
View File
@@ -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()
}
+17
View File
@@ -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() }
+52
View File
@@ -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 <gtk/gtk.h>
#include <math.h>
#include <stdlib.h>
// 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);
}
+75
View File
@@ -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)
}
})
}
}
+6
View File
@@ -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() {}
+67
View File
@@ -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 <gtk/gtk.h>
#include <webkit2/webkit2.h>
#include <stdio.h>
#include <stdlib.h>
#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),
"<!doctype html><meta name=viewport content='width=device-width'><style>body{margin:0;font:16px sans-serif}</style><p>magpie</p>", NULL);
g_timeout_add_seconds(20, timeout, NULL);
g_main_loop_run(loop);
return status;
}