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 .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 .chev { color: var(--faint); display: inline-flex; margin-left: auto; padding-left: 1px; }
|
||||||
.field:hover .chev, .field.open .chev { color: var(--muted); }
|
.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% {} }
|
@keyframes flash { 0%, 35% { box-shadow: inset 0 0 0 1px var(--green); background: var(--green-soft); } 100% {} }
|
||||||
|
|
||||||
/* ---- usage ---------------------------------------------------------------- */
|
/* ---- usage ---------------------------------------------------------------- */
|
||||||
|
|||||||
+106
-8
@@ -4,6 +4,8 @@ const $$ = (s) => document.querySelectorAll(s);
|
|||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const mode = params.get("mode") || "window";
|
const mode = params.get("mode") || "window";
|
||||||
document.body.classList.add(mode);
|
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
|
// `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
|
// around it — it opens links itself, and what is the desktop's is left out
|
||||||
const web = !!window.bootPrefs?.web;
|
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` —
|
// 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
|
// there is no runtime, and it isn't asked for (a 404 in the browser's
|
||||||
// console, Jorben on Discord).
|
// 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");
|
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
|
// the saved language and theme from boot.js, so the first paint is in them
|
||||||
if (window.bootPrefs) {
|
if (window.bootPrefs) {
|
||||||
@@ -9032,6 +9035,7 @@ const tokensOf = (t) => t.input + t.output;
|
|||||||
function renderQuotas() {
|
function renderQuotas() {
|
||||||
renderPanelQuota();
|
renderPanelQuota();
|
||||||
const subscriptions = $("#subscriptionUsage");
|
const subscriptions = $("#subscriptionUsage");
|
||||||
|
const restoreFlash = keepQuotaFlash(subscriptions);
|
||||||
subscriptions.replaceChildren();
|
subscriptions.replaceChildren();
|
||||||
// the allowances' own heading, apart from the period's cost: used or
|
// 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
|
// 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 pick = folded ? usageShown(subs) : null;
|
||||||
const card = el("div", "subscription-card" + (first.user ? " several" : ""));
|
const card = el("div", "subscription-card" + (first.user ? " several" : ""));
|
||||||
card.dataset.key = first.provider;
|
card.dataset.key = first.provider;
|
||||||
|
card.dataset.provider = first.provider;
|
||||||
|
if (!first.user) card.dataset.card = first.provider;
|
||||||
const head = el("div", "subscription-head");
|
const head = el("div", "subscription-head");
|
||||||
head.append(groups.length > 1 ? usageHandle(first, card) : icon(first.icon), el("b", "", first.name));
|
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));
|
if (!first.user && (first.plan || first.until)) head.append(planSpan(first));
|
||||||
@@ -9084,6 +9090,7 @@ function renderQuotas() {
|
|||||||
const [meters, every] = familyQuota(sub);
|
const [meters, every] = familyQuota(sub);
|
||||||
if (sub.user) {
|
if (sub.user) {
|
||||||
const who = el("div", "subscription-account");
|
const who = el("div", "subscription-account");
|
||||||
|
who.dataset.card = trayCardID(sub);
|
||||||
const u = el("span", "user", sub.user);
|
const u = el("span", "user", sub.user);
|
||||||
u.title = sub.user;
|
u.title = sub.user;
|
||||||
who.append(u);
|
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"));
|
if (subs.length > 1) card.append(usageMore(first.provider, subs.length - 1, folded, "text quota-more"));
|
||||||
subscriptions.append(card);
|
subscriptions.append(card);
|
||||||
}
|
}
|
||||||
|
restoreFlash();
|
||||||
|
requestAnimationFrame(focusQuotaCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A subscription with several accounts shows one of them, the others behind
|
// 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.type = "button";
|
||||||
b.setAttribute("aria-expanded", String(!folded));
|
b.setAttribute("aria-expanded", String(!folded));
|
||||||
b.onclick = () => {
|
b.onclick = () => {
|
||||||
if (folded) usageOpen.add(provider); else usageOpen.delete(provider);
|
setUsageOpen(provider, folded);
|
||||||
try { localStorage.setItem("magpie.usageOpen", JSON.stringify([...usageOpen])); } catch {}
|
|
||||||
renderQuotas();
|
|
||||||
if (mode !== "panel") backToReader($("#view-usage"));
|
if (mode !== "panel") backToReader($("#view-usage"));
|
||||||
};
|
};
|
||||||
return b;
|
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
|
// 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
|
// usageOrder, by provider id), as the Agents page's rows are; one it doesn't
|
||||||
@@ -9514,6 +9526,80 @@ function setPanelTab(tab) {
|
|||||||
queueMicrotask(panelUseShown);
|
queueMicrotask(panelUseShown);
|
||||||
fit();
|
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") {
|
if (mode === "panel") {
|
||||||
const tabs = $("#ptabs");
|
const tabs = $("#ptabs");
|
||||||
tabs.hidden = false;
|
tabs.hidden = false;
|
||||||
@@ -9738,8 +9824,9 @@ function renderPanelQuota() {
|
|||||||
}
|
}
|
||||||
if (none && panelTab === "usage") setPanelTab("agents");
|
if (none && panelTab === "usage") setPanelTab("agents");
|
||||||
box.hidden = none;
|
box.hidden = none;
|
||||||
|
const restoreFlash = keepQuotaFlash(box);
|
||||||
box.replaceChildren();
|
box.replaceChildren();
|
||||||
if (none) { fit(); return; }
|
if (none) { quotaFocus = ""; fit(); return; }
|
||||||
if (!quotas) {
|
if (!quotas) {
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const card = el("div", "pq-card");
|
const card = el("div", "pq-card");
|
||||||
@@ -9795,6 +9882,7 @@ function renderPanelQuota() {
|
|||||||
const grid = el("div", "pq-bals");
|
const grid = el("div", "pq-bals");
|
||||||
for (const q of bals) {
|
for (const q of bals) {
|
||||||
const card = el("div", "pq-card bal");
|
const card = el("div", "pq-card bal");
|
||||||
|
card.dataset.card = trayCardID(q);
|
||||||
card.title = [q.name, q.user].filter(Boolean).join(" · ");
|
card.title = [q.name, q.user].filter(Boolean).join(" · ");
|
||||||
// whose balance, at a glance: the provider's logo before its name
|
// whose balance, at a glance: the provider's logo before its name
|
||||||
const who = el("span", "pq-sub pq-bn");
|
const who = el("span", "pq-sub pq-bn");
|
||||||
@@ -9827,8 +9915,10 @@ function renderPanelQuota() {
|
|||||||
g.append(grid);
|
g.append(grid);
|
||||||
box.append(g);
|
box.append(g);
|
||||||
}
|
}
|
||||||
|
restoreFlash();
|
||||||
panelAge();
|
panelAge();
|
||||||
fit();
|
fit();
|
||||||
|
requestAnimationFrame(focusQuotaCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// asOfText: an allowance standing in for one that couldn't be read just
|
// asOfText: an allowance standing in for one that couldn't be read just
|
||||||
@@ -9848,6 +9938,7 @@ function shortWindow(name) {
|
|||||||
|
|
||||||
function panelQuotaCard(q) {
|
function panelQuotaCard(q) {
|
||||||
const card = el("div", "pq-card");
|
const card = el("div", "pq-card");
|
||||||
|
card.dataset.card = trayCardID(q);
|
||||||
card.append(el("span", "pq-user", q.user || q.name));
|
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(" · ");
|
card.title = [q.name, q.user, q.plan, q.until ? planTerm(q) : "", q.balance && t("Balance") + " " + q.balance].filter(Boolean).join(" · ");
|
||||||
if (q.error) {
|
if (q.error) {
|
||||||
@@ -11188,6 +11279,8 @@ function renderLedger() {
|
|||||||
|
|
||||||
const USAGE_TABS = [["usage", "Overview"], ["requests", "Requests"], ["sessions", "Sessions"]];
|
const USAGE_TABS = [["usage", "Overview"], ["requests", "Requests"], ["sessions", "Sessions"]];
|
||||||
let usageTab = "usage";
|
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 {}
|
try { const k = localStorage.getItem("magpie.usageTab"); if (USAGE_TABS.some(([id]) => id === k)) usageTab = k; } catch {}
|
||||||
let sessions = null; // { sessions, terminal, dirs }
|
let sessions = null; // { sessions, terminal, dirs }
|
||||||
let sessAgent = "all";
|
let sessAgent = "all";
|
||||||
@@ -14057,7 +14150,8 @@ function savePrefs(body) {
|
|||||||
// (from a load, a timer, a helper that other clicks share) it is refused,
|
// (from a load, a timer, a helper that other clicks share) it is refused,
|
||||||
// and whatever scroll follows is put back.
|
// and whatever scroll follows is put back.
|
||||||
let purposeUntil = 0, held = null;
|
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) {
|
function scrollOnPurpose(e, ms = 1000) {
|
||||||
if (!e?.isTrusted || performance.now() - e.timeStamp > 1000) {
|
if (!e?.isTrusted || performance.now() - e.timeStamp > 1000) {
|
||||||
console.warn("magpie: a scroll not asked for by the reader was refused");
|
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");
|
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
|
// opened from the tray panel's Usage tab: on the Requests of one provider or agent
|
||||||
if (mode === "window" && params.get("view") === "usage") {
|
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") || "";
|
ledProvider = params.get("provider") || "";
|
||||||
ledAgent = params.get("agent") || "";
|
ledAgent = params.get("agent") || "";
|
||||||
ledComputer = params.get("computer") || "";
|
ledComputer = params.get("computer") || "";
|
||||||
const u = new URL(location.href);
|
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);
|
history.replaceState(null, "", u);
|
||||||
}
|
}
|
||||||
if (mode === "window" && ["providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"].includes(params.get("view"))) show(params.get("view"));
|
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": {"routing"}, "req": {"42"}}), "/?view=routing&req=42&lang=en"},
|
||||||
{mainView(url.Values{"view": {"settings"}}), "/?view=settings&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": {"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("settings"), "/?view=settings&lang=en"},
|
||||||
{argView("usage&tab=x"), "/?view=usage%26tab%3Dx&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
|
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.
|
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,
|
`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
|
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
|
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"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yetone/magpie/internal/gateway"
|
"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
|
// 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
|
// 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
|
// 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
|
// provider or agent when its Usage tab does, its Overview (the allowances)
|
||||||
// of the kind a provider or an agent has, is dropped.
|
// 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 {
|
func mainView(q url.Values) string {
|
||||||
view := q.Get("view")
|
view := q.Get("view")
|
||||||
switch view {
|
switch view {
|
||||||
@@ -77,8 +79,11 @@ func mainView(q url.Values) string {
|
|||||||
view += "&req=" + strconv.FormatInt(id, 10)
|
view += "&req=" + strconv.FormatInt(id, 10)
|
||||||
}
|
}
|
||||||
case "usage":
|
case "usage":
|
||||||
if q.Get("tab") == "requests" {
|
if tab := q.Get("tab"); tab == "requests" || tab == "usage" {
|
||||||
view += "&tab=requests"
|
view += "&tab=" + tab
|
||||||
|
}
|
||||||
|
if card := q.Get("card"); q.Get("tab") == "usage" && card != "" {
|
||||||
|
view += "&card=" + url.QueryEscape(card)
|
||||||
}
|
}
|
||||||
for _, k := range []string{"provider", "agent"} {
|
for _, k := range []string{"provider", "agent"} {
|
||||||
if v := q.Get(k); mainName.MatchString(v) {
|
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.
|
// restart to update) as ShowMain takes it: a name, never parameters.
|
||||||
func argView(s string) string { return url.QueryEscape(s) }
|
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
|
// 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
|
// 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.
|
// 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 iconLen;
|
||||||
int mono;
|
int mono;
|
||||||
int plain; // no logo: the rows alone, a thin line before
|
int plain; // no logo: the rows alone, a thin line before
|
||||||
|
const char *id;
|
||||||
const char *letter;
|
const char *letter;
|
||||||
const char *rows; // one or two, a newline between
|
const char *rows; // one or two, a newline between
|
||||||
} mpTrayCell;
|
} mpTrayCell;
|
||||||
@@ -41,6 +42,7 @@ static NSArray *mpCells(mpTrayCell *cells, int n) {
|
|||||||
if (im != nil && im.representations.count > 0) c[@"icon"] = im;
|
if (im != nil && im.representations.count > 0) c[@"icon"] = im;
|
||||||
[im release];
|
[im release];
|
||||||
}
|
}
|
||||||
|
c[@"id"] = [NSString stringWithUTF8String:cells[i].id ? cells[i].id : ""];
|
||||||
c[@"mono"] = @(cells[i].mono != 0);
|
c[@"mono"] = @(cells[i].mono != 0);
|
||||||
c[@"plain"] = @(cells[i].plain != 0);
|
c[@"plain"] = @(cells[i].plain != 0);
|
||||||
c[@"letter"] = [NSString stringWithUTF8String:cells[i].letter ? cells[i].letter : ""];
|
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;
|
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) {
|
static NSStatusBarButton *mpFind(NSView *v) {
|
||||||
if ([v isKindOfClass:[NSStatusBarButton class]]) return (NSStatusBarButton *)v;
|
if ([v isKindOfClass:[NSStatusBarButton class]]) return (NSStatusBarButton *)v;
|
||||||
for (NSView *s in v.subviews) {
|
for (NSView *s in v.subviews) {
|
||||||
@@ -256,12 +292,29 @@ static NSStatusBarButton *mpButton(void) {
|
|||||||
static NSArray *mpShown;
|
static NSArray *mpShown;
|
||||||
static NSImage *mpBird;
|
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) {
|
static void mpApply(void) {
|
||||||
NSStatusBarButton *b = mpButton();
|
NSStatusBarButton *b = mpButton();
|
||||||
if (b == nil || mpShown == nil) return;
|
if (b == nil || mpShown == nil) return;
|
||||||
b.title = @"";
|
b.title = @"";
|
||||||
b.image = mpImage(mpShown, mpBird, [[NSStatusBar systemStatusBar] thickness]);
|
NSImage *im = mpImage(mpShown, mpBird, [[NSStatusBar systemStatusBar] thickness]);
|
||||||
|
b.image = im;
|
||||||
b.imagePosition = NSImageOnly;
|
b.imagePosition = NSImageOnly;
|
||||||
|
mpHitW = im.size.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
static NSImage *mpBirdImage(const void *bird, int len) {
|
static NSImage *mpBirdImage(const void *bird, int len) {
|
||||||
@@ -317,6 +370,7 @@ static void mpHide(void) {
|
|||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
[mpShown release];
|
[mpShown release];
|
||||||
mpShown = nil;
|
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) {
|
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskLeftMouseDown handler:^NSEvent *(NSEvent *e) {
|
||||||
NSStatusBarButton *b = mpButton();
|
NSStatusBarButton *b = mpButton();
|
||||||
if (b == nil || e.window != b.window) return e;
|
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];
|
[NSApp sendAction:b.action to:b.target from:b];
|
||||||
return nil;
|
return nil;
|
||||||
}];
|
}];
|
||||||
@@ -383,6 +443,33 @@ static void *mpPNG(mpTrayCell *cells, int n, const void *bird, int len, CGFloat
|
|||||||
}
|
}
|
||||||
return out;
|
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"
|
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)]
|
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
|
var owned []unsafe.Pointer
|
||||||
for i, c := range cells {
|
for i, c := range cells {
|
||||||
cs[i] = C.mpTrayCell{letter: C.CString(c.Letter), rows: C.CString(strings.Join(c.Rows, "\n"))}
|
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].letter), unsafe.Pointer(cs[i].rows))
|
owned = append(owned, unsafe.Pointer(cs[i].id), unsafe.Pointer(cs[i].letter), unsafe.Pointer(cs[i].rows))
|
||||||
if len(c.Icon) > 0 {
|
if len(c.Icon) > 0 {
|
||||||
cs[i].icon, cs[i].iconLen = C.CBytes(c.Icon), C.int(len(c.Icon))
|
cs[i].icon, cs[i].iconLen = C.CBytes(c.Icon), C.int(len(c.Icon))
|
||||||
owned = append(owned, cs[i].icon)
|
owned = append(owned, cs[i].icon)
|
||||||
@@ -486,3 +573,31 @@ func cBool(b bool) C.int {
|
|||||||
}
|
}
|
||||||
return 0
|
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.
|
// inkIn counts the pixels in r drawn in the bar's text colour.
|
||||||
func inkIn(img image.Image, r image.Rectangle, dark bool) int {
|
func inkIn(img image.Image, r image.Rectangle, dark bool) int {
|
||||||
n := 0
|
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 (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"regexp"
|
"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
|
// label's parts stacked, the shortest window over the longer ("42%" over
|
||||||
// "18%"), or its balance alone.
|
// "18%"), or its balance alone.
|
||||||
type trayCell struct {
|
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
|
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
|
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
|
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 {
|
func trayPlain(cells []trayCell) []trayCell {
|
||||||
out := make([]trayCell, len(cells))
|
out := make([]trayCell, len(cells))
|
||||||
for i, c := range 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// trayUsageView is what the tray shows for the cards: a cell for each
|
// 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,
|
// that has a label, with its stable identity for clicks; labels joined by
|
||||||
// for a tray that shows text and not the cells; and the tooltip spelling
|
// " | " for a tray that shows text; and a tooltip spelling each out.
|
||||||
// each out.
|
|
||||||
func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool) (cells []trayCell, label, tip string) {
|
func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool) (cells []trayCell, label, tip string) {
|
||||||
var labels, tips []string
|
var labels, tips []string
|
||||||
for _, q := range cards {
|
for _, q := range cards {
|
||||||
@@ -232,7 +233,7 @@ func trayUsageView(cards []provider.SubscriptionQuota, now time.Time, left bool)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
labels = append(labels, l)
|
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 {
|
for i, r := range c.Rows {
|
||||||
c.Rows[i] = trayRow(r)
|
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
|
// 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.
|
// page changes which card it shows; set by the process that has the tray.
|
||||||
var onTrayUsage func()
|
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 (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// 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.
|
// (Codex's among them); the Mac's SVG drawing needs them apart.
|
||||||
func TestSVGArcFlags(t *testing.T) {
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
"github.com/yetone/magpie/internal/provider"
|
"github.com/yetone/magpie/internal/provider"
|
||||||
"github.com/yetone/magpie/internal/settings"
|
"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
|
// 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
|
// that lands, not a tick later, so the menu bar says what the panel does
|
||||||
provider.OnSubscriptionUsage = onTrayUsage
|
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() {
|
go func() {
|
||||||
<-h.ready // the tray is made once the app runs
|
<-h.ready // the tray is made once the app runs
|
||||||
shown, drawn := "", false
|
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
|
// the Mac's cells as an image, the text where it can't be
|
||||||
if len(cells) > 0 {
|
if len(cells) > 0 {
|
||||||
h.tray.SetLabel("")
|
h.tray.SetLabel("")
|
||||||
if trayImageShow(cells, trayIcon) {
|
|
||||||
drawn = true
|
|
||||||
} else {
|
|
||||||
h.tray.SetLabel(label)
|
|
||||||
shown = "" // and try again next time
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if drawn {
|
if drawn {
|
||||||
drawn = false
|
drawn = false
|
||||||
@@ -74,6 +74,16 @@ func (h *host) watchTrayUsage() {
|
|||||||
}
|
}
|
||||||
h.tray.SetTooltip(tip)
|
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
|
// the first answer can take a while; look again soon after it
|
||||||
next := trayUsageEvery()
|
next := trayUsageEvery()
|
||||||
if label == "" && len(s.TrayUsages) > 0 {
|
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