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:
1 parent
8c652a83f7
commit
ebcd5281c2
13 files changed
+775
-27
No files matched your search
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) + ")"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user