codex-ringside 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.cjs CHANGED
@@ -65,6 +65,7 @@ async function runAgent({ appPath, root, signal }) {
65
65
  seenAuth = stamp;
66
66
  log('account file changed, reconnecting');
67
67
  dropClient();
68
+ showUnavailable();
68
69
  nextRead = 0;
69
70
  }
70
71
 
@@ -151,6 +152,7 @@ function loadSettings(root) {
151
152
 
152
153
  async function main() {
153
154
  const { startWatcher } = require('./startup/watch.cjs');
155
+ const { startAutoUpdater } = require('./update.cjs');
154
156
  const root = path.resolve(__dirname, '..');
155
157
  const settings = loadSettings(root);
156
158
  const abort = new AbortController();
@@ -165,6 +167,7 @@ async function main() {
165
167
  process.once('SIGINT', shutdown);
166
168
  process.once('SIGTERM', shutdown);
167
169
 
170
+ startAutoUpdater({ root, signal: abort.signal, log });
168
171
  await runAgent({ appPath: settings.app, root, signal: abort.signal });
169
172
  await watcher.exited;
170
173
  }
package/src/card.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Runs inside the Codex renderer. Must stay self-contained: cdp.cjs injects this file as text.
2
2
  function installCodexRingsideCard() {
3
- const VERSION = 13;
3
+ const VERSION = 17;
4
4
  const KEY = '__codexRingsideCard';
5
5
  const STALE_AFTER_MS = 150000;
6
6
  const TICKS = 30;
@@ -18,14 +18,14 @@ function installCodexRingsideCard() {
18
18
  const card = document.createElement('div');
19
19
  card.id = 'codex-ringside-card';
20
20
  card.tabIndex = 0;
21
- card.setAttribute('role', 'group');
21
+ card.setAttribute('role', 'button');
22
22
  card.setAttribute('aria-label', 'Codex 剩余额度');
23
- card.setAttribute('aria-describedby', 'codex-ringside-tooltip');
24
23
  card.setAttribute('aria-haspopup', 'dialog');
25
24
 
26
25
  const tooltip = document.createElement('div');
27
26
  tooltip.id = 'codex-ringside-tooltip';
28
- tooltip.setAttribute('role', 'tooltip');
27
+ tooltip.setAttribute('role', 'dialog');
28
+ tooltip.setAttribute('aria-label', 'Codex 剩余额度');
29
29
  tooltip.hidden = true;
30
30
 
31
31
  const style = document.createElement('style');
@@ -47,12 +47,16 @@ function installCodexRingsideCard() {
47
47
  /* The spread also grows the corners, so the outer radius is 4 + 3 = 7px; the width matches the rail buttons' 36px. */
48
48
  #codex-ringside-card:hover { background: var(--ringside-hover); box-shadow: 0 0 0 3px var(--ringside-hover); }
49
49
  #codex-ringside-card:active { transform: scale(.96); }
50
+ /* Keep the native menu available, with Ringside's original background-settings click. */
51
+ [data-codex-ringside-host] { height: auto !important; min-height: 36px; padding: 3px !important; }
52
+ [data-codex-ringside-host] > :not(#codex-ringside-card) { display: none !important; }
53
+ [data-codex-ringside-host] > #codex-ringside-card { margin: 0; pointer-events: none; }
50
54
  @media (prefers-reduced-motion: reduce) { #codex-ringside-card { transition: none; } }
51
55
  html.dark #codex-ringside-card, html[data-theme="dark"] #codex-ringside-card { ${dark} }
52
56
  @media (prefers-color-scheme: dark) {
53
57
  html:not(.light):not([data-theme="light"]) #codex-ringside-card { ${dark} }
54
58
  }
55
- #codex-ringside-card[hidden], #codex-ringside-tooltip[hidden] { display: none !important; }
59
+ #codex-ringside-card[hidden], #codex-ringside-tooltip[hidden], #codex-ringside-tooltip [hidden] { display: none !important; }
56
60
  #codex-ringside-card:focus-visible { outline: 1px solid var(--ringside-text); outline-offset: 2px; }
57
61
  #codex-ringside-card .ringside-meter { position: relative; flex: 0 0 auto; width: 30px; height: 30px; }
58
62
  #codex-ringside-card .ringside-meter[data-tone="normal"] { --ringside-glow: #34e08280; }
@@ -67,11 +71,12 @@ function installCodexRingsideCard() {
67
71
  #codex-ringside-card .ringside-number { font-size: 8px; line-height: 9px; font-weight: 600; letter-spacing: -.3px; white-space: nowrap; }
68
72
  #codex-ringside-card .ringside-label { font-size: 6.5px; line-height: 8px; color: var(--ringside-label); white-space: nowrap; }
69
73
  #codex-ringside-tooltip {
70
- box-sizing: border-box; position: fixed; z-index: 2147483000; min-width: 170px; max-width: min(320px, calc(100vw - 16px));
71
- padding: 12px 14px; border: 1px solid color-mix(in srgb, currentColor 12%, transparent); border-radius: 10px;
74
+ box-sizing: border-box; position: fixed; z-index: 2147483000; min-width: 204px; max-width: min(320px, calc(100vw - 16px));
75
+ padding: 12px 14px; border: 1px solid color-mix(in srgb, currentColor 12%, transparent); border-radius: 12px;
72
76
  background: var(--color-surface-elevated-secondary, #f8f8f7); color: var(--color-text-primary, #303630);
73
- box-shadow: 0 4px 18px #0002; pointer-events: none; -webkit-app-region: no-drag;
74
- font: 12px/1.5 -apple-system, BlinkMacSystemFont, sans-serif;
77
+ box-shadow: 0 6px 22px #00000024; -webkit-app-region: no-drag;
78
+ max-height: calc(100vh - 16px); overflow: auto;
79
+ font: 12px/1.45 -apple-system, BlinkMacSystemFont, sans-serif;
75
80
  }
76
81
  html.dark #codex-ringside-tooltip, html[data-theme="dark"] #codex-ringside-tooltip {
77
82
  background: var(--color-surface-elevated-secondary, #2c2e2c); color: var(--color-text-primary, #edf0ed);
@@ -81,21 +86,53 @@ function installCodexRingsideCard() {
81
86
  background: var(--color-surface-elevated-secondary, #2c2e2c); color: var(--color-text-primary, #edf0ed);
82
87
  }
83
88
  }
84
- #codex-ringside-tooltip .ringside-title { font-weight: 600; }
85
- #codex-ringside-tooltip .ringside-sections { display: flex; gap: 20px; margin-top: 8px; }
86
- #codex-ringside-tooltip .ringside-section-percent { margin: 2px 0; font-size: 22px; line-height: 26px; font-weight: 700; font-variant-numeric: tabular-nums; }
87
- #codex-ringside-tooltip .ringside-dim { opacity: .68; font-size: 11px; white-space: nowrap; }
88
- #codex-ringside-tooltip .ringside-footer { margin-top: 10px; padding-top: 8px; border-top: 1px solid color-mix(in srgb, currentColor 10%, transparent); }
89
+ #codex-ringside-tooltip .ringside-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
90
+ #codex-ringside-tooltip .ringside-title { font-weight: 600; font-size: 12.5px; }
91
+ #codex-ringside-tooltip .ringside-badge {
92
+ padding: 1px 7px; border-radius: 999px; font-size: 10.5px; line-height: 16px; font-weight: 500;
93
+ background: color-mix(in srgb, currentColor 9%, transparent); color: color-mix(in srgb, currentColor 80%, transparent); white-space: nowrap;
94
+ }
95
+ #codex-ringside-tooltip .ringside-sections { display: flex; flex-wrap: wrap; gap: 10px 20px; margin-top: 10px; }
96
+ #codex-ringside-tooltip .ringside-section { flex: 1 1 auto; min-width: 72px; }
97
+ #codex-ringside-tooltip .ringside-section-percent {
98
+ margin: 2px 0; font-size: 24px; line-height: 28px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums;
99
+ }
100
+ #codex-ringside-tooltip .ringside-dim { opacity: .68; font-size: 11px; }
101
+ #codex-ringside-tooltip .ringside-message { margin-top: 8px; }
102
+ #codex-ringside-tooltip .ringside-footer { margin-top: 10px; padding-top: 9px; border-top: 1px solid color-mix(in srgb, currentColor 10%, transparent); }
103
+ #codex-ringside-tooltip .ringside-meta { display: flex; flex-direction: column; gap: 6px; }
104
+ #codex-ringside-tooltip .ringside-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
105
+ #codex-ringside-tooltip .ringside-row[data-label]::before { content: attr(data-label); opacity: .68; font-size: 11px; }
106
+ #codex-ringside-tooltip .ringside-credit-balance { font-size: 14px; line-height: 18px; font-weight: 600; font-variant-numeric: tabular-nums; }
107
+ #codex-ringside-tooltip .ringside-meta-value { font-size: 12px; line-height: 16px; font-weight: 500; opacity: .9; font-variant-numeric: tabular-nums; }
108
+ #codex-ringside-tooltip .ringside-sr { font-size: 0; opacity: 0; }
109
+ #codex-ringside-tooltip .ringside-actions { display: flex; gap: 6px; }
110
+ #codex-ringside-tooltip button {
111
+ flex: 1; padding: 5px 10px; border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 6px;
112
+ font: 500 11.5px/1.35 -apple-system, BlinkMacSystemFont, sans-serif; text-align: center; white-space: nowrap;
113
+ color: inherit; background: color-mix(in srgb, currentColor 4%, transparent); cursor: pointer;
114
+ transition: background-color 120ms ease, border-color 120ms ease;
115
+ }
116
+ #codex-ringside-tooltip button:hover, #codex-ringside-tooltip button:focus-visible {
117
+ background: color-mix(in srgb, currentColor 11%, transparent);
118
+ border-color: color-mix(in srgb, currentColor 22%, transparent);
119
+ }
120
+ #codex-ringside-tooltip button:focus-visible { outline: 1px solid currentColor; outline-offset: 1px; }
89
121
  `;
90
122
 
91
123
  let value = { rings: [], message: '正在读取 Codex 剩余额度', note: null, stale: false, updatedAt: null };
92
124
  let rail = null;
125
+ let nativeTrigger = null;
126
+ let eventTarget = null;
93
127
  let placementTimer = null;
94
128
  let hoverTimer = null;
129
+ let hideTimer = null;
130
+ let suppressFocusTooltip = false;
131
+ let forwardingNativeAction = false;
95
132
  let disposed = false;
96
133
 
97
- const element = (className, text) => {
98
- const node = document.createElement('div');
134
+ const element = (className, text, tag = 'div') => {
135
+ const node = document.createElement(tag);
99
136
  node.className = className;
100
137
  if (text != null) node.textContent = text;
101
138
  return node;
@@ -106,6 +143,25 @@ function installCodexRingsideCard() {
106
143
  return rect.width > 0 && rect.height > 0 && getComputedStyle(node).visibility !== 'hidden';
107
144
  };
108
145
  const percentText = percent => (Number.isFinite(percent) ? `${percent}%` : '—');
146
+ const details = element('ringside-details');
147
+ const actions = element('ringside-actions ringside-footer');
148
+ const settingsButton = document.createElement('button');
149
+ settingsButton.type = 'button';
150
+ settingsButton.textContent = '背景设置';
151
+ settingsButton.addEventListener('click', togglePanel);
152
+ const usageButton = document.createElement('button');
153
+ usageButton.type = 'button';
154
+ usageButton.textContent = '用量菜单';
155
+ usageButton.setAttribute('aria-haspopup', 'menu');
156
+ usageButton.addEventListener('click', openUsageMenu);
157
+ actions.append(settingsButton, usageButton);
158
+ tooltip.append(details, actions);
159
+
160
+ function renderActions() {
161
+ settingsButton.hidden = !window.__codexRingsideBackground?.togglePanel;
162
+ usageButton.hidden = !nativeTrigger;
163
+ actions.hidden = settingsButton.hidden && usageButton.hidden;
164
+ }
109
165
 
110
166
  // Filled ticks run clockwise from 12 o'clock; the unused part of the circle stays a solid track.
111
167
  function ringSvg(percent, tone) {
@@ -151,19 +207,53 @@ function installCodexRingsideCard() {
151
207
  if (ring.resetsAt) section.append(element('ringside-dim', `${ring.resetsAt} 重置`));
152
208
  sections.append(section);
153
209
  }
154
- const lines = [element('ringside-title', 'Codex 剩余额度')];
210
+ const header = element('ringside-header');
211
+ header.append(element('ringside-title', 'Codex 剩余额度'));
212
+ if (value.plan) header.append(element('ringside-badge', `${value.plan} 套餐`));
213
+ const lines = [header];
155
214
  if (sections.childElementCount) lines.push(sections);
156
- if (value.message) lines.push(element('ringside-dim', value.message));
157
- if (value.note) {
158
- const footer = element('ringside-dim ringside-footer', value.note);
159
- lines.push(footer);
215
+ if (value.credits || value.note) {
216
+ const meta = element('ringside-meta ringside-footer');
217
+ if (value.credits) {
218
+ const credits = element('ringside-row');
219
+ credits.append(element('ringside-dim', '额度余额'), element('ringside-credit-balance', value.credits.label));
220
+ meta.append(credits);
221
+ }
222
+ if (value.note) {
223
+ const parts = /^([^::]+)([::]\s*)(.+)$/.exec(value.note);
224
+ if (parts) {
225
+ const note = element('ringside-row');
226
+ note.dataset.label = parts[1];
227
+ const val = element('ringside-meta-value', parts[3], 'span');
228
+ val.prepend(element('ringside-sr', `${parts[1]}${parts[2]}`, 'span'));
229
+ note.append(val);
230
+ meta.append(note);
231
+ } else {
232
+ meta.append(element('ringside-dim', value.note));
233
+ }
234
+ }
235
+ lines.push(meta);
160
236
  }
161
- tooltip.replaceChildren(...lines);
237
+ if (value.message) lines.push(element('ringside-dim ringside-message', value.message));
238
+ details.replaceChildren(...lines);
239
+ renderActions();
162
240
  }
163
241
 
164
242
  function render() {
165
- const rings = value.rings?.length ? value.rings : [{ label: '额度', name: '额度', percent: null, tone: 'muted' }];
166
- card.replaceChildren(...rings.map(buildMeter));
243
+ if (!value.rings?.length && value.credits) {
244
+ const balance = element('ringside-center');
245
+ const compact = value.credits.unlimited ? '∞' : new Intl.NumberFormat('zh-CN', {
246
+ notation: 'compact', maximumFractionDigits: 1
247
+ }).format(value.credits.balance);
248
+ balance.append(element('ringside-number', compact), element('ringside-label', '额度'));
249
+ const meter = element('ringside-meter');
250
+ meter.setAttribute('aria-label', `额度余额:${value.credits.label}`);
251
+ meter.append(balance);
252
+ card.replaceChildren(meter);
253
+ } else {
254
+ const rings = value.rings?.length ? value.rings : [{ label: '额度', name: '额度', percent: null, tone: 'muted' }];
255
+ card.replaceChildren(...rings.map(buildMeter));
256
+ }
167
257
  renderTooltip();
168
258
  if (!tooltip.hidden) positionTooltip();
169
259
  }
@@ -174,6 +264,9 @@ function installCodexRingsideCard() {
174
264
  value = {
175
265
  ...value,
176
266
  stale: true,
267
+ credits: null,
268
+ plan: null,
269
+ note: null,
177
270
  message: '额度数据已过期,正在等待重新连接',
178
271
  rings: (value.rings ?? []).map(ring => ({ ...ring, percent: null, tone: 'muted', resetsAt: null }))
179
272
  };
@@ -184,31 +277,121 @@ function installCodexRingsideCard() {
184
277
  const panelOpen = () => document.getElementById('codex-ringside-panel')?.hidden === false;
185
278
  function togglePanel() {
186
279
  hideTooltip();
187
- window.__codexRingsideBackground?.togglePanel(card);
280
+ window.__codexRingsideBackground?.togglePanel(nativeTrigger ?? card);
281
+ }
282
+
283
+ function openUsageMenu() {
284
+ if (!nativeTrigger?.isConnected) return;
285
+ if (panelOpen()) window.__codexRingsideBackground?.togglePanel(nativeTrigger);
286
+ hideTooltip();
287
+ // Current clients open on pointerdown; older triggers may open on click. Pass both
288
+ // through the untouched native handlers, only for this explicit menu action.
289
+ forwardingNativeAction = true;
290
+ try {
291
+ nativeTrigger.focus();
292
+ const pointer = { bubbles: true, cancelable: true, button: 0, pointerType: 'mouse', isPrimary: true };
293
+ nativeTrigger.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, buttons: 1 }));
294
+ nativeTrigger.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0 }));
295
+ nativeTrigger.click();
296
+ } finally {
297
+ forwardingNativeAction = false;
298
+ hideTooltip();
299
+ }
188
300
  }
189
301
 
190
302
  function positionTooltip() {
191
- const rect = card.getBoundingClientRect();
303
+ const rect = (nativeTrigger ?? card).getBoundingClientRect();
192
304
  tooltip.style.left = `${Math.max(8, Math.min(rect.right + 12, innerWidth - tooltip.offsetWidth - 8))}px`;
193
305
  const top = rect.top + rect.height / 2 - tooltip.offsetHeight / 2;
194
306
  tooltip.style.top = `${Math.max(8, Math.min(top, innerHeight - tooltip.offsetHeight - 8))}px`;
195
307
  }
196
308
  function hideTooltip() {
197
309
  clearTimeout(hoverTimer);
310
+ clearTimeout(hideTimer);
198
311
  hoverTimer = null;
199
312
  tooltip.hidden = true;
200
313
  }
201
314
  function showTooltip() {
202
315
  clearTimeout(hoverTimer);
203
- if (disposed || document.hidden || !visible(card) || panelOpen()) return;
316
+ clearTimeout(hideTimer);
317
+ if (disposed || suppressFocusTooltip || document.hidden || !visible(card) || panelOpen()
318
+ || nativeTrigger?.getAttribute('aria-expanded') === 'true') return;
319
+ renderActions();
204
320
  tooltip.hidden = false;
205
321
  positionTooltip();
206
322
  }
207
323
  function scheduleTooltip() {
208
324
  clearTimeout(hoverTimer);
325
+ clearTimeout(hideTimer);
209
326
  hoverTimer = setTimeout(showTooltip, 350);
210
327
  }
211
328
 
329
+ function scheduleHide() {
330
+ clearTimeout(hoverTimer);
331
+ clearTimeout(hideTimer);
332
+ hideTimer = setTimeout(() => {
333
+ if (!tooltip.contains(document.activeElement)) hideTooltip();
334
+ }, 180);
335
+ }
336
+
337
+ // Exact accessible labels cover old builds; the indicator + description covers other locales.
338
+ // Never select the profile menu or a navigation destination merely because it has an SVG.
339
+ function findNativeTrigger(container) {
340
+ return [...(container?.querySelectorAll('button[aria-haspopup="menu"]') ?? [])].find(button =>
341
+ visible(button) && !button.hasAttribute('data-sidebar-destination') && (
342
+ /^(usage|用量|使用量)$/i.test(button.getAttribute('aria-label')?.trim() ?? '')
343
+ || (button.hasAttribute('aria-description') && button.querySelector('[data-transition-phase]'))
344
+ )) ?? null;
345
+ }
346
+
347
+ const suppressNativeTooltip = event => event.stopPropagation();
348
+ const onBlur = event => {
349
+ if (!tooltip.contains(event.relatedTarget) && !eventTarget?.contains(event.relatedTarget)) hideTooltip();
350
+ };
351
+ const interceptBackground = event => {
352
+ if (forwardingNativeAction || !window.__codexRingsideBackground?.togglePanel) return false;
353
+ event.preventDefault();
354
+ event.stopPropagation();
355
+ return true;
356
+ };
357
+ const onPointerDown = event => {
358
+ // Radix opens before click. Stop the left pointerdown as well as the click so
359
+ // the original menu cannot steal focus or cover the background settings.
360
+ if (event.button === 0 && interceptBackground(event)) eventTarget?.focus();
361
+ };
362
+ const onClick = event => {
363
+ if (interceptBackground(event)) togglePanel();
364
+ else hideTooltip();
365
+ };
366
+ const onContextMenu = event => {
367
+ if (!window.__codexRingsideBackground?.togglePanel) return;
368
+ event.preventDefault();
369
+ event.stopPropagation();
370
+ togglePanel();
371
+ };
372
+ const onKeyDown = event => {
373
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) return onContextMenu(event);
374
+ if (!['Enter', ' ', 'ArrowDown'].includes(event.key)) return;
375
+ if (event.key === 'ArrowDown') {
376
+ if (nativeTrigger && panelOpen()) window.__codexRingsideBackground?.togglePanel(nativeTrigger);
377
+ hideTooltip(); // ArrowDown still reaches the native usage-menu handler.
378
+ } else if (interceptBackground(event)) {
379
+ if (!event.repeat) togglePanel();
380
+ }
381
+ };
382
+ function bindTarget(target) {
383
+ if (eventTarget === target) return;
384
+ const listeners = { mouseenter: scheduleTooltip, mouseleave: scheduleHide, focus: showTooltip,
385
+ blur: onBlur, pointerdown: onPointerDown, keydown: onKeyDown, click: onClick, contextmenu: onContextMenu,
386
+ pointerover: suppressNativeTooltip, pointermove: suppressNativeTooltip, focusin: suppressNativeTooltip };
387
+ for (const [name, handler] of Object.entries(listeners)) {
388
+ const capture = ['pointerdown', 'keydown', 'click', 'contextmenu'].includes(name);
389
+ eventTarget?.removeEventListener(name, handler, capture);
390
+ target?.addEventListener(name, handler, capture);
391
+ }
392
+ eventTarget = target;
393
+ }
394
+
212
395
  function place() {
213
396
  if (disposed || !document.body) return;
214
397
  if (!style.isConnected) (document.head ?? document.documentElement).appendChild(style);
@@ -219,6 +402,28 @@ function installCodexRingsideCard() {
219
402
  rail = nextRail;
220
403
  if (rail) resizeObserver.observe(rail);
221
404
  }
405
+ const nextTrigger = findNativeTrigger(rail);
406
+ if (nativeTrigger !== nextTrigger) {
407
+ nativeTrigger?.removeAttribute('data-codex-ringside-host');
408
+ nativeTrigger = nextTrigger;
409
+ hideTooltip();
410
+ }
411
+ if (nativeTrigger) {
412
+ if (!nativeTrigger.hasAttribute('data-codex-ringside-host')) nativeTrigger.setAttribute('data-codex-ringside-host', '');
413
+ card.removeAttribute('tabindex');
414
+ card.setAttribute('role', 'group');
415
+ card.removeAttribute('aria-haspopup');
416
+ card.hidden = false;
417
+ if (card.parentElement !== nativeTrigger) nativeTrigger.append(card);
418
+ bindTarget(nativeTrigger);
419
+ if (nativeTrigger.getAttribute('aria-expanded') === 'true') hideTooltip();
420
+ else if (!tooltip.hidden) positionTooltip();
421
+ return;
422
+ }
423
+ card.tabIndex = 0;
424
+ card.setAttribute('role', 'button');
425
+ card.setAttribute('aria-haspopup', 'dialog');
426
+ bindTarget(card);
222
427
  // Own a sibling just before the rail's footer; never move React-owned nodes.
223
428
  const footer = rail && [...rail.children]
224
429
  .filter(child => child !== card && visible(child) && getComputedStyle(child).position !== 'absolute')
@@ -245,20 +450,26 @@ function installCodexRingsideCard() {
245
450
  if (records.some(record => !ownNode(record.target))) schedulePlacement();
246
451
  });
247
452
  const resizeObserver = new ResizeObserver(schedulePlacement);
248
- const onKeyDown = event => {
249
- if (event.key === 'Escape') hideTooltip();
250
- if (event.key !== 'Enter' && event.key !== ' ') return;
251
- event.preventDefault();
252
- togglePanel();
453
+ const onEscape = event => {
454
+ if (event.key !== 'Escape' || tooltip.hidden) return;
455
+ if (tooltip.contains(document.activeElement)) {
456
+ suppressFocusTooltip = true;
457
+ eventTarget?.focus();
458
+ suppressFocusTooltip = false;
459
+ }
460
+ hideTooltip();
461
+ };
462
+ const onOutsidePointer = event => {
463
+ if (!tooltip.contains(event.target)) hideTooltip();
253
464
  };
254
465
 
255
- observer.observe(document.documentElement, { childList: true, subtree: true });
256
- card.addEventListener('mouseenter', scheduleTooltip);
257
- card.addEventListener('mouseleave', hideTooltip);
258
- card.addEventListener('focus', showTooltip);
259
- card.addEventListener('blur', hideTooltip);
260
- card.addEventListener('keydown', onKeyDown);
261
- card.addEventListener('click', togglePanel);
466
+ observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true,
467
+ attributeFilter: ['aria-expanded', 'aria-label', 'aria-description', 'hidden'] });
468
+ tooltip.addEventListener('mouseenter', () => clearTimeout(hideTimer));
469
+ tooltip.addEventListener('mouseleave', scheduleHide);
470
+ tooltip.addEventListener('focusout', onBlur);
471
+ document.addEventListener('keydown', onEscape);
472
+ document.addEventListener('pointerdown', onOutsidePointer, true);
262
473
  window.addEventListener('resize', schedulePlacement);
263
474
  document.addEventListener('visibilitychange', hideTooltip);
264
475
 
@@ -274,7 +485,10 @@ function installCodexRingsideCard() {
274
485
  status() {
275
486
  return {
276
487
  version: VERSION,
277
- placed: card.parentElement === rail && !card.hidden && visible(card),
488
+ placed: rail?.contains(card) === true && !card.hidden && visible(card),
489
+ replacesNative: nativeTrigger?.contains(card) === true,
490
+ credits: value.credits ?? null,
491
+ plan: value.plan ?? null,
278
492
  rings: value.rings,
279
493
  message: value.message,
280
494
  stale: value.stale ?? false,
@@ -289,6 +503,10 @@ function installCodexRingsideCard() {
289
503
  clearTimeout(placementTimer);
290
504
  clearInterval(freshnessTimer);
291
505
  hideTooltip();
506
+ bindTarget(null);
507
+ nativeTrigger?.removeAttribute('data-codex-ringside-host');
508
+ document.removeEventListener('keydown', onEscape);
509
+ document.removeEventListener('pointerdown', onOutsidePointer, true);
292
510
  window.removeEventListener('resize', schedulePlacement);
293
511
  document.removeEventListener('visibilitychange', hideTooltip);
294
512
  card.remove();
package/src/install.cjs CHANGED
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const { execFileSync } = require('node:child_process');
6
6
  const launchd = require('./launchd.cjs');
7
7
  const { DEFAULT_APP, isExecutable, resolveCodexBin, resolveNode } = require('./resolve.cjs');
8
+ const { inspectBundle, copyBundle } = require('./standalone.cjs');
8
9
 
9
10
  const SWIFTC = '/usr/bin/swiftc';
10
11
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
@@ -29,10 +30,12 @@ function writeJson(file, data) {
29
30
  fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
30
31
  }
31
32
 
32
- function preflight(settings) {
33
+ function preflight(settings, repoRoot, current) {
33
34
  if (process.platform !== 'darwin') throw new Error('只支持 macOS');
34
35
  const app = fs.realpathSync(settings.app);
35
36
  resolveCodexBin(app);
37
+ const bundle = inspectBundle(repoRoot);
38
+ if (bundle) return { app, node: path.join(current.installDir, 'runtime/node'), bundle };
36
39
  if (!isExecutable(SWIFTC)) throw new Error('需要 Xcode Command Line Tools:xcode-select --install');
37
40
  return { app, node: resolveNode() };
38
41
  }
@@ -98,12 +101,13 @@ function selectSettings(current, legacy, env, observe) {
98
101
  ?? readJson(path.join(legacy.installDir, 'settings.json')) ?? {};
99
102
  return {
100
103
  app: env.CODEX_RINGSIDE_APP || env.CODEX_PLUS_APP || saved.app || DEFAULT_APP,
101
- allowClientRestart: observe === undefined ? saved.allowClientRestart !== false : !observe
104
+ allowClientRestart: observe === undefined ? saved.allowClientRestart !== false : !observe,
105
+ autoUpdate: saved.autoUpdate !== false
102
106
  };
103
107
  }
104
108
 
105
109
  function copyUserData(source, stage, destination) {
106
- for (const name of ['background.json', 'background', 'user-images']) {
110
+ for (const name of ['background.json', 'background', 'user-images', 'update-state.json']) {
107
111
  copyIfPresent(path.join(source, name), path.join(stage, name));
108
112
  }
109
113
  const file = path.join(stage, 'background.json');
@@ -123,8 +127,10 @@ function copyUserData(source, stage, destination) {
123
127
  async function install(options = {}) {
124
128
  const { current, legacy, backupRoot, repoRoot, services } = { ...defaults, ...options };
125
129
  return withLock(current, async () => {
130
+ // Recheck under the install lock: an update downloaded before uninstall/reinstall is stale.
131
+ options.beforeInstall?.();
126
132
  const settings = selectSettings(current, legacy, options.env ?? process.env, options.observe);
127
- const checked = await (options.preflight ?? preflight)(settings);
133
+ const checked = await (options.preflight ?? preflight)(settings, repoRoot, current);
128
134
  settings.app = checked.app;
129
135
  const stage = fs.mkdtempSync(`${current.installDir}.staging-`);
130
136
  const locations = [current, legacy];
@@ -134,8 +140,13 @@ async function install(options = {}) {
134
140
  let touched = false;
135
141
  try {
136
142
  fs.cpSync(path.join(repoRoot, 'src'), path.join(stage, 'src'), { recursive: true });
143
+ fs.mkdirSync(path.join(stage, 'bin'));
144
+ fs.copyFileSync(path.join(repoRoot, 'bin/codex-ringside.cjs'), path.join(stage, 'bin/codex-ringside.cjs'));
145
+ if (checked.bundle) fs.copyFileSync(path.join(repoRoot, 'bin/codex-ringside'), path.join(stage, 'bin/codex-ringside'));
137
146
  for (const name of ['package.json', 'LICENSE', 'NOTICE']) fs.copyFileSync(path.join(repoRoot, name), path.join(stage, name));
138
- await (options.compile ?? compile)(repoRoot, stage);
147
+ if (checked.bundle) copyBundle(repoRoot, stage);
148
+ else await (options.compile ?? compile)(repoRoot, stage);
149
+ options.beforeInstall?.();
139
150
  fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
140
151
  backup = fs.mkdtempSync(path.join(backupRoot, 'install-'));
141
152
  snapshots = capture(locations, backup);
@@ -194,6 +205,7 @@ async function install(options = {}) {
194
205
  async function uninstall(options = {}) {
195
206
  const { current, legacy, trashRoot, services } = { ...defaults, ...options };
196
207
  return withLock(current, async () => {
208
+ await services.bootout(launchd.UPDATE_LABEL);
197
209
  const locations = [current, legacy];
198
210
  const running = locations.map(location => !!services.printJob(location.label));
199
211
  const items = locations.flatMap((location, i) => launchd.trashItems(location)
@@ -219,4 +231,4 @@ async function uninstall(options = {}) {
219
231
  });
220
232
  }
221
233
 
222
- module.exports = { install, uninstall, selectSettings };
234
+ module.exports = { install, uninstall, selectSettings, withLock };
package/src/launchd.cjs CHANGED
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const { execFileSync } = require('node:child_process');
6
6
 
7
7
  const LABEL = 'com.codexringside.agent';
8
+ const UPDATE_LABEL = 'com.codexringside.update';
8
9
  const home = os.homedir();
9
10
  const paths = {
10
11
  installDir: path.join(home, 'Library/Application Support/CodexRingside'),
@@ -59,6 +60,19 @@ function agentConfig({ node, installDir = paths.installDir, logDir = paths.logDi
59
60
  };
60
61
  }
61
62
 
63
+ // An independent, one-shot job survives bootout of the agent it is upgrading.
64
+ function updateConfig({ node, installDir = paths.installDir, logDir = paths.logDir }) {
65
+ return {
66
+ Label: UPDATE_LABEL,
67
+ ProgramArguments: [node, path.join(installDir, 'src/update.cjs'), '--auto'],
68
+ RunAtLoad: true,
69
+ ProcessType: 'Background',
70
+ EnvironmentVariables: { NODE_OPTIONS: '', NODE_PATH: '' },
71
+ StandardOutPath: path.join(logDir, 'update.log'),
72
+ StandardErrorPath: path.join(logDir, 'update.err.log')
73
+ };
74
+ }
75
+
62
76
  const launchctl = (...args) => execFileSync('/bin/launchctl', args, {
63
77
  encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe']
64
78
  });
@@ -88,4 +102,4 @@ function writePlist(config, file = paths.plist) {
88
102
  execFileSync('/usr/bin/plutil', ['-lint', file], { stdio: 'ignore' });
89
103
  }
90
104
 
91
- module.exports = { LABEL, LEGACY_LABEL, legacyPaths, agentConfig, bootout, bootstrap, paths, plistXml, printJob, trashItems, writePlist };
105
+ module.exports = { LABEL, UPDATE_LABEL, LEGACY_LABEL, legacyPaths, agentConfig, updateConfig, bootout, bootstrap, paths, plistXml, printJob, trashItems, writePlist };
package/src/limits.cjs CHANGED
@@ -70,6 +70,8 @@ function mergeSnapshot(current, patch) {
70
70
  return {
71
71
  ...base,
72
72
  ...patch,
73
+ credits: patch.credits === undefined ? base?.credits ?? null
74
+ : isRecord(patch.credits) ? { ...base?.credits, ...patch.credits } : null,
73
75
  primary: mergeWindow(base?.primary, patch.primary),
74
76
  secondary: mergeWindow(base?.secondary, patch.secondary)
75
77
  };
@@ -81,7 +83,10 @@ function mergeResponse(current, patch) {
81
83
  if (!isRecord(patch)) return null;
82
84
  const changedAccount = hasField(patch, 'accountId') && hasField(current, 'accountId')
83
85
  && patch.accountId !== current.accountId;
84
- const base = changedAccount ? {} : current ?? {};
86
+ const oldPlan = pickCodexSnapshot(current)?.planType;
87
+ const newPlan = pickCodexSnapshot(patch)?.planType;
88
+ const changedPlan = oldPlan && newPlan && oldPlan !== newPlan;
89
+ const base = changedAccount || changedPlan ? {} : current ?? {};
85
90
  const merged = { ...base, ...patch };
86
91
  merged.rateLimits = base.rateLimits ?? null;
87
92
  if (isRecord(base.rateLimitsByLimitId)) merged.rateLimitsByLimitId = base.rateLimitsByLimitId;
@@ -122,13 +127,33 @@ function formatUsage(response) {
122
127
  const percent = remainingPercent(window.usedPercent);
123
128
  return { label: labels.short, name: labels.long, percent, tone: tone(percent), resetsAt: formatResetTime(window.resetsAt) };
124
129
  });
130
+ const credits = formatCredits(snapshot?.credits);
125
131
  return {
126
132
  rings,
127
- message: rings.length ? null : '暂时无法读取 Codex 剩余额度',
133
+ plan: formatPlan(snapshot?.planType),
134
+ credits,
135
+ message: rings.length || credits ? null : '暂时无法读取 Codex 剩余额度',
128
136
  note: resetCreditsNote(response)
129
137
  };
130
138
  }
131
139
 
140
+ function formatPlan(plan) {
141
+ if (typeof plan !== 'string' || !plan.trim()) return null;
142
+ const labels = { free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', team: 'Team', business: 'Business',
143
+ enterprise: 'Enterprise', edu: 'Edu' };
144
+ return Object.hasOwn(labels, plan) ? labels[plan] : plan;
145
+ }
146
+
147
+ function formatCredits(credits) {
148
+ if (!isRecord(credits)) return null;
149
+ if (credits.unlimited === true) return { unlimited: true, balance: null, label: '不限额' };
150
+ const raw = credits.balance;
151
+ if (typeof raw !== 'number' && (typeof raw !== 'string' || !raw.trim())) return null;
152
+ const balance = Number(raw);
153
+ if (!Number.isFinite(balance) || balance < 0) return null;
154
+ return { unlimited: false, balance, label: new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(balance) };
155
+ }
156
+
132
157
  function resetCreditsNote(response) {
133
158
  const count = Number(response?.rateLimitResetCredits?.availableCount);
134
159
  return Number.isFinite(count) && count > 0 ? `重置卡:${Math.floor(count)} 张可用` : null;
@@ -139,6 +164,9 @@ function unavailable(value, message = '暂时无法读取 Codex 剩余额度,
139
164
  ...value,
140
165
  message,
141
166
  stale: true,
167
+ credits: null,
168
+ plan: null,
169
+ note: null,
142
170
  rings: (value?.rings ?? []).map(ring => ({ ...ring, percent: null, tone: 'muted', resetsAt: null }))
143
171
  };
144
172
  }