gui: open matching allowance cards from macOS menu-bar quotas (#662)

* gui: navigate menu-bar quota cells to allowance cards

* gui: fix quota navigation in lightweight and folded views

Route quota hits through the existing status-item monitor rather than relying on local monitor ordering. Recreate released panels through panelWin, and expand hidden accounts before focusing them. Keep panel headers non-draggable and cancel pending navigation after five seconds or purposeful scrolling.

Add a native AppKit subprocess regression that fails with the former nil-panel dereference and verifies runtime-queued focus, closing, and recreation. Extend browser checks for folded accounts, expiry, cancellation, and panel drag state.
This commit is contained in:
lizhuo authored and GitHub committed 2026-10-03 19:01:13 +08:00
1 parent 8c652a83f7
commit ebcd5281c2
13 files changed
+775 -27

No files matched your search

+1 -1
View File
@@ -503,7 +503,7 @@ body.window .field.solo { grid-column: auto; }
.field .v { min-width: 0; flex: 0 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; font-weight: 500; }
.field .chev { color: var(--faint); display: inline-flex; margin-left: auto; padding-left: 1px; }
.field:hover .chev, .field.open .chev { color: var(--muted); }
.field.flash { animation: flash 1.2s ease-out; }
.field.flash, .subscription-card.flash, .pq-card.flash { animation: flash 1.2s ease-out; }
@keyframes flash { 0%, 35% { box-shadow: inset 0 0 0 1px var(--green); background: var(--green-soft); } 100% {} }
/* ---- usage ---------------------------------------------------------------- */
+106 -8
View File
@@ -4,6 +4,8 @@ const $$ = (s) => document.querySelectorAll(s);
const params = new URLSearchParams(location.search);
const mode = params.get("mode") || "window";
document.body.classList.add(mode);
// The panel loads Wails for ExecJS readiness, but stays attached to the tray.
if (mode === "panel") $("header.top").style.setProperty("--wails-draggable", "no-drag");
// `magpie web`: the page in a browser tab, with no window of the app's
// around it — it opens links itself, and what is the desktop's is left out
const web = !!window.bootPrefs?.web;
@@ -26,7 +28,8 @@ if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win
// Outside the app — a browser on the gateway's page, or on `magpie web` —
// there is no runtime, and it isn't asked for (a 404 in the browser's
// console, Jorben on Discord).
const winRuntime = mode === "window" && !web ? import("/wails/runtime.js").catch(() => null) : Promise.resolve(null);
// The panel also needs runtime readiness to drain Go's queued ExecJS calls.
const winRuntime = !web ? import("/wails/runtime.js").catch(() => null) : Promise.resolve(null);
if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme");
// the saved language and theme from boot.js, so the first paint is in them
if (window.bootPrefs) {
@@ -9032,6 +9035,7 @@ const tokensOf = (t) => t.input + t.output;
function renderQuotas() {
renderPanelQuota();
const subscriptions = $("#subscriptionUsage");
const restoreFlash = keepQuotaFlash(subscriptions);
subscriptions.replaceChildren();
// the allowances' own heading, apart from the period's cost: used or
// left turns their meters, and is only there when some card has one (a
@@ -9073,6 +9077,8 @@ function renderQuotas() {
const pick = folded ? usageShown(subs) : null;
const card = el("div", "subscription-card" + (first.user ? " several" : ""));
card.dataset.key = first.provider;
card.dataset.provider = first.provider;
if (!first.user) card.dataset.card = first.provider;
const head = el("div", "subscription-head");
head.append(groups.length > 1 ? usageHandle(first, card) : icon(first.icon), el("b", "", first.name));
if (!first.user && (first.plan || first.until)) head.append(planSpan(first));
@@ -9084,6 +9090,7 @@ function renderQuotas() {
const [meters, every] = familyQuota(sub);
if (sub.user) {
const who = el("div", "subscription-account");
who.dataset.card = trayCardID(sub);
const u = el("span", "user", sub.user);
u.title = sub.user;
who.append(u);
@@ -9123,6 +9130,8 @@ function renderQuotas() {
if (subs.length > 1) card.append(usageMore(first.provider, subs.length - 1, folded, "text quota-more"));
subscriptions.append(card);
}
restoreFlash();
requestAnimationFrame(focusQuotaCard);
}
// A subscription with several accounts shows one of them, the others behind
@@ -9146,13 +9155,16 @@ function usageMore(provider, more, folded, cls) {
b.type = "button";
b.setAttribute("aria-expanded", String(!folded));
b.onclick = () => {
if (folded) usageOpen.add(provider); else usageOpen.delete(provider);
try { localStorage.setItem("magpie.usageOpen", JSON.stringify([...usageOpen])); } catch {}
renderQuotas();
setUsageOpen(provider, folded);
if (mode !== "panel") backToReader($("#view-usage"));
};
return b;
}
function setUsageOpen(provider, open) {
if (open) usageOpen.add(provider); else usageOpen.delete(provider);
try { localStorage.setItem("magpie.usageOpen", JSON.stringify([...usageOpen])); } catch {}
renderQuotas();
}
// The Usage page's cards in the order they were dragged to (settings
// usageOrder, by provider id), as the Agents page's rows are; one it doesn't
@@ -9514,6 +9526,80 @@ function setPanelTab(tab) {
queueMicrotask(panelUseShown);
fit();
}
// Scroll to a requested card, or resume its highlight after a redraw.
function flashCard(card, target = card, elapsed = 0) {
if (target) {
// The native menu-bar click has no DOM event; permit this requested scroll.
readerScrolls(1000);
target.scrollIntoView({ block: "nearest", behavior: "instant" });
}
card.classList.remove("flash");
void card.offsetWidth; // restart even if this card is still highlighted
card.classList.add("flash");
const animation = card.getAnimations().find((a) => a.animationName === "flash");
if (animation) animation.currentTime = elapsed;
card.onanimationend = (e) => {
if (e.target !== card || e.animationName !== "flash") return;
card.classList.remove("flash");
card.onanimationend = null;
};
}
// A focus refresh rebuilds quota cards. Carry the active animation's time
// to its replacement, preserving scroll position and the remaining duration.
function keepQuotaFlash(box) {
const view = box.closest(".view"), scroll = view?.scrollTop;
const flashes = [...box.querySelectorAll(".subscription-card.flash, .pq-card.flash")].flatMap((card) => {
const animation = card.getAnimations().find((a) => a.animationName === "flash");
if (!animation) return [];
const selector = card.matches(".pq-card") ? `.pq-card[data-card="${CSS.escape(card.dataset.card)}"]`
: `.subscription-card[data-provider="${CSS.escape(card.dataset.provider)}"]`;
return [{ selector, elapsed: animation.currentTime || 0 }];
});
return () => {
for (const { selector, elapsed } of flashes) {
const card = box.querySelector(selector);
if (card) flashCard(card, null, elapsed);
}
if (flashes.length && view) {
view.scrollTop = scroll;
readerLeaves(view);
}
};
}
// Keep the request until quota data has produced the target's DOM node.
function focusQuotaCard() {
if (!quotaFocus) return;
if (performance.now() > quotaFocusUntil) { quotaFocus = ""; return; }
const box = mode === "panel" ? $("#panelQuota") : $("#subscriptionUsage");
if (mode === "panel") setPanelTab("usage");
else if (view !== "usage" || usageTab !== "usage") return;
const id = CSS.escape(quotaFocus);
const target = box.querySelector(`[data-card="${id}"]`) || box.querySelector(`[data-provider="${id}"]`);
// The panel omits folded accounts; the main window renders them hidden.
const quota = quotas?.find((q) => trayCardID(q) === quotaFocus);
if (quota?.user && !usageOpen.has(quota.provider) && (!target || target.hidden)) {
setUsageOpen(quota.provider, true);
return; // the redraw queues focus once the account is visible
}
if (!target) {
if (quotas && !quotasLoading) quotaFocus = "";
return;
}
quotaFocus = "";
flashCard(target.closest(".subscription-card, .pq-card"), target);
}
// Go calls this when a menu-bar quota cell opens the panel.
function panelQuotaFocus(id) {
if (mode !== "panel") return;
quotaFocus = id;
quotaFocusUntil = performance.now() + 5000;
setPanelTab("usage");
requestAnimationFrame(focusQuotaCard);
}
if (mode === "panel") {
const tabs = $("#ptabs");
tabs.hidden = false;
@@ -9738,8 +9824,9 @@ function renderPanelQuota() {
}
if (none && panelTab === "usage") setPanelTab("agents");
box.hidden = none;
const restoreFlash = keepQuotaFlash(box);
box.replaceChildren();
if (none) { fit(); return; }
if (none) { quotaFocus = ""; fit(); return; }
if (!quotas) {
for (let i = 0; i < 2; i++) {
const card = el("div", "pq-card");
@@ -9795,6 +9882,7 @@ function renderPanelQuota() {
const grid = el("div", "pq-bals");
for (const q of bals) {
const card = el("div", "pq-card bal");
card.dataset.card = trayCardID(q);
card.title = [q.name, q.user].filter(Boolean).join(" · ");
// whose balance, at a glance: the provider's logo before its name
const who = el("span", "pq-sub pq-bn");
@@ -9827,8 +9915,10 @@ function renderPanelQuota() {
g.append(grid);
box.append(g);
}
restoreFlash();
panelAge();
fit();
requestAnimationFrame(focusQuotaCard);
}
// asOfText: an allowance standing in for one that couldn't be read just
@@ -9848,6 +9938,7 @@ function shortWindow(name) {
function panelQuotaCard(q) {
const card = el("div", "pq-card");
card.dataset.card = trayCardID(q);
card.append(el("span", "pq-user", q.user || q.name));
card.title = [q.name, q.user, q.plan, q.until ? planTerm(q) : "", q.balance && t("Balance") + " " + q.balance].filter(Boolean).join(" · ");
if (q.error) {
@@ -11188,6 +11279,8 @@ function renderLedger() {
const USAGE_TABS = [["usage", "Overview"], ["requests", "Requests"], ["sessions", "Sessions"]];
let usageTab = "usage";
let quotaFocus = ""; // pending provider/account card requested by the menu bar
let quotaFocusUntil = 0; // expire before a late quota response can move the reader
try { const k = localStorage.getItem("magpie.usageTab"); if (USAGE_TABS.some(([id]) => id === k)) usageTab = k; } catch {}
let sessions = null; // { sessions, terminal, dirs }
let sessAgent = "all";
@@ -14057,7 +14150,8 @@ function savePrefs(body) {
// (from a load, a timer, a helper that other clicks share) it is refused,
// and whatever scroll follows is put back.
let purposeUntil = 0, held = null;
const readerScrolls = (ms) => { purposeUntil = Math.max(purposeUntil, performance.now() + ms); held = null; };
// A new purposeful scroll supersedes any still-pending quota navigation.
const readerScrolls = (ms) => { quotaFocus = ""; purposeUntil = Math.max(purposeUntil, performance.now() + ms); held = null; };
function scrollOnPurpose(e, ms = 1000) {
if (!e?.isTrusted || performance.now() - e.timeStamp > 1000) {
console.warn("magpie: a scroll not asked for by the reader was refused");
@@ -14723,12 +14817,16 @@ if (mode === "window" && params.get("import")) {
if (mode === "window" && params.get("view") === "providers" && params.get("edit")) editing = params.get("edit");
// opened from the tray panel's Usage tab: on the Requests of one provider or agent
if (mode === "window" && params.get("view") === "usage") {
if (params.get("tab") === "requests") usageTab = "requests";
// An explicit destination takes precedence over the remembered tab.
const tab = params.get("tab");
if (tab === "requests" || tab === "usage") usageTab = tab;
quotaFocus = usageTab === "usage" ? (params.get("card") || params.get("provider") || "") : "";
quotaFocusUntil = performance.now() + 5000;
ledProvider = params.get("provider") || "";
ledAgent = params.get("agent") || "";
ledComputer = params.get("computer") || "";
const u = new URL(location.href);
for (const k of ["tab", "provider", "agent", "computer"]) u.searchParams.delete(k);
for (const k of ["tab", "provider", "agent", "computer", "card"]) u.searchParams.delete(k);
history.replaceState(null, "", u);
}
if (mode === "window" && ["providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"].includes(params.get("view"))) show(params.get("view"));
+17
View File
@@ -16,6 +16,8 @@ func TestMainURL(t *testing.T) {
{mainView(url.Values{"view": {"routing"}, "req": {"42"}}), "/?view=routing&req=42&lang=en"},
{mainView(url.Values{"view": {"settings"}}), "/?view=settings&lang=en"},
{mainView(url.Values{"view": {"usage"}, "tab": {"requests"}, "provider": {"relay team"}, "agent": {"claude-desktop"}}), "/?view=usage&tab=requests&provider=relay+team&agent=claude-desktop&lang=en"},
{mainView(url.Values{"view": {"usage"}, "tab": {"usage"}, "provider": {"codex"}}), "/?view=usage&tab=usage&provider=codex&lang=en"},
{mainView(url.Values{"view": {"usage"}, "tab": {"usage"}, "card": {"codex|a+b&c@test"}}), "/?view=usage&tab=usage&card=codex%7Ca%2Bb%26c%40test&lang=en"},
{argView("settings"), "/?view=settings&lang=en"},
{argView("usage&tab=x"), "/?view=usage%26tab%3Dx&lang=en"},
} {
@@ -32,3 +34,18 @@ func TestMainURL(t *testing.T) {
}
}
}
// Quota destinations validate provider names and escape account IDs.
func TestQuotaView(t *testing.T) {
for _, c := range []struct{ id, want string }{
{"codex", "usage&tab=usage&provider=codex"},
{"relay team", "usage&tab=usage&provider=relay+team"},
{"codex|a+b@例子.test", "usage&tab=usage&provider=codex&card=codex%7Ca%2Bb%40%E4%BE%8B%E5%AD%90.test"},
{"bad&name", "usage&tab=usage"},
{"", "usage&tab=usage"},
} {
if got := quotaView(c.id); got != c.want {
t.Errorf("quotaView(%q): %q, want %q", c.id, got, c.want)
}
}
}
+19
View File
@@ -449,6 +449,25 @@ Off posts `trayNoLogos: true`, a save of another setting keeps it, On posts it
back; the row has no left-border stripe, no click moves the page, and there is
no row with no card picked nor off a Mac; in English and Chinese.
`tray-cell-click.test.cjs` checks menu-bar quota navigation in the panel
and main window: selecting Allowances/Overview, scrolling to and highlighting
the matching account or balance, and keeping a click pending while quotas
load. It checks that the panel loads Wails so native ExecJS can run.
A focus-triggered redraw preserves the remaining highlight animation in both
panel and main window. It expands and remembers folded target accounts,
disables panel-header dragging,
and cancels navigation after five seconds or a purposeful user scroll.
It runs in English and Chinese on Chromium and WebKit, with a mocked API. Run with
`node --test internal/gui/tests/tray-cell-click.test.cjs`.
`TestTrayCellClickReleasedPanel` (darwin, cgo, GUI) runs a separate AppKit
process with an isolated config and a minimal page: a quota click recreates
the released panel, queued ExecJS selects the account once Wails is ready,
the next click closes it, and another release can be reopened and focused.
It requires a macOS graphical session and does not start the magpie backend
or create a status item. Run with
`go test ./internal/gui -run '^TestTrayCellClickReleasedPanel$' -count=1`.
`text-size.test.cjs` checks the Settings page's Text size row (100, 110,
125 and 150%): a pick posts to /api/settings/text-size without scrolling
the page, stays picked after a reload (boot.js carries it, and the page's
+212
View File
@@ -0,0 +1,212 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Native ExecJS and main-window URLs select the requested allowance/account,
// including delayed data, focus refreshes, real scrolling and repeated openings. APIs are mocked.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const quotas = (empty) => empty ? [] : [
{ provider: "claude", name: "Claude Code", icon: "claude-color", user: "a@b.c", windows: [{ name: "Weekly", used: 17.6 }, { name: "5-hour", used: 42.2 }] },
{ provider: "codex", name: "Codex", icon: "codex-color", user: "x@y.z", windows: [{ name: "5-hour", used: 8 }, { name: "Weekly", used: 100 }] },
...Array.from({ length: 6 }, (_, i) => ({ provider: "codex", name: "Codex", icon: "codex-color", user: `other-${i}@例子.test`, windows: [{ name: "Weekly", used: 25 }] })),
{ provider: "deepseek", name: "DeepSeek", icon: "deepseek-color", windows: [], balance: "¥12.30" },
{ provider: "kimi", name: "Kimi", icon: "kimi-color", windows: [{ name: "Weekly", used: 30 }] },
];
function serve(lang, empty, ready) {
const settings = { theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd" };
return async (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:"${lang}",theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "window.__runtimeReady = true; export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings });
if (url.pathname === "/api/settings") return json(settings);
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/usage/quotas") { await ready; return json(quotas(empty)); }
if (url.pathname === "/api/usage") return json({ days: [] });
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
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, body: "" }); }
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: a menu-bar cell's click opens the panel's Allowances tab at that agent's card`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(async () => { await browser.close(); });
const errors = [];
const open = async (url, viewport, init, empty, ready) => {
// no reduced-motion emulation here: it makes every animation a
// frame long, and the flash the cards light up with is one
const context = await browser.newContext({ viewport });
// what a click lands on is said by its scrolling there
await context.addInitScript(() => {
window.__scrolled = [];
const scroll = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = function (options) { window.__scrolled.push(this.dataset.card || this.dataset.provider || this.tagName); scroll.call(this, options); };
});
for (const script of init || []) await context.addInitScript(script);
const page = await context.newPage();
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, empty, ready));
await page.goto(url);
return page;
};
const visible = async (page, selector) => {
await page.waitForFunction((selector) => {
const e = document.querySelector(selector);
if (!e) return false;
const r = e.getBoundingClientRect(), v = e.closest(".view").getBoundingClientRect();
return r.top >= v.top - 1 && r.bottom <= v.bottom + 1;
}, selector).catch(async (err) => {
const info = await page.locator(selector).evaluate((e) => {
const v = e.closest(".view");
return { target: e.getBoundingClientRect().toJSON(), view: v.getBoundingClientRect().toJSON(), scroll: v.scrollTop, max: v.scrollHeight - v.clientHeight, viewport: innerHeight };
});
throw new Error(err.message + " " + JSON.stringify(info));
});
};
// the panel: Go's ExecJS runs panelQuotaFocus, the global Go names —
// a click on the Codex cell is the Allowances tab at Codex's card
const panel = await open("http://magpie.test/?mode=panel", { width: 440, height: 640 });
await panel.waitForFunction(() => document.querySelectorAll("#panelQuota .pq-card").length >= 4);
await panel.waitForFunction(() => window.__runtimeReady === true); // native ExecJS can now drain
assert.equal(await panel.locator("header.top").evaluate((e) => getComputedStyle(e).getPropertyValue("--wails-draggable").trim()), "no-drag", "the runtime must not make the panel draggable");
assert.equal(await panel.evaluate(() => typeof panelQuotaFocus), "function", "panelQuotaFocus is Go's to call by name");
assert.equal(await panel.evaluate(() => document.body.dataset.ptab), "agents", "the panel starts on its agents");
await panel.evaluate(() => panelQuotaFocus("codex|x@y.z"));
await panel.waitForFunction(() => document.body.dataset.ptab === "usage");
assert(await panel.locator('#ptabs [data-ptab="usage"]').evaluate((e) => e.classList.contains("on")), "the Allowances tab is the one picked");
assert.equal(await panel.evaluate(() => localStorage.getItem("magpie.panelTab")), "usage");
await panel.waitForFunction(() => document.querySelector('#panelQuota .pq-card[data-card="codex|x@y.z"]')?.classList.contains("flash"));
assert.deepEqual(await panel.evaluate(() => window.__scrolled.slice(-1)), ["codex|x@y.z"], "the card is scrolled to");
assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((card) => {
flashCard(card); // repeated focus restarts the same card
card.firstElementChild.dispatchEvent(new AnimationEvent("animationend", { bubbles: true, animationName: "flash" }));
card.dispatchEvent(new AnimationEvent("animationend", { animationName: "unrelated" }));
return card.classList.contains("flash");
}), "child or unrelated animations must not end the card's highlight");
// Showing a native panel gives it focus, which refreshes its state and quotas.
// The first click must remain highlighted across that refresh.
await panel.evaluate(() => {
window.__beforeFocusRefresh = document.querySelector('#panelQuota [data-card="codex|x@y.z"]');
window.__beforeFocusRefresh.getAnimations().find((a) => a.animationName === "flash").currentTime = 600;
window.dispatchEvent(new Event("focus"));
});
await panel.waitForFunction(() => document.querySelector('#panelQuota [data-card="codex|x@y.z"]') !== window.__beforeFocusRefresh);
assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((e) => e.classList.contains("flash")), "first-open focus refresh must preserve the highlight");
assert(await panel.locator('#panelQuota [data-card="codex|x@y.z"]').evaluate((e) => e.getAnimations().find((a) => a.animationName === "flash")?.currentTime >= 600), "refresh must continue the animation rather than restart it");
await panel.waitForFunction(() => !document.querySelector('#panelQuota [data-card="codex|x@y.z"]').classList.contains("flash"));
// a balance alone is a cell too, and its card is among the Balances
await panel.evaluate(() => panelQuotaFocus("deepseek"));
await panel.waitForFunction(() => document.querySelector('#panelQuota .pq-card.bal[data-card="deepseek"]')?.classList.contains("flash"));
assert.deepEqual(await panel.evaluate(() => window.__scrolled.slice(-1)), ["deepseek"]);
await visible(panel, '#panelQuota [data-card="deepseek"]');
// a card that isn't there picks the tab and stops: nothing flashed,
// nothing broken
await panel.evaluate(() => panelQuotaFocus("nobody"));
await panel.waitForFunction(() => !document.querySelector('#panelQuota .pq-card[data-card="deepseek"]')?.classList.contains("flash"));
assert.equal(await panel.evaluate(() => document.body.dataset.ptab), "usage");
assert.equal(await panel.evaluate(() => window.__scrolled.filter((s) => s === "nobody").length), 0);
// A click before the first quota response must survive the loading cards.
let release;
const ready = new Promise((resolve) => { release = resolve; });
const slow = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, ready);
await slow.waitForFunction(() => typeof panelQuotaFocus === "function");
await slow.evaluate(() => panelQuotaFocus("kimi"));
await slow.evaluate(() => new Promise(requestAnimationFrame));
release();
await slow.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]')?.classList.contains("flash"));
await visible(slow, '#panelQuota [data-card="kimi"]');
await slow.waitForFunction(() => !document.querySelector('#panelQuota [data-card="kimi"]').classList.contains("flash"));
await visible(slow, '#panelQuota [data-card="kimi"]');
await panel.emulateMedia({ reducedMotion: "reduce" });
assert.equal(await panel.locator('#panelQuota [data-card="codex|other-5@例子.test"]').count(), 0, "the requested account starts folded");
await panel.evaluate(() => panelQuotaFocus("codex|other-5@例子.test"));
await panel.waitForFunction(() => window.__scrolled.at(-1) === "codex|other-5@例子.test");
await visible(panel, '#panelQuota [data-card="codex|other-5@例子.test"]');
assert.deepEqual(await panel.evaluate(() => JSON.parse(localStorage.getItem("magpie.usageOpen"))), ["codex"], "navigation remembers the expanded provider");
await panel.waitForFunction(() => !document.querySelector('#panelQuota [data-card="codex|other-5@例子.test"]').classList.contains("flash"));
// no allowances to show: no tab to pick, the panel keeps its agents
const none = await open("http://magpie.test/?mode=panel", { width: 440, height: 640 }, [], true);
await none.waitForFunction(() => document.querySelectorAll("#agents").length === 1);
await none.evaluate(() => panelQuotaFocus("codex|x@y.z"));
await none.waitForFunction(() => document.body.dataset.ptab === "agents");
// the window: opened on its allowances (view=usage&tab=usage), the
// Overview is the pane whatever the page was last left on, at the
// provider's card
const win = await open("http://magpie.test/?view=usage&tab=usage&provider=kimi", { width: 900, height: 700 }, [
() => { try { localStorage.setItem("magpie.usageTab", "requests"); } catch {} },
]);
await win.waitForFunction(() => !document.querySelector("#usagePane").hidden);
assert.equal(await win.evaluate(() => document.querySelector("#ledgerPane").hidden), true, "the Requests the page was left on are not the pane");
await win.waitForFunction(() => document.querySelector('.subscription-card[data-provider="kimi"]')?.classList.contains("flash"));
assert.deepEqual(await win.evaluate(() => window.__scrolled.slice(-1)), ["kimi"], "the provider's card is scrolled to");
await win.evaluate(async () => {
document.querySelector('.subscription-card[data-provider="kimi"]').getAnimations().find((a) => a.animationName === "flash").currentTime = 600;
await loadQuotas();
});
assert(await win.locator('.subscription-card[data-provider="kimi"]').evaluate((e) => e.classList.contains("flash") && e.getAnimations().find((a) => a.animationName === "flash")?.currentTime >= 600), "main-window quota refresh must preserve the remaining highlight");
assert.equal(await win.evaluate(() => location.search.includes("tab=")), false, "the parameters are the page's own again");
await visible(win, '.subscription-card[data-provider="kimi"]');
// Same vendor, a different account: scroll within the tall vendor card.
const id = "codex|other-5@例子.test";
const account = await open("http://magpie.test/?view=usage&tab=usage&provider=codex&card=" + encodeURIComponent(id), { width: 900, height: 500 });
await account.waitForFunction(() => document.querySelector('.subscription-card[data-provider="codex"]')?.classList.contains("flash"));
assert.deepEqual(await account.evaluate(() => window.__scrolled.slice(-1)), [id]);
await visible(account, '.subscription-account[data-card="codex|other-5@例子.test"]');
assert.deepEqual(await account.evaluate(() => JSON.parse(localStorage.getItem("magpie.usageOpen"))), ["codex"]);
assert.equal(await account.evaluate(() => location.search.includes("card=")), false);
await account.waitForFunction(() => !document.querySelector('.subscription-card[data-provider="codex"]').classList.contains("flash"));
await visible(account, '.subscription-account[data-card="codex|other-5@例子.test"]');
// A slow response must not pull the reader back after the request expires.
let finish;
const expired = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, new Promise((resolve) => { finish = resolve; }));
await expired.waitForFunction(() => typeof panelQuotaFocus === "function");
await expired.clock.install();
await expired.evaluate(() => panelQuotaFocus("codex|other-5@例子.test"));
await expired.clock.fastForward(6000);
finish();
await expired.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]'));
await expired.evaluate(() => new Promise(requestAnimationFrame));
assert.deepEqual(await expired.evaluate(() => window.__scrolled), [], "expired requests cannot scroll or expand accounts");
assert.equal(await expired.locator('#panelQuota [data-card="codex|other-5@例子.test"]').count(), 0);
// A purposeful user scroll supersedes a still-pending menu-bar request.
let resume;
const scrolled = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, [], false, new Promise((resolve) => { resume = resolve; }));
await scrolled.waitForFunction(() => typeof panelQuotaFocus === "function");
await scrolled.evaluate(() => panelQuotaFocus("kimi"));
await scrolled.mouse.move(200, 150);
await scrolled.mouse.wheel(0, 200);
// Flush input delivery before allowing the data to arrive.
await scrolled.waitForFunction(() => !quotaFocus);
resume();
await scrolled.waitForFunction(() => document.querySelector('#panelQuota [data-card="kimi"]'));
await scrolled.evaluate(() => new Promise(requestAnimationFrame));
assert.deepEqual(await scrolled.evaluate(() => window.__scrolled), [], "manual scrolling cancels pending navigation");
assert.deepEqual(errors, []);
});
}
}
+23 -4
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/gateway"
@@ -67,8 +68,9 @@ func pricedRoutes(routes []gateway.Route) []routeJSON {
// mainView is the tab the window is asked to open on, as the page's view
// parameter: the Routing page on one request (req, its id) when the tray
// panel's Routing tab asks for it, or the Usage page's Requests on one
// provider or agent when its Usage tab does. Anything but an id, or a name
// of the kind a provider or an agent has, is dropped.
// provider or agent when its Usage tab does, its Overview (the allowances)
// when a menu-bar cell's click does. Anything but an id, or a name of the
// kind a provider or an agent has, is dropped.
func mainView(q url.Values) string {
view := q.Get("view")
switch view {
@@ -77,8 +79,11 @@ func mainView(q url.Values) string {
view += "&req=" + strconv.FormatInt(id, 10)
}
case "usage":
if q.Get("tab") == "requests" {
view += "&tab=requests"
if tab := q.Get("tab"); tab == "requests" || tab == "usage" {
view += "&tab=" + tab
}
if card := q.Get("card"); q.Get("tab") == "usage" && card != "" {
view += "&card=" + url.QueryEscape(card)
}
for _, k := range []string{"provider", "agent"} {
if v := q.Get(k); mainName.MatchString(v) {
@@ -106,6 +111,20 @@ func mainURL(view, query string) string { return "/?view=" + view + query }
// restart to update) as ShowMain takes it: a name, never parameters.
func argView(s string) string { return url.QueryEscape(s) }
// quotaView opens the Usage overview at a provider/account card.
// Invalid provider names fall back to the overview without a target.
func quotaView(id string) string {
provider, _, hasAccount := strings.Cut(id, "|")
view := "usage&tab=usage"
if mainName.MatchString(provider) {
view += "&provider=" + url.QueryEscape(provider)
if hasAccount {
view += "&card=" + url.QueryEscape(id)
}
}
return view
}
// traceRoutes serves the routing trace for the Gateway view to play: it
// waits up to 25 s for something to change after the seq it is given, so
// the page hears of a request as it happens.
+15
View File
@@ -0,0 +1,15 @@
//go:build darwin && cgo
package gui
import "C"
// The export lives separately because its cgo preamble must not define functions.
// Copy the native string before returning; a refresh can replace the drawn cells.
//
//export mpCellClicked
func mpCellClicked(id *C.char) {
if onTrayCellClick != nil {
onTrayCellClick(C.GoString(id)) // the handler schedules work after startup
}
}
+118 -3
View File
@@ -13,6 +13,7 @@ typedef struct {
int iconLen;
int mono;
int plain; // no logo: the rows alone, a thin line before
const char *id;
const char *letter;
const char *rows; // one or two, a newline between
} mpTrayCell;
@@ -41,6 +42,7 @@ static NSArray *mpCells(mpTrayCell *cells, int n) {
if (im != nil && im.representations.count > 0) c[@"icon"] = im;
[im release];
}
c[@"id"] = [NSString stringWithUTF8String:cells[i].id ? cells[i].id : ""];
c[@"mono"] = @(cells[i].mono != 0);
c[@"plain"] = @(cells[i].plain != 0);
c[@"letter"] = [NSString stringWithUTF8String:cells[i].letter ? cells[i].letter : ""];
@@ -234,6 +236,40 @@ static NSImage *mpImage(NSArray *cells, NSImage *bird, CGFloat h) {
return im;
}
// mpStarts writes each cell's start in image points, following mpDraw's layout.
// Plain cells start at their separator; others start at their logo.
static void mpStarts(NSArray *cells, CGFloat h, CGFloat *out) {
CGFloat pos = h + mpBirdGap;
BOOL first = YES;
int i = 0;
for (NSDictionary *c in cells) {
if ([c[@"plain"] boolValue]) {
out[i] = first ? pos : pos + mpSepGap;
pos += mpLead(c, first);
} else {
pos += mpLead(c, first) - mpLogo - mpLogoGap;
out[i] = pos;
pos += mpLogo + mpLogoGap;
}
pos += mpColumn(c);
i++;
first = NO;
}
}
// mpCellAt returns the cell at x, including its trailing gap.
// The bird and points outside the image return -1.
static int mpCellAt(NSArray *cells, CGFloat h, CGFloat x, CGFloat w) {
if (cells.count == 0 || x < h + mpBirdGap || x >= w) return -1;
int n = (int)cells.count;
CGFloat starts[n];
mpStarts(cells, h, starts);
for (int i = 0; i < n; i++) {
if (x < (i + 1 < n ? starts[i + 1] : w)) return i;
}
return -1;
}
static NSStatusBarButton *mpFind(NSView *v) {
if ([v isKindOfClass:[NSStatusBarButton class]]) return (NSStatusBarButton *)v;
for (NSView *s in v.subviews) {
@@ -256,12 +292,29 @@ static NSStatusBarButton *mpButton(void) {
static NSArray *mpShown;
static NSImage *mpBird;
// mpCellClicked copies the drawn card's identity to Go before it can be replaced.
extern void mpCellClicked(char *id);
static CGFloat mpHitW = 0;
// Used by the single status-item monitor and native tests. Modified clicks
// retain the button's normal action; an unmodified quota click uses its ID.
static BOOL mpClickCell(NSArray *cells, CGFloat h, CGFloat x, CGFloat w, NSEventModifierFlags flags) {
if (flags & (NSEventModifierFlagCommand | NSEventModifierFlagControl | NSEventModifierFlagOption | NSEventModifierFlagShift)) return NO;
int i = mpCellAt(cells, h, x, w);
if (i < 0) return NO;
mpCellClicked((char *)[cells[i][@"id"] UTF8String]);
return YES;
}
static void mpApply(void) {
NSStatusBarButton *b = mpButton();
if (b == nil || mpShown == nil) return;
b.title = @"";
b.image = mpImage(mpShown, mpBird, [[NSStatusBar systemStatusBar] thickness]);
NSImage *im = mpImage(mpShown, mpBird, [[NSStatusBar systemStatusBar] thickness]);
b.image = im;
b.imagePosition = NSImageOnly;
mpHitW = im.size.width;
}
static NSImage *mpBirdImage(const void *bird, int len) {
@@ -317,6 +370,7 @@ static void mpHide(void) {
dispatch_async(dispatch_get_main_queue(), ^{
[mpShown release];
mpShown = nil;
mpHitW = 0; // every click is the item's whole one again
});
}
@@ -338,6 +392,12 @@ static void mpOwnClicks(void) {
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskLeftMouseDown handler:^NSEvent *(NSEvent *e) {
NSStatusBarButton *b = mpButton();
if (b == nil || e.window != b.window) return e;
NSPoint p = [b convertPoint:e.locationInWindow fromView:nil];
if (!NSPointInRect(p, b.bounds)) return e;
// Route quota clicks before the generic action, within this
// one monitor; local monitor ordering is not guaranteed.
CGFloat x = p.x - (b.bounds.size.width - mpHitW) / 2;
if (mpClickCell(mpShown, [[NSStatusBar systemStatusBar] thickness], x, mpHitW, e.modifierFlags)) return nil;
[NSApp sendAction:b.action to:b.target from:b];
return nil;
}];
@@ -383,6 +443,33 @@ static void *mpPNG(mpTrayCell *cells, int n, const void *bird, int len, CGFloat
}
return out;
}
// Native hit-testing helpers for Go tests, using the actual image layout.
static int mpCellAtCells(mpTrayCell *cells, int n, CGFloat h, CGFloat x) {
@autoreleasepool {
NSArray *cs = mpCells(cells, n);
return mpCellAt(cs, h, x, mpWidth(cs, h));
}
}
static CGFloat mpCellStartAt(mpTrayCell *cells, int n, CGFloat h, int i) {
@autoreleasepool {
NSArray *cs = mpCells(cells, n);
if (i < 0 || i >= n) return -1;
CGFloat starts[n];
mpStarts(cs, h, starts);
return starts[i];
}
}
// Exercise the same identity lookup and C-to-Go callback without a live status item.
static int mpClickCellsAt(mpTrayCell *cells, int n, CGFloat h, CGFloat x, unsigned long flags) {
@autoreleasepool {
NSArray *cs = mpCells(cells, n);
return mpClickCell(cs, h, x, mpWidth(cs, h), flags);
}
}
*/
import "C"
@@ -406,8 +493,8 @@ func withCells(cells []trayCell, f func(*C.mpTrayCell, C.int)) {
cs := (*[1 << 16]C.mpTrayCell)(C.malloc(C.size_t(len(cells)) * C.size_t(unsafe.Sizeof(C.mpTrayCell{}))))[:len(cells):len(cells)]
var owned []unsafe.Pointer
for i, c := range cells {
cs[i] = C.mpTrayCell{letter: C.CString(c.Letter), rows: C.CString(strings.Join(c.Rows, "\n"))}
owned = append(owned, unsafe.Pointer(cs[i].letter), unsafe.Pointer(cs[i].rows))
cs[i] = C.mpTrayCell{id: C.CString(c.ID), letter: C.CString(c.Letter), rows: C.CString(strings.Join(c.Rows, "\n"))}
owned = append(owned, unsafe.Pointer(cs[i].id), unsafe.Pointer(cs[i].letter), unsafe.Pointer(cs[i].rows))
if len(c.Icon) > 0 {
cs[i].icon, cs[i].iconLen = C.CBytes(c.Icon), C.int(len(c.Icon))
owned = append(owned, cs[i].icon)
@@ -486,3 +573,31 @@ func cBool(b bool) C.int {
}
return 0
}
// These native test wrappers stay here because Go does not support cgo
// imports in _test.go files. They do not install monitors or create windows.
// trayImageCellAt tests the native hit map; -1 means no quota cell.
func trayImageCellAt(cells []trayCell, h, x float64) int {
at := -1
withCells(cells, func(cs *C.mpTrayCell, count C.int) {
at = int(C.mpCellAtCells(cs, count, C.CGFloat(h), C.CGFloat(x)))
})
return at
}
// trayImageCellStart returns cell i's start in image points, or -1 if absent.
func trayImageCellStart(cells []trayCell, h float64, i int) float64 {
s := -1.0
withCells(cells, func(cs *C.mpTrayCell, count C.int) {
s = float64(C.mpCellStartAt(cs, count, C.CGFloat(h), C.int(i)))
})
return s
}
func trayImageClickAt(cells []trayCell, h, x float64, modifiers uint64) bool {
hit := false
withCells(cells, func(cs *C.mpTrayCell, count C.int) {
hit = C.mpClickCellsAt(cs, count, C.CGFloat(h), C.CGFloat(x), C.ulong(modifiers)) != 0
})
return hit
}
+92
View File
@@ -82,6 +82,63 @@ func TestTrayImage(t *testing.T) {
}
}
// Hit-testing follows the image layout for both logo and plain cells.
func TestTrayImageCellAt(t *testing.T) {
bird, err := os.ReadFile("tray.png")
if err != nil {
t.Fatal(err)
}
cells, _, _ := trayUsageView(trayCards(), time.Now(), false)
const h = 22.0
_, w, _ := trayImagePNG(cells, bird, h, 2, false, false)
width := float64(w) / 2
if at := trayImageCellAt(cells, h, 5); at != -1 {
t.Errorf("a click by the bird says cell %d", at)
}
if at := trayImageCellAt(cells, h, width-0.5); at != len(cells)-1 {
t.Errorf("a click at the image's end says cell %d, want %d", at, len(cells)-1)
}
if at := trayImageCellAt(cells, h, width+1); at != -1 {
t.Errorf("a click past the image says cell %d", at)
}
if at := trayImageCellAt(nil, h, 5); at != -1 {
t.Errorf("no cells: cell %d", at)
}
from := make([]float64, len(cells))
for i := range cells {
from[i] = trayImageCellStart(cells, h, i)
if from[i] <= h || from[i] >= width {
t.Fatalf("cell %d begins at %g of %g", i, from[i], width)
}
if i > 0 && from[i] <= from[i-1] {
t.Fatalf("cell %d begins at %g, before the one before it at %g", i, from[i], from[i-1])
}
}
for i := range cells {
if at := trayImageCellAt(cells, h, from[i]+0.5); at != i {
t.Errorf("a click at %g (cell %d's start) says cell %d", from[i], i, at)
}
if i+1 < len(cells) {
if at := trayImageCellAt(cells, h, from[i+1]-0.5); at != i {
t.Errorf("a click at %g (before cell %d) says cell %d", from[i+1]-0.5, i+1, at)
}
}
}
if s := trayImageCellStart(cells, h, len(cells)); s != -1 {
t.Errorf("a cell past them all begins at %g", s)
}
// logos off (Settings): the same walk, the digits alone
plain := trayPlain(cells)
for i := range plain {
if s := trayImageCellStart(plain, h, i); s <= h || s >= width {
t.Errorf("plain cell %d begins at %g of %g", i, s, width)
}
}
if at := trayImageCellAt(plain, h, trayImageCellStart(plain, h, 0)+0.5); at != 0 {
t.Errorf("the first plain cell's click says cell %d", at)
}
}
// inkIn counts the pixels in r drawn in the bar's text colour.
func inkIn(img image.Image, r image.Rectangle, dark bool) int {
n := 0
@@ -164,3 +221,38 @@ func TestTrayImageLogoSizes(t *testing.T) {
}
}
}
// The native callback must carry the account ID before the drawing is freed
// or a refresh changes the order, including the logo-free layout.
func TestTrayImageClickIdentity(t *testing.T) {
prior := onTrayCellClick
t.Cleanup(func() { onTrayCellClick = prior })
clicked := make(chan string, 1)
onTrayCellClick = func(id string) { clicked <- id }
cards := trayCards()
cards[1].User = "a\"b\\c@例子.test"
cells, _, _ := trayUsageView(cards, time.Now(), false)
for _, layout := range [][]trayCell{cells, trayPlain(cells)} {
if trayImageClickAt(layout, 22, 5, 0) {
t.Fatal("bird intercepted")
}
x := trayImageCellStart(layout, 22, 1) + 1
// AppKit's Shift, Control, Option and Command modifier bits.
for _, modifiers := range []uint64{1 << 17, 1 << 18, 1 << 19, 1 << 20} {
if trayImageClickAt(layout, 22, x, modifiers) {
t.Fatal("modified click intercepted")
}
}
if !trayImageClickAt(layout, 22, x, 0) {
t.Fatal("cell missed")
}
select {
case id := <-clicked:
if id != trayCardID(cards[1]) {
t.Fatalf("clicked %q", id)
}
case <-time.After(time.Second):
t.Fatal("native click callback lost")
}
}
}
+16 -5
View File
@@ -3,6 +3,7 @@ package gui
import (
"cmp"
"context"
"encoding/json"
"fmt"
"math"
"regexp"
@@ -200,6 +201,7 @@ func trayInUseCard(cards []provider.SubscriptionQuota, pid string) (provider.Sub
// label's parts stacked, the shortest window over the longer ("42%" over
// "18%"), or its balance alone.
type trayCell struct {
ID string // stable provider/account identity, captured with the drawn cell
Icon []byte // the logo as the Usage page has it (SVG or PNG); nil for none
Mono bool // a black glyph, drawn in the menu bar's text colour
Letter string // drawn in its place when there is no logo, or it can't be read
@@ -212,15 +214,14 @@ type trayCell struct {
func trayPlain(cells []trayCell) []trayCell {
out := make([]trayCell, len(cells))
for i, c := range cells {
out[i] = trayCell{Plain: true, Rows: c.Rows}
out[i] = trayCell{ID: c.ID, Plain: true, Rows: c.Rows}
}
return out
}
// trayUsageView is what the tray shows for the cards: a cell for each
// that has a label; the labels as one line of text, " | " between cards,
// for a tray that shows text and not the cells; and the tooltip spelling
// each out.
// that has a label, with its stable identity for clicks; labels joined by
// " | " for a tray that shows text; and a tooltip spelling each out.
func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool) (cells []trayCell, label, tip string) {
var labels, tips []string
for _, q := range cards {
@@ -232,7 +233,7 @@ func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool)
continue
}
labels = append(labels, l)
c := trayCell{Rows: strings.Split(l, " · ")}
c := trayCell{ID: trayCardID(q), Rows: strings.Split(l, " · ")}
for i, r := range c.Rows {
c.Rows[i] = trayRow(r)
}
@@ -354,3 +355,13 @@ func svgNumber(d string, i int) int {
// onTrayUsage brings the tray's text up to date at once, when the Settings
// page changes which card it shows; set by the process that has the tray.
var onTrayUsage func()
// onTrayCellClick receives the stable card ID from the native image.
// Set by the tray process; it must schedule window work without blocking.
var onTrayCellClick func(id string)
// panelQuotaJS safely quotes the card ID for the panel's focus handler.
func panelQuotaJS(card string) string {
id, _ := json.Marshal(card) // quoted and escaped: a JSON string is a JS one
return "panelQuotaFocus(" + string(id) + ")"
}
+26
View File
@@ -2,6 +2,7 @@ package gui
import (
"slices"
"strconv"
"strings"
"testing"
"time"
@@ -114,6 +115,31 @@ func TestTrayUsageCells(t *testing.T) {
}
}
// Every rendered cell carries its own identity, even with logos disabled
// or unreadable cards skipped. JavaScript arguments preserve unusual account IDs.
func TestTrayCellsCards(t *testing.T) {
cards := trayCards()
cards[0].User = "a\"b\\c@例子.test"
cards[1].Error = "signed out"
cells, _, _ := trayUsageView(cards, time.Now(), false)
plain := trayPlain(cells)
if len(cells) != len(cards)-1 {
t.Fatalf("cells: %d", len(cells))
}
for i, cell := range cells {
at := i
if i > 0 {
at++
}
if cell.ID != trayCardID(cards[at]) || plain[i].ID != cell.ID {
t.Fatalf("cell %d: %q, plain %q", i, cell.ID, plain[i].ID)
}
if got := panelQuotaJS(cell.ID); got != "panelQuotaFocus("+strconv.Quote(cell.ID)+")" {
t.Fatalf("JS: %s", got)
}
}
}
// An arc's flags run into the number after them in many of the logos
// (Codex's among them); the Mac's SVG drawing needs them apart.
func TestSVGArcFlags(t *testing.T) {
@@ -0,0 +1,97 @@
//go:build darwin && cgo && !nogui
package gui
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
// A separate process runs AppKit on its main thread. It uses an isolated
// config and a minimal page, without starting magpie's backend or status item.
func TestTrayCellClickReleasedPanel(t *testing.T) {
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, exe)
cmd.Env = append(os.Environ(), "MAGPIE_TEST_TRAY_PANEL=1", "XDG_CONFIG_HOME="+t.TempDir())
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), "tray quota: recreated and focused twice") {
t.Fatalf("released panel: %v\n%s", err, out)
}
}
func init() {
if os.Getenv("MAGPIE_TEST_TRAY_PANEL") != "1" {
return
}
seen := make(chan string, 2)
app := application.New(application.Options{
Name: "magpie tray regression",
Mac: application.MacOptions{ActivationPolicy: application.ActivationPolicyProhibited},
Assets: application.AssetOptions{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/seen" {
seen <- r.URL.Query().Get("id")
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<script>function panelQuotaFocus(id) { fetch('/seen?id=' + encodeURIComponent(id)); }</script><script type="module" src="/wails/runtime.js"></script>`)
})},
})
h := &host{app: app, tray: &application.SystemTray{}}
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
application.InvokeAsync(func() { h.trayCellClick("codex|first@test") })
})
go func() {
if id := <-seen; id != "codex|first@test" {
panic("wrong initial target: " + id)
}
var first *application.WebviewWindow
application.InvokeSync(func() {
first = h.panel
if !first.IsVisible() {
panic("initial quota click did not open the panel")
}
h.trayCellClick("codex|first@test") // the second click closes it
})
// Native hide dispatches asynchronously, even from the main thread.
deadline := time.Now().Add(2 * time.Second)
for application.InvokeSyncWithResult(first.IsVisible) {
if time.Now().After(deadline) {
panic("second quota click did not close the panel")
}
time.Sleep(10 * time.Millisecond)
}
application.InvokeSync(func() {
// Same release state as lighten: the closed panel no longer exists.
first.Close()
h.panel = nil
h.tray.AttachWindow(nil)
h.trayCellClick("codex|second@test")
if h.panel == nil || h.panel == first {
panic("panel was not recreated")
}
})
if id := <-seen; id != "codex|second@test" {
panic("wrong recreated target: " + id)
}
fmt.Println("tray quota: recreated and focused twice")
app.Quit()
}()
if err := app.Run(); err != nil {
panic(err)
}
os.Exit(0)
}
+33 -6
View File
@@ -8,6 +8,8 @@ import (
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
@@ -25,6 +27,10 @@ func (h *host) watchTrayUsage() {
// a card read while stale is refreshed behind it: read it again once
// that lands, not a tick later, so the menu bar says what the panel does
provider.OnSubscriptionUsage = onTrayUsage
// Schedule native cell clicks on the application thread after startup.
onTrayCellClick = func(id string) {
h.whenReady(func() { application.InvokeAsync(func() { h.trayCellClick(id) }) })
}
go func() {
<-h.ready // the tray is made once the app runs
shown, drawn := "", false
@@ -58,12 +64,6 @@ func (h *host) watchTrayUsage() {
// the Mac's cells as an image, the text where it can't be
if len(cells) > 0 {
h.tray.SetLabel("")
if trayImageShow(cells, trayIcon) {
drawn = true
} else {
h.tray.SetLabel(label)
shown = "" // and try again next time
}
} else {
if drawn {
drawn = false
@@ -74,6 +74,16 @@ func (h *host) watchTrayUsage() {
}
h.tray.SetTooltip(tip)
}
// Reapply even unchanged cells: a system menu-bar rebuild can
// replace the composed image with Wails' bird icon.
if len(cells) > 0 {
if trayImageShow(cells, trayIcon) {
drawn = true
} else {
h.tray.SetLabel(label)
shown = "" // and try again next time
}
}
// the first answer can take a while; look again soon after it
next := trayUsageEvery()
if label == "" && len(s.TrayUsages) > 0 {
@@ -86,3 +96,20 @@ func (h *host) watchTrayUsage() {
}
}()
}
// trayCellClick opens the allowance represented by the clicked image cell.
// A visible panel closes on the next click, as clicking the bird does.
func (h *host) trayCellClick(id string) {
if id == "" {
return
}
if settings.Load().Tray == "window" {
h.ShowMain(quotaView(id))
return
}
w, _ := h.panelWin()
if !w.IsVisible() {
w.ExecJS(panelQuotaJS(id))
}
h.togglePanel()
}