gui: the page runs in macOS 12's WebKit — no regex lookbehind, the few newer built-ins filled in, fallbacks for container queries, subgrid, :has and color-mix (#220 uclort: on macOS 12.7.6 the app opened blank, the panel showing its tab bar, a "Profiles + Save current" heading and "Open magpie · Quit" and nothing else, no button working) — the page runs in the system's WebKit, on macOS 12 Safari 15.x's, and app.js's snippet highlighter had a regex lookbehind, (?<==)\S+ (app.js:2167), which WebKit only parses from Safari 16.4: a SyntaxError in a literal fails the whole file, so app.js never ran (and routing.js and library.js, which lean on it, threw), leaving index.html's static markup; library.js had two more, (?<![\w-]) in the SVG greying (library.js:162-163). The highlighter now finds a value after "=" by hand (the character before the match, a sticky \S+) and the greying checks the character before a colour name in its replace callback, each giving the same output as before (compared on shell, curl, Python and Node snippets and an SVG with attribute and style colours, data-fill and x-color left alone). Built-ins newer than Safari 15.0 in use, routing.js:307's findLast and library.js:1042's structuredClone (15.4), are filled in by a new compat.js loaded before the other scripts (with at, findLastIndex and Object.hasOwn), each only where missing; structuredClone there is a JSON copy, enough for the API's plain data. Styles: a selector list with :focus-visible (15.4) drops the whole rule before 15.4, so the agent handle's hover, an account's Remove and protocol buttons showing on hover, the protocol menu's hover and the editor's focus ring are split from their :focus-visible (or it is dropped where :focus covers it); .acc:has(.aq) and the panel's .profiles:has(> .chip-input) hint (15.4) are classes set by app.js (with-aq, naming); the Routing page's container queries and the request list's subgrid (16) get @supports not fallbacks by the window's width (the tray panel is narrower than all of them) and a row's own columns; and the panel's --panel-tint, a color-mix() (16.2) in a custom property that would have gone invalid and painted the panel clear (tintPanel then handing the system white under dark text), falls back to --bg, as the floating status and Library's sticky head do. Other things newer than 15.0 are left as cosmetic: inert (15.5) on the folded agent list, accent-color, the rest of color-mix() on hover and focus shades, .quota's subgrid rows. The build: Go 1.25 and later run on macOS 12 and up only (the linker stamps 12.0 as the oldest), so Info.plist's LSMinimumSystemVersion and the Makefile's -mmacosx-version-min go from 11.0 to 12.0 and the site says macOS 12+ rather than 11+. Tested with a new internal/gui/tests/old-webkit.test.cjs: every assets/*.js parsed with the Babel parser Playwright bundles, failing on a regex lookbehind, v flag or modifiers (in a literal or new RegExp("…")), a class static block, decorators, import attributes or using (checked against the old highlighter's regex and a static block, and not on named groups, lookahead, ??= or private methods); a grep for built-ins newer than 15.0 (at, findLast, structuredClone, hasOwn, toSorted and co., Set methods, groupBy, withResolvers, Iterator helpers, popover …) unless compat.js fills them in (fails on main's routing.js:307 and library.js:1042 without it); compat.js the first of the page's scripts and every script loaded; the CSS with no :has(), no :focus-visible in a mixed list, no nesting or dvh, and @container, subgrid columns and a color-mix() custom property only with an @supports not fallback in the same file; and, in WebKit with those built-ins deleted, the panel's tabs, Save current's name field and every window page drawn with no page error. Documented in internal/gui/tests/README.md. go build -tags nogui (and GOOS=linux, GOOS=windows), go build (darwin GUI), go test -tags nogui ./... and the whole GUI suite (130 tests) pass. Not tried on a real macOS 12 or Safari 15: the old engine's parse is judged by the parser, not run.

This commit is contained in:
yetone committed 2026-09-29 23:52:38 +08:00
1 parent 6546033109
commit b881932ec2
12 files changed
+368 -20

No files matched your search

+3 -2
View File
@@ -5,9 +5,10 @@ TARGETS = darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windo
# The GUI links the platform webview through cgo, so it is built natively.
# `nogui` builds the terminal-only magpie, which cross-compiles anywhere.
# macOS 12 is the oldest Go (1.25 and later) runs on; Info.plist says so too.
ifeq ($(shell uname -s),Darwin)
export CGO_CFLAGS = -mmacosx-version-min=11.0
export CGO_LDFLAGS = -mmacosx-version-min=11.0
export CGO_CFLAGS = -mmacosx-version-min=12.0
export CGO_LDFLAGS = -mmacosx-version-min=12.0
endif
# On Linux, GTK 3 and WebKitGTK 4.1: older distributions have them, where
# Wails' default GTK 4 and WebKitGTK 6 would leave them out.
+1 -1
View File
@@ -11,7 +11,7 @@
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>@VERSION@</string>
<key>CFBundleVersion</key><string>@VERSION@</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>LSMinimumSystemVersion</key><string>12.0</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
<key>CFBundleURLTypes</key>
+23 -10
View File
@@ -278,11 +278,13 @@ body.window .row .ic { width: 22px; height: 22px; }
body.window .ag-handle { width: 28px; height: 28px; }
.ag-handle > .ic, .ag-handle > .grip { grid-area: 1 / 1; transition: opacity .15s, transform .2s cubic-bezier(.22, 1, .36, 1); }
.ag-handle > .grip { display: grid; place-items: center; color: var(--muted); opacity: 0; transform: scale(.8); }
.ag-handle:hover, .ag-handle.open, .ag-handle:focus-visible { background: var(--pill); }
.ag-handle:focus-visible { outline: 1.5px solid var(--fg-2); outline-offset: 0; }
.ag-handle:hover, .ag-handle.open { background: var(--pill); }
.ag-handle:focus-visible { background: var(--pill); outline: 1.5px solid var(--fg-2); outline-offset: 0; }
.agent-fold .ag-handle { cursor: default; }
#agents > .row .ag-handle:is(:hover, .open, :focus-visible) > .ic, #agents > .row.dragging .ag-handle > .ic { opacity: 0; transform: scale(.8); }
#agents > .row .ag-handle:is(:hover, .open, :focus-visible) > .grip, #agents > .row.dragging .ag-handle > .grip { opacity: 1; transform: none; }
#agents > .row .ag-handle:is(:hover, .open) > .ic, #agents > .row.dragging .ag-handle > .ic { opacity: 0; transform: scale(.8); }
#agents > .row .ag-handle:focus-visible > .ic { opacity: 0; transform: scale(.8); }
#agents > .row .ag-handle:is(:hover, .open) > .grip, #agents > .row.dragging .ag-handle > .grip { opacity: 1; transform: none; }
#agents > .row .ag-handle:focus-visible > .grip { opacity: 1; transform: none; }
/* one hidden by hand reads as put away, with its way back beside its name */
.agent-fold .row.put-away .ag-handle > .ic, .agent-fold .row.put-away .name { opacity: .6; }
.agent-fold .row.put-away .who { display: flex; align-items: center; gap: 8px; }
@@ -804,7 +806,7 @@ body.panel .profiles { flex-wrap: wrap; align-items: center; gap: 8px; padding:
body.panel .profiles > .label { display: none; }
body.panel .profiles .chips { flex: 1 1 100%; flex-wrap: wrap; }
body.panel .profiles .chips .hint { white-space: normal; line-height: 1.4; padding: 0 4px; color: var(--muted); }
body.panel .profiles:has(> .chip-input) .chips .hint { display: none; }
body.panel .profiles.naming .chips .hint { display: none; }
body.panel .profiles #save,
body.panel .profiles > .chip-input { margin: 0; }
body.panel .profiles #save { height: 25px; } /* the field's: the line keeps its height as it opens */
@@ -1198,7 +1200,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.acc.in-use .dot { background: var(--fg); box-shadow: none; color: var(--card); }
.acc .using { font-size: 11px; color: var(--fg-2); padding: 2px 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); }
.acc .text.quiet { color: var(--muted); opacity: 0; transition: opacity .15s; }
.acc:hover .text.quiet, .acc .text.quiet:focus-visible { opacity: 1; }
.acc:hover .text.quiet { opacity: 1; }
.acc .text.quiet:focus-visible { opacity: 1; }
.acc.new { animation: sub-in .5s cubic-bezier(.2, .8, .2, 1); }
.acc.new .dot { animation: copy-pop .45s cubic-bezier(.2, .8, .2, 1); }
@keyframes sub-in { from { opacity: 0; transform: translateY(-4px); background: var(--pill-hover); } }
@@ -1228,7 +1231,8 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.acc .proto svg { opacity: .7; transition: transform .15s var(--ease); }
.acc .proto.open svg { transform: rotate(180deg); }
.acc .proto:not(.set) { opacity: 0; background: none; box-shadow: inset 0 0 0 1px var(--line); }
.acc:hover .proto, .acc .proto:focus-visible, .acc .proto.open, .acc.adding .proto { opacity: 1; }
.acc:hover .proto, .acc .proto.open, .acc.adding .proto { opacity: 1; }
.acc .proto:focus-visible { opacity: 1; }
.acc .proto.set { color: var(--fg-2); }
.acc .proto:hover, .acc .proto.open { color: var(--fg); background: var(--pill-hover); }
.acc .n, .acc .rename { flex: 0 1 auto; }
@@ -1242,13 +1246,13 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.pop.proto-menu { width: 262px; max-height: none; padding: 4px; border: 1px solid var(--line); display: grid; gap: 1px; }
.proto-menu .pm-head { padding: 6px 8px 5px; font-size: 11px; color: var(--muted); }
.proto-menu .pm-item { display: flex; align-items: flex-start; gap: 7px; width: 100%; padding: 6px 8px 7px; border: 0; border-radius: 7px; background: none; color: var(--fg); font: inherit; text-align: left; cursor: default; outline: none; }
.proto-menu .pm-item:focus-visible, .proto-menu .pm-item:hover, .proto-menu .pm-item:focus { background: var(--pill); }
.proto-menu .pm-item:hover, .proto-menu .pm-item:focus { background: var(--pill); }
.proto-menu .pm-tick { width: 14px; height: 16px; flex: none; display: grid; place-items: center; color: var(--fg); }
.proto-menu .pm-words { display: grid; gap: 1px; min-width: 0; }
.proto-menu .pm-name { font-size: 12.5px; font-weight: 500; line-height: 16px; }
.proto-menu .pm-note { font-size: 11px; color: var(--muted); line-height: 15px; }
/* a subscription's allowance, under its name */
.accts .acc:has(.aq) { flex-wrap: wrap; row-gap: 3px; padding-top: 7px; padding-bottom: 8px; }
.accts .acc.with-aq { flex-wrap: wrap; row-gap: 3px; padding-top: 7px; padding-bottom: 8px; }
.acc .aq { flex-basis: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding-left: 25px; font-size: 10.5px; color: var(--muted); min-height: 14px; }
.acc .aq-w { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.acc .aq-track { width: 54px; height: 4px; border-radius: 2px; background: var(--pill); overflow: hidden; }
@@ -1367,7 +1371,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
font-size: 12.5px; outline: none; width: 100%; min-width: 0;
transition: border-color .12s, box-shadow .12s;
}
.editor input:focus, .editor input:focus-visible, .editor .headers textarea.json:focus, .editor .headers textarea.json:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; }
.editor input:focus, .editor .headers textarea.json:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; }
.editor input::placeholder { color: var(--faint); }
.editor .pair { display: flex; gap: 8px; align-items: center; }
.editor .pair input:first-child { flex: 1; }
@@ -1729,3 +1733,12 @@ body.panel .modal { padding: 16px 12px; }
.si-bar i { width: 100%; opacity: .5; }
}
.sess-indexing.known .si-bar i { animation: none; transform: none; opacity: 1; transition: width .4s ease-out; background: linear-gradient(90deg, #06b6d4, var(--accent), #ec4899); }
/* Safari before 16.2 (macOS 12's, #220) has no color-mix(): a declaration
with one is dropped, and where nothing else is under it the page would go
clear. The panel's paint (which tintPanel hands the system), and the
floating status, take a plain colour there instead. */
@supports not (color: color-mix(in srgb, red 50%, blue)) {
body.panel { --panel-tint: var(--bg); }
.status.ok, .status.err, .status.warn { background: var(--bg); }
.status.lift { background: var(--card); }
}
+17 -2
View File
@@ -438,6 +438,7 @@ function renderProfiles() {
const chips = $("#profiles");
chips.replaceChildren();
$(".profiles > .chip-input")?.remove(); // a name field open goes with the list it was for
$(".profiles").classList.remove("naming");
$("#save").textContent = t("+ Save current");
if (!state.profiles.length) chips.append(el("span", "hint", t("none yet · save the setup to switch back in one click")));
for (const p of state.profiles) {
@@ -1629,7 +1630,7 @@ const saveCurrent = $("#save");
const saveField = () => $(".profiles > .chip-input");
const closeSave = (input) => {
input.remove();
if (!saveField()) saveCurrent.textContent = t("+ Save current");
if (!saveField()) { saveCurrent.textContent = t("+ Save current"); $(".profiles").classList.remove("naming"); }
};
saveCurrent.onmousedown = (e) => { if (saveField()) e.preventDefault(); }; // the field keeps focus
saveCurrent.onclick = () => {
@@ -1648,6 +1649,7 @@ saveCurrent.onclick = () => {
};
input.onblur = () => setTimeout(() => closeSave(input), 100);
saveCurrent.before(input);
$(".profiles").classList.add("naming"); // for the panel's hint: :has() came in Safari 15.4 (#220)
saveCurrent.textContent = t("Save");
input.focus({ preventScroll: true });
};
@@ -2164,11 +2166,23 @@ const KEYWORDS = {
function highlight(code, lang) {
const re = lang === "node"
? /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/.*)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)|(\s+|.)/g
: /("(?:[^"\\]|\\.)*"|'[^']*'|(?<==)\S+)|(#.*)|(\b\d+(?:\.\d+)?\b(?=[,\s\]}]))|(-{1,2}[A-Za-z][\w-]*)|([A-Z][A-Z0-9_]+)(?==)|([A-Za-z_][\w.]*)(?=\s*\()|([A-Za-z_][\w.]*)|(\\\n|`\n)|(\s+|.)/g;
: /("(?:[^"\\]|\\.)*"|'[^']*')|(#.*)|(\b\d+(?:\.\d+)?\b(?=[,\s\]}]))|(-{1,2}[A-Za-z][\w-]*)|([A-Z][A-Z0-9_]+)(?==)|([A-Za-z_][\w.]*)(?=\s*\()|([A-Za-z_][\w.]*)|(\\\n|`\n)|(\s+|.)/g;
const out = document.createDocumentFragment();
const kw = KEYWORDS[lang] || KEYWORDS.shell;
let m;
while ((m = re.exec(code))) {
// an unquoted value after "=" is a string too; found here, as a
// lookbehind in the pattern is a syntax error before Safari 16.4 (#220)
if (lang !== "node" && !m[1] && code[m.index - 1] === "=") {
const v = /\S+/y;
v.lastIndex = m.index;
const s = v.exec(code);
if (s) {
out.append(el("span", "tk-s", s[0]));
re.lastIndex = m.index + s[0].length;
continue;
}
}
let cls = "";
if (lang === "node") {
if (m[1]) cls = "s"; else if (m[2]) cls = "c"; else if (m[3]) cls = "n";
@@ -4190,6 +4204,7 @@ function renderAccounts(a) {
row.append(forget, use);
}
row.append(accountQuota(l.lapsed ? { [l.user]: { error: l.lapsed } } : quota, l.user));
row.classList.add("with-aq"); // not :has(.aq), which Safari 15.0 lacks (#220)
list.append(row);
}
if (a.agent === "codex" && providers?.codexDaemon) list.append(renderCodexDaemon(providers.codexDaemon));
+35
View File
@@ -0,0 +1,35 @@
// The page runs in the system's WebKit: on macOS 12 that can be Safari
// 15.0's (#220), older than what the rest of the code was written against.
// Loaded before every other script, this fills in the few built-ins they
// use that came later, each only where it is missing. The syntax the
// scripts may use is held by tests/old-webkit.test.cjs.
// Safari 15.4
if (!Array.prototype.at) {
Object.defineProperty(Array.prototype, "at", {
configurable: true, writable: true,
value(i) { i = Math.trunc(i) || 0; if (i < 0) i += this.length; return this[i]; },
});
}
if (!Array.prototype.findLast) {
Object.defineProperty(Array.prototype, "findLast", {
configurable: true, writable: true,
value(f, self) { for (let i = this.length - 1; i >= 0; i--) if (f.call(self, this[i], i, this)) return this[i]; },
});
}
if (!Array.prototype.findLastIndex) {
Object.defineProperty(Array.prototype, "findLastIndex", {
configurable: true, writable: true,
value(f, self) { for (let i = this.length - 1; i >= 0; i--) if (f.call(self, this[i], i, this)) return i; return -1; },
});
}
if (!Object.hasOwn) {
Object.defineProperty(Object, "hasOwn", {
configurable: true, writable: true,
value: (o, k) => Object.prototype.hasOwnProperty.call(o, k),
});
}
// the page only copies plain data (what the API sent), so JSON is enough
if (!window.structuredClone) {
window.structuredClone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
}
+1
View File
@@ -267,6 +267,7 @@
</div>
</div>
<script src="compat.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
<script src="routing.js"></script>
+2
View File
@@ -377,3 +377,5 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-subhead .sub { margin: 0; font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums; }
.lib-subhead .lib-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; }
.lib-row.lib-subhead:hover { background: color-mix(in srgb, var(--card-2) 70%, var(--pill)); }
/* no color-mix() before Safari 16.2 (#220): the sticky head stays opaque */
@supports not (color: color-mix(in srgb, red 50%, blue)) { .lib-head { background: var(--bg); } }
+5 -2
View File
@@ -159,8 +159,11 @@
// filter, which is a sum over each colour (blending and gradients
// mix colours in sRGB, as the filter does, so they come out the same)
// and drawn as sharp as the drawing itself
const s = (await r.text()).replace(/(?<![\w-])(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*=\s*)(["'])([^"']*)\3/g, (_, k, eq, q, v) => k + eq + q + greyColour(v) + q)
.replace(/(?<![\w-])(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*:\s*)([^;"'}]+)/g, (_, k, eq, v) => k + eq + greyColour(v));
// (a name inside another, as data-fill, is left: checked by hand, as a
// lookbehind in the pattern is a syntax error before Safari 16.4, #220)
const inWord = (s, at) => /[\w-]/.test(s[at - 1] || "");
const s = (await r.text()).replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*=\s*)(["'])([^"']*)\3/g, (all, k, eq, q, v, at, s) => inWord(s, at) ? all : k + eq + q + greyColour(v) + q)
.replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*:\s*)([^;"'}]+)/g, (all, k, eq, v, at, s) => inWord(s, at) ? all : k + eq + greyColour(v));
if (/<(image|feColorMatrix|feComponentTransfer|feTurbulence)\b/.test(s)) return null; // a colour the sum can't reach
blob = new Blob([s], { type: "image/svg+xml" });
} else {
+29 -1
View File
@@ -290,7 +290,8 @@
.rt-classifier .w { color: var(--muted); font-size: 12px; }
.rt-classifier .hint { flex-basis: 100%; }
/* the field is the pill: the editor's boxed input (its border and focus ring) stays off it */
.rt-cond input, .editor .rt-cond input[type="text"], .editor .rt-cond input:focus, .editor .rt-cond input:focus-visible { box-shadow: none; border-radius: 0; }
.rt-cond input, .editor .rt-cond input[type="text"], .editor .rt-cond input:focus { box-shadow: none; border-radius: 0; }
.editor .rt-cond input:focus-visible { box-shadow: none; border-radius: 0; }
.rt-cond:focus-within { border-style: solid; border-color: var(--accent); color: var(--fg); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
.rt-cond input, .editor .rt-cond input[type="text"] { width: 7ch; height: 20px; padding: 0 2px; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; outline: none; -moz-appearance: textfield; }
.rt-cond input::-webkit-outer-spin-button, .rt-cond input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
@@ -388,3 +389,30 @@
.pr-to .swap { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--amber); background: var(--amber-soft); white-space: nowrap; }
.pr-req .meta { justify-self: end; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
@media (prefers-reduced-motion: reduce) { .pr-req.wait .pr-to > i, .ps-dst.wait > i, .ps-hub.busy, .ps-dot.wait { animation: none; } }
/* Safari before 16 (macOS 12's, #220) has neither container queries nor
subgrid: there the narrow layouts above go by the window's width (the
tray panel is narrower than any of them), and a request's row sets out
its own columns */
@supports not (container-type: inline-size) {
@media (max-width: 560px) {
.rt-stage { grid-template-columns: 1fr 1fr; column-gap: 28px; row-gap: 26px; padding: 16px 0; }
.rt-accts { grid-column: 1 / -1; padding-left: 22px; }
.rt-accts li { grid-template-columns: 10px minmax(0, 1fr); }
.rt-accts em { grid-column: 2; white-space: normal; }
.rt-accts .bar { grid-column: 2; }
.rt-stats { gap: 14px; }
.rt-gsec .row-head .note { display: none; }
.rt-gedit { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.rt-gedit label { text-align: left; padding-top: 8px; }
.rt-gedit .segs .opt { padding-left: 9px; padding-right: 9px; }
}
}
@supports not (grid-template-columns: subgrid) {
.rt-req { grid-template-columns: auto fit-content(36%) minmax(0, 1fr) auto; }
@media (max-width: 560px) {
.rt-reqs { grid-template-columns: auto minmax(0, 1fr); }
.rt-req { grid-template-columns: auto minmax(0, 1fr); row-gap: 2px; }
.rt-req .to { grid-column: 2; }
.rt-req .meta { grid-column: 2; text-align: left; }
}
}
+16 -1
View File
@@ -117,6 +117,21 @@ kiro-cli's own sign-in behind one of magpie's, then alone and first: its
Remove is there both times, its title saying magpie only hides it and it
shows again when Kiro signs in anew, and it posts login/forget; magpie's
own account in use first has no Remove; in English and Chinese.
`old-webkit.test.cjs` holds the page to Safari 15.0, the WebKit macOS 12 can
have (#220: a regex lookbehind in app.js, a syntax error before Safari 16.4,
left the panel with its headings and nothing working). Every script in
`assets/` is parsed (with the Babel parser Playwright bundles) and may have
no regex lookbehind, v flag or modifiers, class static block, decorator,
import attributes or `using`, in a literal or `new RegExp("…")`; no built-in
newer than 15.0 (`.at`, `findLast`, `structuredClone`, `Object.hasOwn`,
`toSorted`, Set methods, `Object.groupBy`, `Promise.withResolvers` …) unless
`compat.js`, loaded before the rest, fills it in; the styles no `:has()`, no
`:focus-visible` in a list with other selectors, no nesting or dvh units, and
container queries, `subgrid` columns and a `color-mix()` custom property
only with an `@supports not (…)` fallback in the same file. Then, in WebKit
with those built-ins deleted, the panel's tabs and the window's pages are
drawn with no page error. It cannot run an old engine, so syntax is judged
by the parse, not by running it.
`usage-ledger.test.cjs` opens the Usage page's Requests, a row per request
from a faked `/api/usage/requests`: the columns, the model asked for, the
@@ -145,7 +160,7 @@ projects.
With Node.js and Playwright available:
```sh
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/routing-steady.test.cjs
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
+235
View File
@@ -0,0 +1,235 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// The page runs in the system's WebKit, which on macOS 12 can be Safari
// 15.0's (#220 uclort: on 12.7.6 the panel showed its tabs and headings and
// nothing else, no button working — a regex lookbehind in app.js is a
// syntax error before Safari 16.4, so the whole file never ran). What the
// scripts and styles may use is held to Safari 15.0 here:
// - every script parses, and none has syntax newer than it: a regex
// lookbehind (16.4) or v flag (17) or modifiers, a class static block
// (16.4), decorators, import attributes, `using`;
// - no built-in newer than it is used unless compat.js (loaded before the
// rest) fills it in;
// - the styles have no :has() (15.4), :focus-visible only in rules of its
// own (a list with it and more drops them all before 15.4), no nesting, no dvh
// units, and container queries, subgrid and a color-mix() custom property
// only with an @supports fallback in the same file;
// - in WebKit with those built-ins taken away, the panel and the window
// draw every page with no error.
// Syntax is parsed with the Babel parser Playwright bundles, so no other
// module is needed.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { webkit } = require("playwright");
const { babelParse } = require("playwright/lib/transform/babelBundle");
const assets = path.resolve(__dirname, "../assets");
const scripts = async () => (await fs.readdir(assets)).filter((f) => f.endsWith(".js")).sort();
const styles = async () => (await fs.readdir(assets)).filter((f) => f.endsWith(".css")).sort();
const read = (f) => fs.readFile(path.join(assets, f), "utf8");
// Syntax Safari 15.0 can't parse; one of these fails the whole file.
const LOOKBEHIND = /\(\?<[=!]/;
const MODIFIERS = /\(\?[ims]*-?[ims]+:/;
function badSyntax(ast) {
const found = [];
const at = (n) => n.loc ? `:${n.loc.start.line}` : "";
const regex = (n, pattern, flags) => {
if (LOOKBEHIND.test(pattern)) found.push(`${at(n)} regex lookbehind (Safari 16.4): /${pattern}/${flags}`);
if (/v/.test(flags)) found.push(`${at(n)} regex v flag (Safari 17): /${pattern}/${flags}`);
if (MODIFIERS.test(pattern)) found.push(`${at(n)} regex modifiers: /${pattern}/${flags}`);
};
const walk = (n) => {
if (!n || typeof n.type !== "string") return;
switch (n.type) {
case "RegExpLiteral": regex(n, n.pattern, n.flags); break;
case "StaticBlock": found.push(`${at(n)} class static block (Safari 16.4)`); break;
case "Decorator": found.push(`${at(n)} decorator`); break;
case "ClassAccessorProperty": found.push(`${at(n)} accessor field`); break;
case "ImportAttribute": found.push(`${at(n)} import attributes`); break;
case "VariableDeclaration": if (/using/.test(n.kind)) found.push(`${at(n)} ${n.kind} declaration`); break;
case "NewExpression": case "CallExpression": {
// new RegExp("…") takes the same syntax, a SyntaxError when it runs
const a = n.arguments?.[0];
if (n.callee?.type === "Identifier" && n.callee.name === "RegExp" && a) {
const text = a.type === "StringLiteral" ? a.value : a.type === "TemplateLiteral" ? a.quasis.map((q) => q.value.cooked).join("") : null;
const flags = n.arguments[1]?.type === "StringLiteral" ? n.arguments[1].value : "";
if (text != null) regex(n, text, flags);
}
break;
}
}
for (const k of Object.keys(n)) {
if (k === "loc" || k === "extra" || k === "leadingComments" || k === "trailingComments") continue;
const v = n[k];
if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === "object") walk(v);
}
};
walk(ast.program);
return found;
}
// Built-ins Safari 15.0 lacks. `fill` is what compat.js tests for when it
// fills one in; the others must not be used at all.
const BUILTINS = [
{ name: "Array.prototype.at", re: /\.at\(/, since: "15.4", fill: "Array.prototype.at" },
{ name: "findLast", re: /\.findLast\(/, since: "15.4", fill: "Array.prototype.findLast" },
{ name: "findLastIndex", re: /\.findLastIndex\(/, since: "15.4", fill: "Array.prototype.findLastIndex" },
{ name: "Object.hasOwn", re: /\bObject\.hasOwn\(/, since: "15.4", fill: "Object.hasOwn" },
{ name: "structuredClone", re: /\bstructuredClone\(/, since: "15.4", fill: "window.structuredClone" },
{ name: "toSorted/toReversed/toSpliced", re: /\.to(Sorted|Reversed|Spliced)\(/, since: "16" },
{ name: "Array.prototype.with", re: /\]\.with\(|\bArray\.prototype\.with\b/, since: "16" },
{ name: "String isWellFormed/toWellFormed", re: /\.(is|to)WellFormed\(/, since: "16.4" },
{ name: "AbortSignal.timeout/any", re: /\bAbortSignal\.(timeout|any)\(/, since: "16/17.4" },
{ name: "URL.canParse", re: /\bURL\.canParse\(/, since: "17" },
{ name: "Set methods", re: /\.(union|intersection|difference|symmetricDifference|isSubsetOf|isSupersetOf|isDisjointFrom)\(/, since: "17" },
{ name: "Array.fromAsync", re: /\bArray\.fromAsync\(/, since: "16.4" },
{ name: "Object.groupBy/Map.groupBy", re: /\b(Object|Map)\.groupBy\(/, since: "17.4" },
{ name: "Promise.withResolvers", re: /\bPromise\.withResolvers\(/, since: "17.4" },
{ name: "Iterator helpers", re: /\bIterator\.from\(|\.(keys|values|entries)\(\)\.(map|filter|take|drop|toArray|reduce|some|every|find|forEach|flatMap)\(/, since: "18.4" },
{ name: "checkVisibility", re: /\.checkVisibility\(/, since: "17.4" },
{ name: "popover", re: /\.(showPopover|hidePopover|togglePopover)\(|\.popover\s*=/, since: "17" },
{ name: "RegExp.escape", re: /\bRegExp\.escape\(/, since: "18.2" },
{ name: "Promise.try", re: /\bPromise\.try\(/, since: "18.2" },
];
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\"'`])\/\/.*$/gm, "$1");
test("every script parses with no syntax newer than Safari 15.0", async () => {
const bad = [];
for (const f of await scripts()) {
let ast;
try { ast = babelParse(await read(f), f, false); } catch (e) { bad.push(`${f}: does not parse: ${e.message.split("\n")[0]}`); continue; }
for (const x of badSyntax(ast)) bad.push(f + x);
}
assert.deepEqual(bad, []);
});
test("the checks catch what broke macOS 12", () => {
const found = (src) => badSyntax(babelParse(src, "x.js", false));
assert.equal(found('const re = /("[^"]*"|(?<==)\\S+)/g;').length, 1, "lookbehind");
assert.equal(found("s.replace(/(?<![\\w-])fill/g, f);").length, 1, "negative lookbehind");
assert.equal(found('const re = new RegExp("(?<=a)b");').length, 1, "lookbehind in new RegExp");
assert.equal(found("class A { static { init(); } }").length, 1, "static block");
assert.equal(found("const re = /[\\p{L}--a]/v;").length, 1, "v flag");
assert.deepEqual(found('const re = /(?<name>a)(?=b)(?!c)\\k<name>/g; a ??= b; class B { #x; #m() {} }'), [], "what Safari 15 has");
});
test("no built-in newer than Safari 15.0 unless compat.js fills it in", async () => {
const compat = await read("compat.js");
const bad = [];
for (const f of await scripts()) {
if (f === "compat.js") continue;
const lines = stripComments(await read(f)).split("\n");
for (const b of BUILTINS) {
if (b.fill && compat.includes(`if (!${b.fill})`)) continue;
lines.forEach((l, i) => { if (b.re.test(l)) bad.push(`${f}:${i + 1} ${b.name} (Safari ${b.since}): ${l.trim().slice(0, 100)}`); });
}
}
assert.deepEqual(bad, []);
});
test("compat.js is the first of the page's own scripts", async () => {
const html = await read("index.html");
const srcs = [...html.matchAll(/<script\s+src="([^"]+)"/g)].map((m) => m[1]).filter((s) => s !== "boot.js");
assert.equal(srcs[0], "compat.js", "loaded first: " + srcs.join(", "));
for (const f of await scripts()) assert(srcs.includes(f), f + " is not loaded by index.html");
});
test("the styles hold up in Safari 15.0", async () => {
const bad = [];
for (const f of await styles()) {
const css = (await read(f)).replace(/\/\*[\s\S]*?\*\//g, "");
const lineOf = (i) => css.slice(0, i).split("\n").length;
const each = (re, what) => { for (const m of css.matchAll(re)) bad.push(`${f}:~${lineOf(m.index)} ${what}: ${m[0].trim().slice(0, 90)}`); };
each(/:has\(/g, ":has() (Safari 15.4)");
each(/[\d.]+(dvh|svh|lvh|dvw|svw|lvw)\b/g, "dynamic viewport units (Safari 15.4)");
each(/[^{}]*&[^{}]*\{/g, "nesting (Safari 16.5)");
// a selector list with :focus-visible and more: all of it is dropped
for (const m of css.matchAll(/([^{}]+)\{/g)) {
const sel = m[1].trim();
if (sel.startsWith("@")) continue;
const parts = sel.split(",");
if (parts.some((p) => p.includes(":focus-visible")) && !parts.every((p) => p.includes(":focus-visible"))) bad.push(`${f}:~${lineOf(m.index)} :focus-visible in a selector list (Safari 15.4 drops the rule): ${sel.slice(0, 90)}`);
if (/:is\([^)]*:focus-visible/.test(sel)) bad.push(`${f}:~${lineOf(m.index)} :focus-visible inside :is(): ${sel.slice(0, 90)}`);
}
if (/@container\b/.test(css) && !/@supports not \(container-type: inline-size\)/.test(css)) bad.push(`${f}: @container (Safari 16) with no @supports not (container-type: inline-size) fallback`);
if (/:\s*subgrid\b/.test(css) && !/@supports not \(grid-template-columns: subgrid\)/.test(css)) {
// a subgrid that only lines rows up across cards may go without
for (const m of css.matchAll(/grid-template-columns:\s*subgrid/g)) bad.push(`${f}:~${lineOf(m.index)} grid-template-columns: subgrid (Safari 16) with no fallback`);
}
// a custom property is never checked: one holding color-mix() goes
// invalid where it is used, and whatever it painted goes clear
if (/--[\w-]+:\s*color-mix\(/.test(css) && !/@supports not \(color: color-mix\(/.test(css)) bad.push(`${f}: a custom property with color-mix() (Safari 16.2) and no @supports not (color: color-mix(…)) fallback`);
}
assert.deepEqual(bad, []);
});
// In WebKit with what Safari 15.0 lacks taken away, every page is drawn with
// no error: compat.js fills them in, and nothing else needs them.
const TAKEN = `
for (const k of ["at", "findLast", "findLastIndex", "toSorted", "toReversed", "toSpliced", "with"]) delete Array.prototype[k];
delete Object.hasOwn; delete window.structuredClone; delete Promise.withResolvers; delete Object.groupBy;
`;
const state = {
agents: [{ id: "codex", name: "Codex", path: "/test/config.toml", fields: [{ key: "model", label: "model", value: "gpt-6", options: [{ value: "gpt-6", label: "gpt-6" }] }] }],
profiles: [{ name: "work" }], settings: { lang: "en", theme: "light" },
};
async function serve(route) {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: 'window.bootPrefs = {lang:"en",theme:"light",web:true};' });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], gateway: { running: true } });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
return json({ mine: true, now: new Date().toISOString(), seq: 0, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404 }); }
}
test("with Safari 15.0's built-ins, the panel and the window draw every page", async () => {
const browser = await webkit.launch();
try {
for (const mode of ["panel", "window"]) {
const page = await browser.newPage({ viewport: mode === "panel" ? { width: 380, height: 640 } : { width: 1100, height: 760 } });
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.addInitScript(TAKEN);
await page.route("**/*", serve);
await page.goto(`http://magpie.test/?mode=${mode}`);
await page.locator("#agents .row").first().waitFor();
assert.equal(await page.evaluate(() => [1, 2, 3].findLast((x) => x < 3)), 2, "compat.js fills findLast in");
assert.deepEqual(await page.evaluate(() => { const a = { b: [1] }, c = structuredClone(a); return [c.b[0], c !== a && c.b !== a.b]; }), [1, true]);
assert.equal(await page.evaluate(() => [1, 2, 3].at(-1)), 3);
if (mode === "panel") {
assert(await page.locator("#ptabs").isVisible(), "the panel's tabs are the page's own, drawn by app.js");
for (const tab of ["usage", "routing", "agents", "profiles"]) {
const b = page.locator(`#ptabs [data-ptab="${tab}"]`);
if (await b.isVisible()) await b.click();
await page.waitForTimeout(150);
}
await page.locator("#save").click();
assert(await page.locator(".profiles.naming > .chip-input").isVisible(), "Save current opens the name field");
} else {
for (const v of ["providers", "gateway", "routing", "usage", "library", "agents"]) {
await page.locator(`#nav [data-view="${v}"]`).click();
await page.locator(`#view-${v}`).waitFor();
await page.waitForTimeout(200);
}
}
assert.deepEqual(errors, [], mode + ": no error on the page");
await page.close();
}
} finally {
await browser.close();
}
});
+1 -1
View File
@@ -370,7 +370,7 @@ footer a:hover { color: var(--ink); }
</a>
<a class="btn ghost" href="#cli">Install from terminal</a>
</div>
<div class="meta"><span class="ver">Free</span> · <a href="https://github.com/yetone/magpie">Open source</a> · <span class="os-note">macOS 11+ · <a class="alt" href="/download/mac-intel">Intel Mac</a></span> · <a href="#get">Windows &amp; Linux</a></div>
<div class="meta"><span class="ver">Free</span> · <a href="https://github.com/yetone/magpie">Open source</a> · <span class="os-note">macOS 12+ · <a class="alt" href="/download/mac-intel">Intel Mac</a></span> · <a href="#get">Windows &amp; Linux</a></div>
</div>
<div class="shot">
<img loading="lazy" class="pick for-light" src="/img/picker-light.png" width="440" height="555" alt="Codex's model picker in magpie, listing DeepSeek, Kimi and GLM models">