dsh-usage-mini 0.2.2

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/lib/client.js ADDED
@@ -0,0 +1,1495 @@
1
+ /**
2
+ * dsh-usage-mini — floating usage mini-window for the dsh web GUI, with a
3
+ * control card in the DSH Settings page (显示/隐藏开关 + 立即刷新).
4
+ *
5
+ * Content rules (per user request):
6
+ * - Subscription models (ChatGPT/Codex, Claude via dsh-plugin-subscriptions):
7
+ * show each usage window's used/remaining percent and the next reset time.
8
+ * NEVER show balance or today's money spend here.
9
+ * - API-billed DeepSeek Flash (official open platform): show today's spend
10
+ * (money) and the account balance. Uses dsh-cost-meter's ledger + official
11
+ * balance cache through the Typert HTTP gateway.
12
+ *
13
+ * Data transport is plain same-origin POST (no ctx services for data):
14
+ * - Typert unary: POST /api/<service>/<method>
15
+ * - Subscriptions: POST /subscriptions-auth/<endpoint> (Connection RPC)
16
+ * Both answer the client-request envelope:
17
+ * { type:'client-request', rpcId, method, payload } →
18
+ * { type:'server-response', rpcId, result:{ ok:true, value } | { ok:false, error } }
19
+ *
20
+ * The DSH Settings card is registered through the standard `settings.section`
21
+ * slot (ctx.slots), same mechanism cost-meter/subscriptions use.
22
+ */
23
+ window.__ModuleLoader__.load({
24
+ id: 'dsh-usage-mini',
25
+ factory: (require) => {
26
+ var module = { exports: {} }
27
+ var exports = module.exports
28
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
29
+
30
+ const React = require('react')
31
+
32
+ const UID = 'dsh-usage-mini'
33
+ const LS_UI = 'dsh-usage-mini:ui'
34
+ const AUTO_REFRESH_MS = 60 * 1000
35
+ const EVT_SET = 'dsh-usage-mini:set' // { visible?: boolean }
36
+ const EVT_REFRESH = 'dsh-usage-mini:refresh' // { force?: boolean }
37
+ const EVT_LOCALE = 'dsh-usage-mini:locale' // fired after the host <html lang> changes
38
+
39
+ // ── feedback (opens a pre-filled GitHub issue; no network call from here) ──
40
+ const PLUGIN_VERSION = '0.2.2'
41
+ const FEEDBACK_REPO = 'teethyachi/dsh-usage-mini'
42
+ const FEEDBACK_MAX_CHARS = 500
43
+ const FEEDBACK_COOLDOWN_MS = 60 * 1000
44
+ const LS_FEEDBACK = 'dsh-usage-mini:feedback'
45
+
46
+ // ── locale ───────────────────────────────────────────────────────────────
47
+ // The plugin follows the host: DSH Web sets <html lang> to "zh-CN" or "en"
48
+ // and updates it live when the user switches Settings → Language. No own
49
+ // preference, no own switch. Fallback chain: <html lang> → navigator.language → en.
50
+
51
+ const STR = {
52
+ zh: {
53
+ title: '用量小窗',
54
+ feedbackPrompt: '给点儿意见?',
55
+ feedbackAria: '反馈',
56
+ feedbackSend: '发送',
57
+ feedbackSendTitle: '在 GitHub 打开预填好的反馈(需 GitHub 登录)',
58
+ feedbackEmpty: '写一句就行,哪怕只是「这里看不懂」。',
59
+ feedbackWait: (s) => `慢一点,${s} 秒后再发。`,
60
+ feedbackOpened: '已在 GitHub 打开,点「Submit」即可。谢谢!',
61
+ feedbackBlocked: '弹窗被拦截:',
62
+ feedbackOpenHere: '点这里打开',
63
+ feedbackTitlePrefix: '[反馈] ',
64
+ rpcMissing: (s) => `RPC 通道不存在 (HTTP ${s})`,
65
+ rpcBadEnvelope: 'RPC 响应异常',
66
+ rpcFailed: 'RPC 调用失败',
67
+ resetNow: '即将重置',
68
+ resetUnderMinute: '约 1 分钟内重置',
69
+ resetMinutes: (m) => `约 ${m} 分钟后重置`,
70
+ resetHoursMinutes: (h, m) => `约 ${h} 小时 ${m} 分后重置`,
71
+ resetHours: (h) => `约 ${h} 小时后重置`,
72
+ resetDaysHours: (d, h) => `约 ${d} 天 ${h} 小时后重置`,
73
+ resetDays: (d) => `约 ${d} 天后重置`,
74
+ costMeterEmpty: 'cost-meter 返回了空状态',
75
+ refreshNow: '立即刷新',
76
+ minimize: '最小化收起',
77
+ headTitle: '拖拽移动 · 60 秒自动刷新',
78
+ secSubs: '订阅模型',
79
+ secSubsTag: '仅用量与重置 · 不显示余额/花费',
80
+ secApi: 'DeepSeek Flash(官方 API)',
81
+ tabTitle: '点我展开用量小窗',
82
+ tabExpand: '展开用量小窗',
83
+ tipSubsUnknown: '订阅用量未知',
84
+ tipSubsCritical: (p) => `订阅用量即将用尽:已用 ${p}%`,
85
+ tipSubsTight: (p) => `订阅额度偏紧:已用 ${p}%`,
86
+ tipSubsOk: (p) => `订阅用量正常:最高已用 ${p}%`,
87
+ tipApiToday: (v) => `DeepSeek 今日 ${v}`,
88
+ loading: '加载中…',
89
+ subsNotSignedIn: '未登录订阅账户(设置 → Subscriptions 登录 ChatGPT/Claude)',
90
+ subsQueryFailed: (e) => `用量查询失败:${e}`,
91
+ subsNoWindows: '该账户未返回用量窗口',
92
+ subsNoData: '暂无用量窗口数据',
93
+ winSession: '5 小时窗口',
94
+ winWeekly: '本周窗口',
95
+ winGeneric: '窗口',
96
+ used: ' 已用',
97
+ remaining: (p) => `剩余 ${p}%`,
98
+ resetAt: (d) => ` · 重置 ${d}`,
99
+ resetUnknown: ' · 重置时间未知',
100
+ balanceFailed: '余额查询失败(可点刷新重试)',
101
+ balanceUnconfigured: '余额未配置或未启用(今日消耗仍按账本显示)',
102
+ todayCallsTip: (n) => `今日官方调用 ${n} 次`,
103
+ flashTip: (n, v) => `deepseek-v4-flash ${n} 次 / ${v}`,
104
+ todaySpend: '今日消耗(官方)',
105
+ todayCalls: '今日调用',
106
+ callsUnit: (n) => `${n} 次`,
107
+ granted: (v) => `赠送 ${v}`,
108
+ toppedUp: (v) => `充值 ${v}`,
109
+ balanceTotal: '余额(总)',
110
+ updatedAt: (t) => `更新于 ${t}`,
111
+ autoTag: '60s 自动',
112
+ subsChannelMissing: '订阅用量通道不可用(未安装/未启用 dsh-plugin-subscriptions)',
113
+ subsFetchFailed: (e) => `订阅用量获取失败:${e}`,
114
+ apiChannelMissing: '费用/余额通道不可用(未安装/未启用 dsh-cost-meter)',
115
+ apiFetchFailed: (e) => `DeepSeek 用量获取失败:${e}`,
116
+ refreshing: '刷新中…',
117
+ settingsDesc: '右下角用量小窗:订阅模型用量与重置时间、DeepSeek Flash 今日消耗与余额。',
118
+ settingsDescSmall: '订阅区不显示余额与花费;窗口可拖拽/收起。开关状态记录在此浏览器。',
119
+ settingsShow: '显示用量小窗',
120
+ settingsShownHint: '窗口当前显示在右下角',
121
+ settingsHiddenHint: '窗口已隐藏,打开后立即刷新',
122
+ settingsHide: '隐藏小窗',
123
+ settingsShowShort: '显示小窗',
124
+ settingsRefreshHint: '强制重新查询订阅用量与官方余额',
125
+ },
126
+ en: {
127
+ title: 'Usage Window',
128
+ feedbackPrompt: 'Any feedback?',
129
+ feedbackAria: 'Feedback',
130
+ feedbackSend: 'Send',
131
+ feedbackSendTitle: 'Opens a pre-filled GitHub issue (GitHub sign-in required)',
132
+ feedbackEmpty: 'One line is enough, even just "this part is unclear".',
133
+ feedbackWait: (s) => `Easy there — try again in ${s}s.`,
134
+ feedbackOpened: 'Opened on GitHub. Press Submit and you’re done. Thanks!',
135
+ feedbackBlocked: 'Pop-up blocked: ',
136
+ feedbackOpenHere: 'open it here',
137
+ feedbackTitlePrefix: '[feedback] ',
138
+ rpcMissing: (s) => `RPC channel missing (HTTP ${s})`,
139
+ rpcBadEnvelope: 'Malformed RPC response',
140
+ rpcFailed: 'RPC call failed',
141
+ resetNow: 'resets now',
142
+ resetUnderMinute: 'resets in under a minute',
143
+ resetMinutes: (m) => `resets in ~${m} min`,
144
+ resetHoursMinutes: (h, m) => `resets in ~${h}h ${m}m`,
145
+ resetHours: (h) => `resets in ~${h}h`,
146
+ resetDaysHours: (d, h) => `resets in ~${d}d ${h}h`,
147
+ resetDays: (d) => `resets in ~${d}d`,
148
+ costMeterEmpty: 'cost-meter returned an empty state',
149
+ refreshNow: 'Refresh now',
150
+ minimize: 'Minimize',
151
+ headTitle: 'Drag to move · auto-refresh every 60s',
152
+ secSubs: 'Subscriptions',
153
+ secSubsTag: 'usage & resets only · no balance / spend',
154
+ secApi: 'DeepSeek Flash (official API)',
155
+ tabTitle: 'Click to expand the usage window',
156
+ tabExpand: 'Expand usage window',
157
+ tipSubsUnknown: 'Subscription usage unknown',
158
+ tipSubsCritical: (p) => `Subscription almost exhausted: ${p}% used`,
159
+ tipSubsTight: (p) => `Subscription running tight: ${p}% used`,
160
+ tipSubsOk: (p) => `Subscription OK: peak ${p}% used`,
161
+ tipApiToday: (v) => `DeepSeek today ${v}`,
162
+ loading: 'Loading…',
163
+ subsNotSignedIn: 'No subscription signed in (Settings → Subscriptions → sign in to ChatGPT / Claude)',
164
+ subsQueryFailed: (e) => `Usage query failed: ${e}`,
165
+ subsNoWindows: 'This account returned no usage windows',
166
+ subsNoData: 'No usage window data yet',
167
+ winSession: '5-hour window',
168
+ winWeekly: 'Weekly window',
169
+ winGeneric: 'Window',
170
+ used: ' used',
171
+ remaining: (p) => `${p}% left`,
172
+ resetAt: (d) => ` · resets ${d}`,
173
+ resetUnknown: ' · reset time unknown',
174
+ balanceFailed: 'Balance query failed (press refresh to retry)',
175
+ balanceUnconfigured: 'Balance not configured or disabled (today’s spend still comes from the ledger)',
176
+ todayCallsTip: (n) => `${n} official calls today`,
177
+ flashTip: (n, v) => `deepseek-v4-flash ${n} calls / ${v}`,
178
+ todaySpend: 'Today’s spend (official)',
179
+ todayCalls: 'Calls today',
180
+ callsUnit: (n) => `${n}`,
181
+ granted: (v) => `granted ${v}`,
182
+ toppedUp: (v) => `topped up ${v}`,
183
+ balanceTotal: 'Balance (total)',
184
+ updatedAt: (t) => `updated ${t}`,
185
+ autoTag: 'auto 60s',
186
+ subsChannelMissing: 'Subscription usage channel unavailable (dsh-plugin-subscriptions not installed / enabled)',
187
+ subsFetchFailed: (e) => `Subscription usage fetch failed: ${e}`,
188
+ apiChannelMissing: 'Cost / balance channel unavailable (dsh-cost-meter not installed / enabled)',
189
+ apiFetchFailed: (e) => `DeepSeek usage fetch failed: ${e}`,
190
+ refreshing: 'Refreshing…',
191
+ settingsDesc: 'Bottom-right usage window: subscription usage and reset times, DeepSeek Flash spend and balance for today.',
192
+ settingsDescSmall: 'The subscription section never shows balance or spend. Drag / collapse the window; the toggle is remembered in this browser.',
193
+ settingsShow: 'Show usage window',
194
+ settingsShownHint: 'The window is currently shown at the bottom right',
195
+ settingsHiddenHint: 'The window is hidden; it refreshes as soon as it opens',
196
+ settingsHide: 'Hide window',
197
+ settingsShowShort: 'Show window',
198
+ settingsRefreshHint: 'Force a fresh query of subscription usage and the official balance',
199
+ },
200
+ }
201
+
202
+ function detectLocale() {
203
+ let tag = ''
204
+ try { tag = (document.documentElement && document.documentElement.lang) || '' } catch { /* noop */ }
205
+ if (!tag) { try { tag = (navigator && navigator.language) || '' } catch { /* noop */ } }
206
+ return /^zh/i.test(String(tag)) ? 'zh' : 'en'
207
+ }
208
+
209
+ let locale = 'en'
210
+
211
+ function t(key, ...args) {
212
+ const dict = STR[locale] || STR.en
213
+ const v = dict[key] !== undefined ? dict[key] : STR.en[key]
214
+ if (typeof v === 'function') return v(...args)
215
+ return v === undefined ? key : v
216
+ }
217
+
218
+ const PROVIDERS = [
219
+ { id: 'codex', name: 'Codex (ChatGPT)' },
220
+ { id: 'claude', name: 'Claude' },
221
+ ]
222
+ const OFFICIAL_PROVIDERS = new Set(['deepseek', 'deepseek-official'])
223
+ const FLASH_MODEL = 'deepseek-v4-flash'
224
+
225
+ // Corner-dock geometry.
226
+ const CORNER_GAP = 14 // collapsed bar inset from the screen edge.
227
+ const EXPAND_GAP = 16 // expanded window inset when corner-anchored.
228
+ const CORNER_ID = { bl: 'bl', br: 'br', tl: 'tl', tr: 'tr' }
229
+
230
+ // ── tiny helpers ─────────────────────────────────────────────────────────
231
+
232
+ const num = (v) => {
233
+ const n = Number(v)
234
+ return Number.isFinite(n) && n > 0 ? n : 0
235
+ }
236
+
237
+ function uuid() {
238
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
239
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
240
+ const r = (Math.random() * 16) | 0
241
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
242
+ })
243
+ }
244
+
245
+ function el(tag, props, ...children) {
246
+ const node = document.createElement(tag)
247
+ if (props) {
248
+ for (const key of Object.keys(props)) {
249
+ const value = props[key]
250
+ if (key === 'class') node.className = value
251
+ else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value)
252
+ else if (key === 'title' || key === 'data-tip') node.title = value
253
+ else if (key.startsWith('on') && typeof value === 'function') {
254
+ node.addEventListener(key.slice(2).toLowerCase(), value)
255
+ } else if (value !== null && value !== undefined && value !== false) {
256
+ node.setAttribute(key, value === true ? '' : String(value))
257
+ }
258
+ }
259
+ }
260
+ for (const child of children) {
261
+ if (child === null || child === undefined || child === false) continue
262
+ node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child)
263
+ }
264
+ return node
265
+ }
266
+
267
+ async function rpcPost(url, method, payload) {
268
+ const rpcId = uuid()
269
+ const res = await fetch(url, {
270
+ method: 'POST',
271
+ headers: { 'content-type': 'application/json' },
272
+ body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
273
+ })
274
+ // 404: no plugin registered the path. 405: the host has the path but no
275
+ // handler accepts POST (seen on a bare web profile). Both mean "not installed".
276
+ if (res.status === 404 || res.status === 405) {
277
+ const err = new Error(t('rpcMissing', res.status))
278
+ err.notFound = true
279
+ throw err
280
+ }
281
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
282
+ const env = await res.json()
283
+ if (!env || env.type !== 'server-response' || env.rpcId !== rpcId) throw new Error(t('rpcBadEnvelope'))
284
+ const result = env.result
285
+ if (!result || result.ok !== true) {
286
+ const msg = result && result.error && result.error.message ? result.error.message : t('rpcFailed')
287
+ throw new Error(msg)
288
+ }
289
+ return result.value
290
+ }
291
+
292
+ function defaultConfig() {
293
+ return { symbol: '¥', decimals: 4, exchangeRate: 7.2, currency: 'CNY' }
294
+ }
295
+
296
+ /** Display a value already in the display/bookkeeping currency (e.g. official balance CNY). */
297
+ function fmtMoneyValue(value, cfg) {
298
+ const symbol = (cfg && typeof cfg.symbol === 'string' && cfg.symbol.length > 0) ? cfg.symbol : '¥'
299
+ const decimals = Math.max(0, Math.min(10, Math.floor(Number(cfg && cfg.decimals) || 2)))
300
+ let effective = decimals
301
+ if (value > 0 && value < Math.pow(10, -decimals)) effective = decimals + 2
302
+ let fixed = value.toFixed(effective)
303
+ if (fixed.includes('.')) fixed = fixed.replace(/0+$/, '').replace(/\.$/, '')
304
+ return symbol + fixed
305
+ }
306
+
307
+ /** Convert an USD ledger amount to the display currency and format it. */
308
+ function fmtUsd(usd, cfg) {
309
+ const rate = Number(cfg && cfg.exchangeRate)
310
+ const value = usd * (Number.isFinite(rate) && rate > 0 ? rate : 1)
311
+ return fmtMoneyValue(value, cfg)
312
+ }
313
+
314
+ /** Official balance is already in the bookkeeping currency — no rate conversion. */
315
+ function fmtBalance(value, cfg) {
316
+ return fmtMoneyValue(value, cfg)
317
+ }
318
+
319
+ function fmtReset(epochMs) {
320
+ if (!Number.isFinite(epochMs) || epochMs <= 0) return { date: '', rel: '' }
321
+ const d = new Date(epochMs)
322
+ const pad = (x) => String(x).padStart(2, '0')
323
+ const date = `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
324
+ const diffSec = Math.round((epochMs - Date.now()) / 1000)
325
+ let rel = ''
326
+ if (diffSec <= 0) rel = t('resetNow')
327
+ else if (diffSec < 60) rel = t('resetUnderMinute')
328
+ else if (diffSec < 3600) rel = t('resetMinutes', Math.ceil(diffSec / 60))
329
+ else if (diffSec < 86400) {
330
+ const h = Math.floor(diffSec / 3600)
331
+ const m = Math.round((diffSec % 3600) / 60)
332
+ rel = m > 0 ? t('resetHoursMinutes', h, m) : t('resetHours', h)
333
+ } else {
334
+ const days = Math.floor(diffSec / 86400)
335
+ const h = Math.round((diffSec % 86400) / 3600)
336
+ rel = h > 0 ? t('resetDaysHours', days, h) : t('resetDays', days)
337
+ }
338
+ return { date, rel }
339
+ }
340
+
341
+ function barColor(usedPercent) {
342
+ if (usedPercent >= 95) return 'var(--dsw-alias-state-error-primary, #f85149)'
343
+ if (usedPercent >= 80) return 'var(--dsw-alias-state-warn-primary, #f5a623)'
344
+ return 'var(--dsw-alias-state-success-primary, #3fb950)'
345
+ }
346
+
347
+ function clockText(date) {
348
+ const pad = (x) => String(x).padStart(2, '0')
349
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
350
+ }
351
+
352
+ // ── preferences (per browser; visible default ON) ────────────────────────
353
+
354
+ let uiPrefs = { visible: true, collapsed: false, collapsedCorner: 'br', expand: { mode: 'edge', corner: 'br' }, left: null, top: null }
355
+
356
+ function loadPrefs() {
357
+ try {
358
+ const raw = window.localStorage.getItem(LS_UI)
359
+ if (raw) {
360
+ const parsed = JSON.parse(raw)
361
+ const exp = parsed.expand && typeof parsed.expand === 'object' ? parsed.expand : {}
362
+ uiPrefs = {
363
+ visible: typeof parsed.visible === 'boolean' ? parsed.visible : true,
364
+ collapsed: parsed.collapsed === true,
365
+ collapsedCorner: typeof parsed.collapsedCorner === 'string' && parsed.collapsedCorner in CORNER_ID
366
+ ? parsed.collapsedCorner
367
+ : 'br',
368
+ expand: {
369
+ mode: exp.mode === 'custom' ? 'custom' : 'edge',
370
+ corner: typeof exp.corner === 'string' && exp.corner in CORNER_ID ? exp.corner : 'br',
371
+ left: typeof exp.left === 'number' && Number.isFinite(exp.left) ? exp.left : null,
372
+ top: typeof exp.top === 'number' && Number.isFinite(exp.top) ? exp.top : null,
373
+ },
374
+ left: typeof parsed.left === 'number' && Number.isFinite(parsed.left) ? parsed.left : null,
375
+ top: typeof parsed.top === 'number' && Number.isFinite(parsed.top) ? parsed.top : null,
376
+ }
377
+ }
378
+ } catch { /* storage unavailable: keep defaults */ }
379
+ return uiPrefs
380
+ }
381
+
382
+ function savePrefs() {
383
+ try { window.localStorage.setItem(LS_UI, JSON.stringify(uiPrefs)) } catch { /* ignore */ }
384
+ }
385
+
386
+ function setVisiblePref(visible) {
387
+ uiPrefs.visible = visible === true
388
+ savePrefs()
389
+ }
390
+
391
+ // ── feedback helpers ─────────────────────────────────────────────────────
392
+ // Pure: builds the GitHub "new issue" URL. The only user-derived payload is
393
+ // the trimmed feedback text; the rest is the plugin version. Never include
394
+ // usage numbers, balances, account identifiers or tokens.
395
+
396
+ function buildFeedbackUrl(text, version) {
397
+ const body = String(text || '').trim().slice(0, FEEDBACK_MAX_CHARS)
398
+ if (!body) return null
399
+ const ver = String(version || PLUGIN_VERSION)
400
+ const params = new URLSearchParams()
401
+ params.set('template', 'feedback.yml')
402
+ params.set('labels', 'feedback')
403
+ params.set('title', t('feedbackTitlePrefix') + body.split('\n')[0].slice(0, 60))
404
+ params.set('feedback', body)
405
+ params.set('version', ver)
406
+ return 'https://github.com/' + FEEDBACK_REPO + '/issues/new?' + params.toString()
407
+ }
408
+
409
+ function loadFeedbackState() {
410
+ try {
411
+ const raw = window.localStorage.getItem(LS_FEEDBACK)
412
+ if (raw) {
413
+ const p = JSON.parse(raw)
414
+ return {
415
+ lastAt: typeof p.lastAt === 'number' ? p.lastAt : 0,
416
+ count: typeof p.count === 'number' ? p.count : 0,
417
+ }
418
+ }
419
+ } catch { /* ignore */ }
420
+ return { lastAt: 0, count: 0 }
421
+ }
422
+
423
+ function saveFeedbackState(s) {
424
+ try { window.localStorage.setItem(LS_FEEDBACK, JSON.stringify(s)) } catch { /* ignore */ }
425
+ }
426
+
427
+ // ── styles ────────────────────────────────────────────────────────────────
428
+
429
+ const css = `
430
+ #${UID}-root{position:fixed;right:16px;bottom:16px;z-index:2147483000;width:min(302px,calc(100vw - 24px));
431
+ font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--dsw-alias-label-primary,#e6e9f0);
432
+ background:var(--dsw-alias-bg-layer-2,rgba(22,24,30,.94));border:1px solid var(--dsw-alias-stroke-layer-2,rgba(255,255,255,.09));
433
+ border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,.35);backdrop-filter:blur(8px);user-select:none;overflow:hidden}
434
+ #${UID}-root *{box-sizing:border-box}
435
+ .um-head{display:flex;align-items:center;gap:6px;height:30px;padding:0 8px 0 10px;cursor:grab;border-bottom:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.06))}
436
+ .um-head:active{cursor:grabbing}
437
+ .um-title{font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary,#e6e9f0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
438
+ .um-title .dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary,#3fb950);margin-right:6px;vertical-align:1px}
439
+ .um-btn{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#c3c9d6);cursor:pointer;font-size:12px;line-height:1;padding:0}
440
+ .um-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary,#e6e9f0)}
441
+ .um-btn.spin svg{animation:um-spin 0.8s linear infinite}
442
+ @keyframes um-spin{to{transform:rotate(360deg)}}
443
+ .um-body{padding:8px 10px 6px;max-height:min(70vh,560px);overflow:auto}
444
+ .um-body::-webkit-scrollbar{width:6px}
445
+ .um-body::-webkit-scrollbar-thumb{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.1));border-radius:3px}
446
+ .um-sec{margin-bottom:8px}
447
+ .um-sec:last-child{margin-bottom:0}
448
+ .um-sec-title{display:flex;align-items:baseline;gap:6px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#c3c9d6);margin:2px 0 5px}
449
+ .um-sec-title .um-tag{font-size:10px;font-weight:400;color:var(--dsw-alias-label-tertiary,#8b93a7)}
450
+ .um-card{border:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.07));border-radius:9px;background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.025));padding:6px 8px;margin-bottom:6px}
451
+ .um-card:last-child{margin-bottom:0}
452
+ .um-card-head{display:flex;align-items:baseline;gap:6px;font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary,#e6e9f0);margin-bottom:4px}
453
+ .um-card-head .sub{font-weight:400;font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;text-align:right}
454
+ .um-row{display:flex;align-items:center;gap:6px;margin:3px 0 1px}
455
+ .um-row .lbl{flex:none;width:86px;color:var(--dsw-alias-label-secondary,#c3c9d6);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
456
+ .um-bar{flex:1;height:5px;border-radius:3px;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.09));overflow:hidden}
457
+ .um-fill{height:100%;border-radius:3px}
458
+ .um-pct{flex:none;min-width:46px;text-align:right;font-size:11px;color:var(--dsw-alias-label-secondary,#c3c9d6);font-variant-numeric:tabular-nums}
459
+ .um-row .um-pct strong{color:var(--dsw-alias-label-primary,#e6e9f0)}
460
+ .um-sub{font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);margin:0 0 2px;font-variant-numeric:tabular-nums}
461
+ .um-sub .ok{color:var(--dsw-alias-state-success-primary,#3fb950)}
462
+ .um-kv{display:flex;justify-content:space-between;gap:8px;padding:2px 0;font-size:11px}
463
+ .um-kv .k{color:var(--dsw-alias-label-secondary,#c3c9d6)}
464
+ .um-kv .v{color:var(--dsw-alias-label-primary,#e6e9f0);font-variant-numeric:tabular-nums;font-weight:600}
465
+ .um-kv .v.dim{color:var(--dsw-alias-label-tertiary,#8b93a7);font-weight:400}
466
+ .um-note{font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);padding:2px 0;line-height:1.5}
467
+ .um-note.err{color:var(--dsw-alias-state-error-primary,#f85149)}
468
+ .um-note.warn{color:var(--dsw-alias-state-warn-primary,#f5a623)}
469
+ .um-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);padding:4px 0 0;border-top:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.05));margin-top:2px}
470
+ /* ---- minimized: shrink down into a slim bar (plugin name + expand key) ---- */
471
+ #${UID}-root{--um-tab-dot:#3fb950}
472
+ #${UID}-root.collapsed{left:auto;top:auto;right:auto;bottom:auto;width:auto;height:auto;background:var(--dsw-alias-bg-layer-2,rgba(22,24,30,.95));border:1px solid var(--dsw-alias-stroke-layer-2,rgba(255,255,255,.10));border-radius:999px;box-shadow:0 6px 18px rgba(0,0,0,.35);backdrop-filter:blur(8px);overflow:hidden}
473
+ #${UID}-root.collapsed .um-head,#${UID}-root.collapsed .um-body,#${UID}-root.collapsed .um-foot{display:none}
474
+ #${UID}-root .um-tab{display:none}
475
+ #${UID}-root.collapsed .um-tab{display:flex}
476
+ .um-tab{align-items:center;gap:8px;height:34px;padding:0 6px 0 13px;cursor:grab;white-space:nowrap;box-sizing:border-box}
477
+ .um-tab:active{cursor:grabbing}
478
+ .um-tab-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--um-tab-dot,#3fb950);animation:um-blink 1.9s ease-in-out infinite}
479
+ @keyframes um-blink{0%,100%{opacity:1}50%{opacity:.3}}
480
+ .um-tab-name{flex:none;font-weight:600;font-size:12px;color:var(--dsw-alias-label-primary,#e6e9f0);line-height:1}
481
+ .um-tab-open{flex:none;position:relative;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;border:0;color:#fff;cursor:pointer;background:radial-gradient(120% 130% at 28% 18%,rgba(255,255,255,.32),rgba(255,255,255,0) 46%),linear-gradient(155deg,var(--dsw-alias-brand-primary,#4c8dff),#2e5fd7);box-shadow:0 0 8px rgba(80,140,255,.55),0 2px 6px rgba(10,20,60,.35);transition:transform .15s ease,box-shadow .15s ease;animation:um-breathe 2.4s ease-in-out infinite}
482
+ .um-tab-open:hover{transform:scale(1.14);box-shadow:0 0 14px rgba(95,155,255,.95),0 2px 8px rgba(10,20,60,.4)}
483
+ .um-tab-open:active{transform:scale(.94)}
484
+ .um-tab-open svg{width:13px;height:13px}
485
+ @keyframes um-breathe{0%,100%{box-shadow:0 0 6px rgba(80,140,255,.4),0 2px 6px rgba(10,20,60,.3)}50%{box-shadow:0 0 15px rgba(95,155,255,.95),0 2px 7px rgba(10,20,60,.38)}}
486
+ /* ---- feedback bar (expanded window only) ---- */
487
+ .um-fb{display:flex;align-items:center;gap:6px;padding:6px 0 0;margin-top:6px;border-top:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.05))}
488
+ .um-fb-input{flex:1;min-width:0;height:24px;padding:0 8px;border-radius:7px;border:1px solid var(--dsw-alias-stroke-layer-2,rgba(255,255,255,.10));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,#e6e9f0);font:11px/1 inherit;font-family:inherit;outline:none;user-select:text}
489
+ .um-fb-input::placeholder{color:var(--dsw-alias-label-tertiary,#8b93a7);font-style:italic;opacity:.8}
490
+ .um-fb-input:focus{border-color:var(--dsw-alias-brand-primary,#4c8dff)}
491
+ .um-fb-send{flex:none;height:24px;padding:0 9px;border-radius:7px;border:0;background:var(--dsw-alias-brand-primary,#4c8dff);color:#fff;font-size:11px;cursor:pointer;white-space:nowrap}
492
+ .um-fb-send:disabled{opacity:.45;cursor:default}
493
+ .um-fb-send:not(:disabled):hover{filter:brightness(1.1)}
494
+ .um-fb-msg{font-size:10px;color:var(--dsw-alias-label-tertiary,#8b93a7);padding:3px 0 0;min-height:14px;line-height:1.4}
495
+ .um-fb-msg.ok{color:var(--dsw-alias-state-success-primary,#3fb950)}
496
+ .um-fb-msg a{color:var(--dsw-alias-brand-primary,#4c8dff);text-decoration:none}
497
+ /* ---- settings card ---- */
498
+ .ums-card{font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color:var(--dsw-alias-label-primary,#e6e9f0);max-width:640px}
499
+ .ums-card .ums-desc{color:var(--dsw-alias-label-secondary,#c3c9d6);margin:2px 0 10px}
500
+ .ums-card .ums-desc small{color:var(--dsw-alias-label-tertiary,#8b93a7)}
501
+ .ums-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:7px 0;border-top:1px solid var(--dsw-alias-stroke-layer-1,rgba(255,255,255,.06))}
502
+ .ums-row:first-of-type{border-top:0}
503
+ .ums-row .ums-main{min-width:0}
504
+ .ums-row .ums-name{font-weight:600;color:var(--dsw-alias-label-primary,#e6e9f0)}
505
+ .ums-row .ums-hint{font-size:11px;color:var(--dsw-alias-label-tertiary,#8b93a7)}
506
+ .ums-actions{display:flex;align-items:center;gap:8px;flex:none}
507
+ .ums-switch{position:relative;flex:none;width:34px;height:18px;border-radius:9px;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.12));cursor:pointer;transition:background .15s;border:0;padding:0}
508
+ .ums-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary,#4c8dff)}
509
+ .ums-switch .knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .15s}
510
+ .ums-switch[aria-checked="true"] .knob{left:18px}
511
+ .ums-btn{flex:none;display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 10px;border-radius:7px;border:1px solid var(--dsw-alias-stroke-layer-2,rgba(255,255,255,.12));background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.04));color:var(--dsw-alias-label-primary,#e6e9f0);cursor:pointer;font-size:12px}
512
+ .ums-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.09))}
513
+ .ums-status{font-size:11px;color:var(--dsw-alias-label-tertiary,#8b93a7)}
514
+ `
515
+
516
+ function ensureStyle() {
517
+ if (!document.getElementById(`${UID}-style`)) {
518
+ const style = el('style', { id: `${UID}-style`, 'data-plugin': UID }, css)
519
+ document.head.appendChild(style)
520
+ }
521
+ }
522
+
523
+ // ── data fetching ────────────────────────────────────────────────────────
524
+
525
+ const view = {
526
+ loading: true,
527
+ subsError: null,
528
+ apiError: null,
529
+ api: null,
530
+ subs: null,
531
+ lastAt: 0,
532
+ }
533
+
534
+ async function fetchSubs(force) {
535
+ const status = await rpcPost('/subscriptions-auth/status', 'status', {})
536
+ const byProvider = (status && status.providers) || {}
537
+ const list = []
538
+ for (const meta of PROVIDERS) {
539
+ const entry = byProvider[meta.id]
540
+ const accounts = (entry && Array.isArray(entry.accounts) ? entry.accounts : [])
541
+ const accRows = []
542
+ for (const acc of accounts) {
543
+ const row = {
544
+ key: acc.key,
545
+ label: acc.account || acc.key,
546
+ plan: acc.plan || '',
547
+ windows: [],
548
+ error: null,
549
+ ok: false,
550
+ }
551
+ try {
552
+ const usage = await rpcPost('/subscriptions-auth/usage', 'usage', {
553
+ provider: meta.id,
554
+ account: acc.key,
555
+ ...(force ? { force: true } : {}),
556
+ })
557
+ row.ok = Boolean(usage && usage.supported !== false)
558
+ row.windows = Array.isArray(usage && usage.windows) ? usage.windows : []
559
+ row.plan = row.plan || (usage && usage.plan) || ''
560
+ } catch (err) {
561
+ row.error = err && err.message ? err.message : String(err)
562
+ }
563
+ accRows.push(row)
564
+ }
565
+ list.push({ ...meta, accounts: accRows })
566
+ }
567
+ return list
568
+ }
569
+
570
+ async function fetchApi(force) {
571
+ let stateData = await rpcPost('/api/costMeter/getState', 'costMeter/getState', { args: {} })
572
+ if (!stateData || typeof stateData !== 'object') throw new Error(t('costMeterEmpty'))
573
+ const balanceNow = stateData.balance || {}
574
+ const neverFetched = balanceNow.status !== 'ok' && !(Number(balanceNow.fetchedAt) > 0)
575
+ // Auto-refresh only when the official balance was never fetched yet (fresh boot or
576
+ // cost-meter not opened); otherwise keep the cached snapshot and let the manual
577
+ // refresh button force a live re-check. This avoids hammering the balance API.
578
+ if (force || (balanceNow.status !== 'ok' && neverFetched)) {
579
+ try {
580
+ const refreshed = await rpcPost('/api/costMeter/refreshBalance', 'costMeter/refreshBalance', { args: {} })
581
+ if (refreshed && refreshed.state && typeof refreshed.state === 'object') {
582
+ stateData = refreshed.state
583
+ }
584
+ } catch {
585
+ /* keep the previous snapshot; its balance.message explains the state */
586
+ }
587
+ }
588
+ const today = stateData.today && typeof stateData.today === 'object' ? stateData.today : { cost: 0, calls: 0, byProviderModel: {} }
589
+ const by = (today.byProviderModel && typeof today.byProviderModel === 'object') ? today.byProviderModel : {}
590
+ let officialUsd = 0
591
+ let officialCalls = 0
592
+ let flashUsd = 0
593
+ let flashCalls = 0
594
+ const keys = Object.keys(by)
595
+ for (const key of keys) {
596
+ const idx = key.indexOf(':')
597
+ const provider = idx >= 0 ? key.slice(0, idx) : key
598
+ const model = idx >= 0 ? key.slice(idx + 1) : ''
599
+ if (!OFFICIAL_PROVIDERS.has(provider)) continue
600
+ const entry = by[key] || {}
601
+ const cost = num(entry.cost)
602
+ const calls = num(entry.calls)
603
+ officialUsd += cost
604
+ officialCalls += calls
605
+ if (model === FLASH_MODEL) {
606
+ flashUsd += cost
607
+ flashCalls += calls
608
+ }
609
+ }
610
+ const balance = stateData.balance || { status: 'off', message: '', totalBalance: 0, grantedBalance: 0, toppedUpBalance: 0, fetchedAt: 0 }
611
+ return {
612
+ balance,
613
+ todayUsd: officialUsd,
614
+ todayCalls: officialCalls,
615
+ flashUsd,
616
+ flashCalls,
617
+ hasBreakdown: keys.length > 0,
618
+ config: stateData.config || defaultConfig(),
619
+ }
620
+ }
621
+
622
+ // ── floating window UI (vanilla DOM) ─────────────────────────────────────
623
+
624
+ let root = null
625
+ let headTitle = null
626
+ let subsBox = null
627
+ let apiBox = null
628
+ let footEl = null
629
+ let refreshBtn = null
630
+ let autoTimer = null
631
+ let visibilityHandler = null
632
+ let resizeHandler = null
633
+ let tabBar = null
634
+ let tabNameEl = null
635
+ let tabOpenEl = null
636
+ let secSubsTitle = null
637
+ let secApiTitle = null
638
+ let fbInputEl = null
639
+ let fbSendEl = null
640
+ let headEl = null
641
+ let minimizeBtnEl = null
642
+ let suppressTabClick = false
643
+ let animBusy = false
644
+
645
+ function buildStructure() {
646
+ ensureStyle()
647
+ root = el('div', { id: `${UID}-root`, class: uiPrefs.collapsed ? 'collapsed' : '' })
648
+
649
+ function svgBtn(svg, title, fn) {
650
+ const b = el('button', { class: 'um-btn', title, type: 'button' })
651
+ b.addEventListener('click', (e) => { e.stopPropagation(); fn(e) })
652
+ b.innerHTML = svg
653
+ return b
654
+ }
655
+
656
+ headTitle = el('span', { class: 'um-title' })
657
+ const dot = el('span', { class: 'dot' })
658
+ headTitle.appendChild(dot)
659
+
660
+ const refreshIcon = '<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 1.8v3.4h-3.4"/></svg>'
661
+ refreshBtn = svgBtn(refreshIcon, t('refreshNow'), () => { refresh(true) })
662
+
663
+ const minimizeIcon = '<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 9.5 8 4 2.5 9.5"/><path d="M3.5 12.5h9"/></svg>'
664
+ const minimizeBtn = svgBtn(minimizeIcon, t('minimize'), () => { setCollapsed(true) })
665
+
666
+ const head = el('div', { class: 'um-head', title: t('headTitle') })
667
+ headEl = head
668
+ minimizeBtnEl = minimizeBtn
669
+ head.appendChild(headTitle)
670
+ head.appendChild(minimizeBtn)
671
+ head.appendChild(refreshBtn)
672
+
673
+ const bodyEl = el('div', { class: 'um-body' })
674
+ const sec1 = el('div', { class: 'um-sec' },
675
+ (secSubsTitle = el('div', { class: 'um-sec-title' }, t('secSubs'), el('span', { class: 'um-tag' }, t('secSubsTag')))),
676
+ (subsBox = el('div')),
677
+ )
678
+ const sec2 = el('div', { class: 'um-sec' },
679
+ (secApiTitle = el('div', { class: 'um-sec-title' }, t('secApi'))),
680
+ (apiBox = el('div')),
681
+ )
682
+ bodyEl.appendChild(sec1)
683
+ bodyEl.appendChild(sec2)
684
+
685
+ footEl = el('div', { class: 'um-foot' })
686
+ bodyEl.appendChild(footEl)
687
+ bodyEl.appendChild(buildFeedbackBar())
688
+ root.appendChild(head)
689
+ root.appendChild(bodyEl)
690
+
691
+ // Minimized slim bar: keeps the plugin name and a glowing expand key.
692
+ const expandIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>'
693
+ tabBar = el('div', { class: 'um-tab', title: t('tabTitle') })
694
+ const tabDot = el('span', { class: 'um-tab-dot' })
695
+ tabNameEl = el('span', { class: 'um-tab-name' }, t('title'))
696
+ const tabName = tabNameEl
697
+ const tabOpen = el('button', { class: 'um-tab-open', type: 'button', title: t('tabExpand'), 'aria-label': t('tabExpand') })
698
+ tabOpenEl = tabOpen
699
+ tabOpen.innerHTML = expandIcon
700
+ tabOpen.addEventListener('click', (e) => { e.stopPropagation(); setCollapsed(false) })
701
+ tabBar.appendChild(tabDot)
702
+ tabBar.appendChild(tabName)
703
+ tabBar.appendChild(tabOpen)
704
+ root.appendChild(tabBar)
705
+
706
+ // Expand must also work when the pointer-captured click lands on the root
707
+ // (drag handling retargets click targets) — listen at root level, never only
708
+ // on the inner tab/button.
709
+ root.addEventListener('click', () => {
710
+ if (!root.classList.contains('collapsed')) return
711
+ if (suppressTabClick) { suppressTabClick = false; return }
712
+ setCollapsed(false)
713
+ })
714
+
715
+ makeDraggable(root)
716
+ updateWindowLabel()
717
+ updateTabStatus()
718
+ }
719
+
720
+ // Feedback bar: one input + send. "Send" opens a pre-filled GitHub issue in
721
+ // a new tab; nothing is transmitted by this plugin itself.
722
+ function buildFeedbackBar() {
723
+ const wrap = el('div')
724
+ const input = el('input', {
725
+ class: 'um-fb-input',
726
+ type: 'text',
727
+ maxlength: String(FEEDBACK_MAX_CHARS),
728
+ placeholder: t('feedbackPrompt'),
729
+ 'aria-label': t('feedbackAria'),
730
+ autocomplete: 'off',
731
+ })
732
+ const send = el('button', { class: 'um-fb-send', type: 'button', title: t('feedbackSendTitle') }, t('feedbackSend'))
733
+ fbInputEl = input
734
+ fbSendEl = send
735
+ const msg = el('div', { class: 'um-fb-msg' })
736
+ const bar = el('div', { class: 'um-fb' }, input, send)
737
+ wrap.appendChild(bar)
738
+ wrap.appendChild(msg)
739
+
740
+ // Keep typing/selection inside the input from being eaten by the drag handler.
741
+ for (const evt of ['pointerdown', 'mousedown', 'click', 'keydown']) {
742
+ input.addEventListener(evt, (e) => e.stopPropagation())
743
+ }
744
+ send.addEventListener('pointerdown', (e) => e.stopPropagation())
745
+
746
+ const submit = () => {
747
+ const text = input.value.trim()
748
+ if (!text) { msg.className = 'um-fb-msg'; msg.textContent = t('feedbackEmpty'); input.focus(); return }
749
+ const st = loadFeedbackState()
750
+ const wait = FEEDBACK_COOLDOWN_MS - (Date.now() - st.lastAt)
751
+ if (wait > 0) { msg.className = 'um-fb-msg'; msg.textContent = t('feedbackWait', Math.ceil(wait / 1000)); return }
752
+ const url = buildFeedbackUrl(text, PLUGIN_VERSION)
753
+ if (!url) return
754
+ const next = { lastAt: Date.now(), count: st.count + 1 }
755
+ saveFeedbackState(next)
756
+ const win = window.open(url, '_blank', 'noopener,noreferrer')
757
+ msg.className = 'um-fb-msg ok'
758
+ msg.innerHTML = ''
759
+ if (win) {
760
+ msg.appendChild(document.createTextNode(t('feedbackOpened')))
761
+ } else {
762
+ const a = el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, t('feedbackOpenHere'))
763
+ msg.appendChild(document.createTextNode(t('feedbackBlocked')))
764
+ msg.appendChild(a)
765
+ }
766
+ input.value = ''
767
+ }
768
+ send.addEventListener('click', (e) => { e.stopPropagation(); submit() })
769
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit() } })
770
+ return wrap
771
+ }
772
+
773
+ // Follow the host locale: DSH Web rewrites <html lang> on every language
774
+ // switch. Relabel static chrome in place and re-render dynamic sections.
775
+ let localeObserver = null
776
+
777
+ function applyLocale() {
778
+ const next = detectLocale()
779
+ if (next === locale) return
780
+ locale = next
781
+ if (root) {
782
+ if (headEl) headEl.title = t('headTitle')
783
+ if (refreshBtn) refreshBtn.title = t('refreshNow')
784
+ if (minimizeBtnEl) minimizeBtnEl.title = t('minimize')
785
+ if (secSubsTitle) { secSubsTitle.textContent = ''; secSubsTitle.appendChild(document.createTextNode(t('secSubs'))); secSubsTitle.appendChild(el('span', { class: 'um-tag' }, t('secSubsTag'))) }
786
+ if (secApiTitle) secApiTitle.textContent = t('secApi')
787
+ if (tabBar) tabBar.title = t('tabTitle')
788
+ if (tabNameEl) tabNameEl.textContent = t('title')
789
+ if (tabOpenEl) { tabOpenEl.title = t('tabExpand'); tabOpenEl.setAttribute('aria-label', t('tabExpand')) }
790
+ if (fbInputEl) { fbInputEl.placeholder = t('feedbackPrompt'); fbInputEl.setAttribute('aria-label', t('feedbackAria')) }
791
+ if (fbSendEl) { fbSendEl.textContent = t('feedbackSend'); fbSendEl.title = t('feedbackSendTitle') }
792
+ if (root.parentNode) { if (root.classList.contains('collapsed')) updateTabStatus(); else render() }
793
+ }
794
+ window.dispatchEvent(new CustomEvent(EVT_LOCALE, { detail: { locale } }))
795
+ }
796
+
797
+ function startLocaleObserver() {
798
+ if (localeObserver || typeof MutationObserver === 'undefined') return
799
+ try {
800
+ localeObserver = new MutationObserver(applyLocale)
801
+ localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
802
+ } catch { /* noop */ }
803
+ }
804
+
805
+ function updateWindowLabel() {
806
+ if (!headTitle) return
807
+ headTitle.innerHTML = ''
808
+ const dot = el('span', { class: 'dot' })
809
+ headTitle.appendChild(dot)
810
+ headTitle.appendChild(document.createTextNode(t('title')))
811
+ }
812
+
813
+ // ── corner-dock collapse / expand ────────────────────────────────────────
814
+
815
+ function viewportSize() {
816
+ return {
817
+ width: (document.documentElement && document.documentElement.clientWidth) || window.innerWidth,
818
+ height: (document.documentElement && document.documentElement.clientHeight) || window.innerHeight,
819
+ }
820
+ }
821
+
822
+ function parsePx(value) {
823
+ const n = parseFloat(value)
824
+ return Number.isFinite(n) ? n : NaN
825
+ }
826
+
827
+ /** Current collapsed-bar size; falls back to a stable estimate. */
828
+ function barSize() {
829
+ if (!tabBar) return { width: 120, height: 36 }
830
+ const r = tabBar.getBoundingClientRect()
831
+ return { width: r.width > 0 ? r.width : 120, height: r.height > 0 ? r.height : 36 }
832
+ }
833
+
834
+ /** Dock target for the collapsed bar at a corner (CORNER_GAP from the edges). */
835
+ function cornerRect(corner, bw, bh) {
836
+ const v = viewportSize()
837
+ const g = CORNER_GAP
838
+ const left = corner === 'br' || corner === 'tr' ? v.width - g - bw : g
839
+ const top = corner === 'br' || corner === 'bl' ? v.height - g - bh : g
840
+ return {
841
+ left: Math.max(0, left),
842
+ top: Math.max(0, top),
843
+ width: bw,
844
+ height: bh,
845
+ }
846
+ }
847
+
848
+ /** The corner nearest a rectangle's centre (tl | tr | bl | br). */
849
+ function nearestCorner(rect) {
850
+ const v = viewportSize()
851
+ const horizontal = rect.left + rect.width / 2 < v.width / 2 ? 'l' : 'r'
852
+ const vertical = rect.top + rect.height / 2 < v.height / 2 ? 't' : 'b'
853
+ return vertical + horizontal
854
+ }
855
+
856
+ /** Learn the real collapsed-bar size while the window is still expanded (no paint). */
857
+ function measureBarWhileExpanded() {
858
+ if (!root || !tabBar) return { width: 120, height: 36 }
859
+ const had = root.classList.contains('collapsed')
860
+ root.classList.add('collapsed')
861
+ const r = tabBar.getBoundingClientRect()
862
+ if (!had) root.classList.remove('collapsed')
863
+ return { width: Math.max(1, r.width), height: Math.max(1, r.height) }
864
+ }
865
+
866
+ function setBox(left, top, right, bottom) {
867
+ // 'auto' (not '') overrides the stylesheet's default right/bottom so the
868
+ // panel never stretches along an edge it is not anchored to.
869
+ root.style.left = left === null || left === undefined ? 'auto' : `${left}px`
870
+ root.style.top = top === null || top === undefined ? 'auto' : `${top}px`
871
+ root.style.right = right === null || right === undefined ? 'auto' : `${right}px`
872
+ root.style.bottom = bottom === null || bottom === undefined ? 'auto' : `${bottom}px`
873
+ }
874
+
875
+ /** Keep a left/top box fully inside the viewport (never negative, never past the far edge). */
876
+ function clampToViewport(left, top, width, height) {
877
+ const v = viewportSize()
878
+ return {
879
+ left: Math.max(0, Math.min(left, v.width - width)),
880
+ top: Math.max(0, Math.min(top, v.height - height)),
881
+ }
882
+ }
883
+
884
+ /** Place the window after (re)mounting: dock the collapsed bar, or restore the expanded spot. */
885
+ function finalizePlacement() {
886
+ if (!root || !root.parentNode) return
887
+ root.style.transform = ''
888
+ if (uiPrefs.collapsed) {
889
+ dockBarToCorner(uiPrefs.collapsedCorner || 'br')
890
+ } else {
891
+ applyExpandedLayout()
892
+ }
893
+ }
894
+
895
+ /** Snap the collapsed bar into a screen corner. */
896
+ function dockBarToCorner(corner) {
897
+ if (!root || !tabBar) return
898
+ const bar = barSize()
899
+ const rect = cornerRect(corner, bar.width, bar.height)
900
+ setBox(rect.left, rect.top, null, null)
901
+ // A bar dragged to a different corner should expand at that corner, not
902
+ // fly back to wherever the window last sat.
903
+ if (uiPrefs.collapsedCorner !== corner) uiPrefs.expand = { mode: 'edge', corner, left: null, top: null }
904
+ uiPrefs.collapsedCorner = corner
905
+ savePrefs()
906
+ }
907
+
908
+ /** Remember where the window should come back to when expanding. */
909
+ function snapshotExpandState() {
910
+ if (!root) return
911
+ const l = root.style.left
912
+ const t = root.style.top
913
+ const custom = typeof l === 'string' && l.length > 0 && l !== 'auto' && !l.endsWith('%')
914
+ && typeof t === 'string' && t.length > 0 && t !== 'auto' && !t.endsWith('%')
915
+ if (custom) {
916
+ uiPrefs.expand = { mode: 'custom', left: parsePx(l), top: parsePx(t), corner: null }
917
+ } else {
918
+ uiPrefs.expand = { mode: 'edge', corner: uiPrefs.collapsedCorner || 'br', left: null, top: null }
919
+ }
920
+ savePrefs()
921
+ }
922
+
923
+ /** Apply the stored/derived expanded placement and return its rect. */
924
+ function applyExpandedLayout() {
925
+ root.classList.remove('collapsed')
926
+ const exp = uiPrefs.expand && typeof uiPrefs.expand === 'object' ? uiPrefs.expand : {}
927
+ if (exp.mode === 'custom' && Number.isFinite(Number(exp.left)) && Number.isFinite(Number(exp.top))) {
928
+ // Measure at the requested spot, then pull it back on screen if the
929
+ // viewport shrank or the spot was saved partly off-screen.
930
+ setBox(Number(exp.left), Number(exp.top), null, null)
931
+ const r = root.getBoundingClientRect()
932
+ const c = clampToViewport(Number(exp.left), Number(exp.top), r.width, r.height)
933
+ if (c.left !== Number(exp.left) || c.top !== Number(exp.top)) setBox(c.left, c.top, null, null)
934
+ } else {
935
+ const corner = exp.corner in CORNER_ID ? exp.corner : (uiPrefs.collapsedCorner || 'br')
936
+ const g = EXPAND_GAP
937
+ if (corner === 'br') setBox(null, null, g, g)
938
+ else if (corner === 'bl') setBox(g, null, null, g)
939
+ else if (corner === 'tr') setBox(null, g, g, null)
940
+ else setBox(g, g, null, null)
941
+ }
942
+ return root.getBoundingClientRect()
943
+ }
944
+
945
+ async function collapseWindow() {
946
+ if (!root || root.classList.contains('collapsed') || animBusy) return
947
+ animBusy = true
948
+ try {
949
+ snapshotExpandState()
950
+ const A = root.getBoundingClientRect()
951
+ const bar = measureBarWhileExpanded()
952
+ const corner = nearestCorner(A)
953
+ const B = cornerRect(corner, bar.width, bar.height)
954
+ uiPrefs.collapsedCorner = corner
955
+ if (root.animate) {
956
+ root.style.transformOrigin = '0 0'
957
+ const anim = root.animate([
958
+ { transform: 'translate(0px,0px) scale(1,1)', opacity: 1 },
959
+ {
960
+ transform: `translate(${B.left - A.left}px, ${B.top - A.top}px) scale(${bar.width / A.width}, ${bar.height / A.height})`,
961
+ opacity: 0.5,
962
+ },
963
+ ], { duration: 240, easing: 'cubic-bezier(.4,.05,.3,1)', fill: 'forwards' })
964
+ try { await anim.finished } catch { /* cancelled */ }
965
+ anim.cancel()
966
+ }
967
+ root.style.transformOrigin = ''
968
+ root.style.transform = ''
969
+ root.classList.add('collapsed')
970
+ setBox(B.left, B.top, null, null)
971
+ if (root.animate) {
972
+ try {
973
+ root.animate(
974
+ [{ transform: 'scale(.9)', opacity: 0.25 }, { transform: 'scale(1)', opacity: 1 }],
975
+ { duration: 120, easing: 'ease-out' },
976
+ )
977
+ } catch { /* noop */ }
978
+ }
979
+ uiPrefs.collapsed = true
980
+ savePrefs()
981
+ updateTabStatus()
982
+ } finally {
983
+ animBusy = false
984
+ }
985
+ }
986
+
987
+ async function expandWindow() {
988
+ if (!root || !root.classList.contains('collapsed') || animBusy) return
989
+ animBusy = true
990
+ try {
991
+ const A = root.getBoundingClientRect() // bar rect
992
+ // Measure the future expanded spot first (no paint yet), then restore bar.
993
+ applyExpandedLayout()
994
+ const B = root.getBoundingClientRect()
995
+ root.classList.add('collapsed')
996
+ const bar = barSize()
997
+ const dock = cornerRect(uiPrefs.collapsedCorner || 'br', bar.width, bar.height)
998
+ setBox(dock.left, dock.top, null, null)
999
+ const dx = (B.left + B.width / 2) - (A.left + A.width / 2)
1000
+ const dy = (B.top + B.height / 2) - (A.top + A.height / 2)
1001
+ if (root.animate) {
1002
+ const anim = root.animate([
1003
+ { transform: 'translate(0px,0px) scale(1,1)', opacity: 1 },
1004
+ { transform: `translate(${dx}px, ${dy}px) scale(1.06,1.06)`, opacity: 0 },
1005
+ ], { duration: 170, easing: 'cubic-bezier(.3,.7,.4,1)', fill: 'forwards' })
1006
+ try { await anim.finished } catch { /* cancelled */ }
1007
+ anim.cancel()
1008
+ }
1009
+ root.style.transform = ''
1010
+ applyExpandedLayout()
1011
+ updateWindowLabel()
1012
+ render() // show the last snapshot immediately; the stale check below refreshes it
1013
+ if (root.animate) {
1014
+ try {
1015
+ await root.animate(
1016
+ [
1017
+ { transform: 'scale(.95) translateY(8px)', opacity: 0 },
1018
+ { transform: 'scale(1) translateY(0px)', opacity: 1 },
1019
+ ],
1020
+ { duration: 170, easing: 'cubic-bezier(.2,.8,.3,1)' },
1021
+ ).finished
1022
+ } catch { /* cancelled */ }
1023
+ }
1024
+ uiPrefs.collapsed = false
1025
+ savePrefs()
1026
+ updateTabStatus()
1027
+ if (view.lastAt === 0 || Date.now() - view.lastAt > AUTO_REFRESH_MS) void refresh(false)
1028
+ } finally {
1029
+ animBusy = false
1030
+ }
1031
+ }
1032
+
1033
+ function setCollapsed(collapsed) {
1034
+ if (collapsed) void collapseWindow()
1035
+ else void expandWindow()
1036
+ }
1037
+
1038
+ /** Most-consumed subscription window percent across logged accounts (null when none). */
1039
+ function worstUsedPercent() {
1040
+ let worst = null
1041
+ const subs = view.subs
1042
+ if (subs) {
1043
+ for (const provider of subs) {
1044
+ for (const acc of provider.accounts) {
1045
+ if (!acc.ok) continue
1046
+ for (const win of acc.windows) {
1047
+ const used = Number(win && win.usedPercent)
1048
+ if (Number.isFinite(used)) worst = worst === null ? used : Math.max(worst, used)
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+ return worst
1054
+ }
1055
+
1056
+ function updateTabStatus() {
1057
+ if (!root || !tabBar) return
1058
+ const worst = worstUsedPercent()
1059
+ const pct = worst === null ? 0 : Math.min(100, Math.max(0, worst))
1060
+ const color = pct >= 95 ? '#f85149' : pct >= 80 ? '#f5a623' : '#3fb950'
1061
+ root.style.setProperty('--um-tab-dot', color)
1062
+ const tips = []
1063
+ if (worst === null) tips.push(t('tipSubsUnknown'))
1064
+ else if (worst >= 95) tips.push(t('tipSubsCritical', Math.round(worst)))
1065
+ else if (worst >= 80) tips.push(t('tipSubsTight', Math.round(worst)))
1066
+ else tips.push(t('tipSubsOk', Math.round(worst)))
1067
+ const apiOk = view.api && view.api.balance && view.api.balance.status === 'ok'
1068
+ if (apiOk && view.api) tips.push(t('tipApiToday', fmtUsd(view.api.todayUsd, view.api.config || defaultConfig())))
1069
+ tips.push(t('tabTitle'))
1070
+ const summary = tips.join(' · ')
1071
+ root.title = summary
1072
+ tabBar.title = summary
1073
+ }
1074
+
1075
+ function makeDraggable(handle) {
1076
+ let dragging = false
1077
+ let startX = 0
1078
+ let startY = 0
1079
+ let originLeft = 0
1080
+ let originTop = 0
1081
+ let moved = 0
1082
+ handle.addEventListener('pointerdown', (e) => {
1083
+ if (e.target.closest('button')) return
1084
+ const collapsed = root && root.classList.contains('collapsed')
1085
+ // Expanded: only the header row drags; minimized: the whole slim bar drags.
1086
+ if (!collapsed && !e.target.closest('.um-head')) return
1087
+ dragging = true
1088
+ moved = 0
1089
+ startX = e.clientX
1090
+ startY = e.clientY
1091
+ const rect = root.getBoundingClientRect()
1092
+ originLeft = rect.left
1093
+ originTop = rect.top
1094
+ root.style.right = 'auto'
1095
+ root.style.bottom = 'auto'
1096
+ root.style.left = `${originLeft}px`
1097
+ root.style.top = `${originTop}px`
1098
+ handle.setPointerCapture(e.pointerId)
1099
+ })
1100
+ handle.addEventListener('pointermove', (e) => {
1101
+ if (!dragging) return
1102
+ const dx = e.clientX - startX
1103
+ const dy = e.clientY - startY
1104
+ moved = Math.max(moved, Math.abs(dx) + Math.abs(dy))
1105
+ const r = root.getBoundingClientRect()
1106
+ const c = clampToViewport(originLeft + dx, originTop + dy, r.width, r.height)
1107
+ root.style.left = `${c.left}px`
1108
+ root.style.top = `${c.top}px`
1109
+ })
1110
+ const end = (e) => {
1111
+ if (!dragging) return
1112
+ dragging = false
1113
+ try { handle.releasePointerCapture(e.pointerId) } catch { /* noop */ }
1114
+ // A real drag must not be treated as a click-to-expand on the minimized bar.
1115
+ if (moved > 6) {
1116
+ suppressTabClick = true
1117
+ window.setTimeout(() => { suppressTabClick = false }, 200)
1118
+ }
1119
+ if (root.classList.contains('collapsed')) {
1120
+ // Dock the bar to the nearest screen corner after dragging.
1121
+ const rect = root.getBoundingClientRect()
1122
+ dockBarToCorner(nearestCorner(rect))
1123
+ } else {
1124
+ uiPrefs.left = root.offsetLeft
1125
+ uiPrefs.top = root.offsetTop
1126
+ uiPrefs.expand = { mode: 'custom', left: root.offsetLeft, top: root.offsetTop, corner: null }
1127
+ savePrefs()
1128
+ }
1129
+ }
1130
+ handle.addEventListener('pointerup', end)
1131
+ handle.addEventListener('pointercancel', end)
1132
+ }
1133
+
1134
+ function renderSubs() {
1135
+ subsBox.innerHTML = ''
1136
+ if (view.subsError) {
1137
+ subsBox.appendChild(el('div', { class: 'um-note err' }, view.subsError))
1138
+ return
1139
+ }
1140
+ if (!view.subs) {
1141
+ subsBox.appendChild(el('div', { class: 'um-note' }, t('loading')))
1142
+ return
1143
+ }
1144
+ const logged = view.subs.filter((p) => p.accounts.length > 0)
1145
+ if (logged.length === 0) {
1146
+ subsBox.appendChild(el('div', { class: 'um-note warn' }, t('subsNotSignedIn')))
1147
+ return
1148
+ }
1149
+ for (const provider of logged) {
1150
+ for (const acc of provider.accounts) {
1151
+ const card = el('div', { class: 'um-card' })
1152
+ const headLabel = `${provider.name}`
1153
+ const headSubParts = []
1154
+ if (acc.plan) headSubParts.push(acc.plan)
1155
+ if (acc.label && acc.label !== acc.key) headSubParts.push(acc.label)
1156
+ card.appendChild(el('div', { class: 'um-card-head' }, headLabel, el('span', { class: 'sub' }, headSubParts.join(' · '))))
1157
+ if (acc.error) {
1158
+ card.appendChild(el('div', { class: 'um-note err' }, t('subsQueryFailed', acc.error)))
1159
+ } else if (!acc.ok) {
1160
+ card.appendChild(el('div', { class: 'um-note' }, t('subsNoWindows')))
1161
+ } else if (acc.windows.length === 0) {
1162
+ card.appendChild(el('div', { class: 'um-note' }, t('subsNoData')))
1163
+ } else {
1164
+ for (const win of acc.windows) {
1165
+ const used = Number(win.usedPercent)
1166
+ const pct = Number.isFinite(used) ? used : 0
1167
+ const kindLabel = win.kind === 'session' ? t('winSession') : win.kind === 'weekly' ? t('winWeekly') : (win.kind || t('winGeneric'))
1168
+ const scope = (win.scope !== undefined && win.scope !== null && win.scope !== '') ? ` · ${win.scope}` : ''
1169
+ const remaining = Math.max(0, 100 - pct)
1170
+ const row = el('div', { class: 'um-row' },
1171
+ el('span', { class: 'lbl', title: kindLabel + scope }, kindLabel + scope),
1172
+ el('span', { class: 'um-bar' }, el('span', { class: 'um-fill', style: { width: `${Math.min(100, Math.max(0, pct))}%`, background: barColor(pct) } })),
1173
+ el('span', { class: 'um-pct' }, el('strong', {}, `${Math.round(pct)}%`), t('used')),
1174
+ )
1175
+ card.appendChild(row)
1176
+ const resetInfo = fmtReset(Number(win.resetsAt))
1177
+ const subLine = el('div', { class: 'um-sub' })
1178
+ subLine.appendChild(el('span', { class: 'ok' }, t('remaining', Math.round(remaining))))
1179
+ if (resetInfo.date) {
1180
+ subLine.appendChild(document.createTextNode(t('resetAt', resetInfo.date)))
1181
+ if (resetInfo.rel) subLine.appendChild(el('span', {}, `(${resetInfo.rel})`))
1182
+ } else {
1183
+ subLine.appendChild(document.createTextNode(t('resetUnknown')))
1184
+ }
1185
+ card.appendChild(subLine)
1186
+ }
1187
+ }
1188
+ subsBox.appendChild(card)
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ function renderApi() {
1194
+ apiBox.innerHTML = ''
1195
+ if (view.apiError) {
1196
+ apiBox.appendChild(el('div', { class: 'um-note err' }, view.apiError))
1197
+ return
1198
+ }
1199
+ if (!view.api) {
1200
+ apiBox.appendChild(el('div', { class: 'um-note' }, t('loading')))
1201
+ return
1202
+ }
1203
+ const cfg = view.api.config || defaultConfig()
1204
+ const balance = view.api.balance || {}
1205
+ if (balance.status === 'error') {
1206
+ apiBox.appendChild(el('div', { class: 'um-note warn' }, balance.message || t('balanceFailed')))
1207
+ } else if (balance.status === 'off') {
1208
+ apiBox.appendChild(el('div', { class: 'um-note' }, t('balanceUnconfigured')))
1209
+ }
1210
+
1211
+ const rows = el('div')
1212
+ const todayUsd = view.api.todayUsd || 0
1213
+ const tipParts = []
1214
+ if (view.api.hasBreakdown) {
1215
+ tipParts.push(t('todayCallsTip', view.api.todayCalls))
1216
+ if (view.api.flashCalls > 0) tipParts.push(t('flashTip', view.api.flashCalls, fmtUsd(view.api.flashUsd, cfg)))
1217
+ }
1218
+ rows.appendChild(el('div', { class: 'um-kv' },
1219
+ el('span', { class: 'k' }, t('todaySpend')),
1220
+ el('span', { class: 'v', title: tipParts.join(';') }, fmtUsd(todayUsd, cfg)),
1221
+ ))
1222
+ if (view.api.hasBreakdown) {
1223
+ rows.appendChild(el('div', { class: 'um-kv' },
1224
+ el('span', { class: 'k' }, t('todayCalls')),
1225
+ el('span', { class: 'v dim' }, t('callsUnit', view.api.todayCalls)),
1226
+ ))
1227
+ }
1228
+ if (balance.status === 'ok') {
1229
+ const balanceTip = []
1230
+ if (Number(balance.grantedBalance) > 0) balanceTip.push(t('granted', fmtBalance(num(balance.grantedBalance), cfg)))
1231
+ if (Number(balance.toppedUpBalance) > 0) balanceTip.push(t('toppedUp', fmtBalance(num(balance.toppedUpBalance), cfg)))
1232
+ rows.appendChild(el('div', { class: 'um-kv' },
1233
+ el('span', { class: 'k' }, t('balanceTotal')),
1234
+ el('span', { class: 'v', title: balanceTip.length > 0 ? balanceTip.join(' · ') : undefined }, fmtBalance(num(balance.totalBalance), cfg)),
1235
+ ))
1236
+ }
1237
+ apiBox.appendChild(rows)
1238
+ }
1239
+
1240
+ function render() {
1241
+ if (!root || !root.parentNode) return
1242
+ updateWindowLabel()
1243
+ updateTabStatus()
1244
+ renderSubs()
1245
+ renderApi()
1246
+ const foot = []
1247
+ if (view.lastAt > 0) foot.push(t('updatedAt', clockText(new Date(view.lastAt))))
1248
+ foot.push(t('autoTag'))
1249
+ footEl.textContent = foot.join(' · ')
1250
+ }
1251
+
1252
+ // ── refresh orchestration ────────────────────────────────────────────────
1253
+
1254
+ let refreshing = false
1255
+
1256
+ async function refresh(force) {
1257
+ if (!root || !root.parentNode || refreshing) return
1258
+ refreshing = true
1259
+ if (refreshBtn) refreshBtn.classList.add('spin')
1260
+ try {
1261
+ const results = await Promise.allSettled([fetchSubs(Boolean(force)), fetchApi(Boolean(force))])
1262
+ const subsResult = results[0]
1263
+ const apiResult = results[1]
1264
+ if (subsResult.status === 'fulfilled') {
1265
+ view.subs = subsResult.value
1266
+ view.subsError = null
1267
+ } else {
1268
+ const err = subsResult.reason
1269
+ view.subs = null
1270
+ view.subsError = (err && err.notFound)
1271
+ ? t('subsChannelMissing')
1272
+ : t('subsFetchFailed', (err && err.message) || err)
1273
+ }
1274
+ if (apiResult.status === 'fulfilled') {
1275
+ view.api = apiResult.value
1276
+ view.apiError = null
1277
+ } else {
1278
+ const err = apiResult.reason
1279
+ view.api = null
1280
+ view.apiError = (err && err.notFound)
1281
+ ? t('apiChannelMissing')
1282
+ : t('apiFetchFailed', (err && err.message) || err)
1283
+ }
1284
+ view.lastAt = Date.now()
1285
+ view.loading = false
1286
+ } finally {
1287
+ refreshing = false
1288
+ if (refreshBtn) refreshBtn.classList.remove('spin')
1289
+ }
1290
+ // Collapsed: only refresh the tiny bar status (dot/tooltip); skip rebuilding
1291
+ // the hidden content DOM. Expanded: render the full window.
1292
+ if (root.classList.contains('collapsed')) updateTabStatus()
1293
+ else render()
1294
+ }
1295
+
1296
+ function startTimers() {
1297
+ if (autoTimer !== null) return
1298
+ // Poll only while the window is actually expanded, visible, and on screen;
1299
+ // collapsing stops auto-polling (expand refreshes on demand).
1300
+ const shouldPoll = () => (
1301
+ !document.hidden
1302
+ && root !== null
1303
+ && root.parentNode !== null
1304
+ && !root.classList.contains('collapsed')
1305
+ )
1306
+ autoTimer = window.setInterval(() => { if (shouldPoll()) void refresh(false) }, AUTO_REFRESH_MS)
1307
+ visibilityHandler = () => { if (shouldPoll()) void refresh(false) }
1308
+ document.addEventListener('visibilitychange', visibilityHandler)
1309
+ // Re-dock / re-clamp when the viewport changes so the window never ends
1310
+ // up off-screen after a resize or sidebar toggle.
1311
+ resizeHandler = () => { if (root && root.parentNode && !animBusy) finalizePlacement() }
1312
+ window.addEventListener('resize', resizeHandler)
1313
+ }
1314
+
1315
+ function stopTimers() {
1316
+ if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null }
1317
+ if (visibilityHandler !== null) { document.removeEventListener('visibilitychange', visibilityHandler); visibilityHandler = null }
1318
+ if (resizeHandler !== null) { window.removeEventListener('resize', resizeHandler); resizeHandler = null }
1319
+ }
1320
+
1321
+ function showWindow() {
1322
+ if (root && root.parentNode) return
1323
+ if (!root) buildStructure()
1324
+ document.body.appendChild(root)
1325
+ finalizePlacement()
1326
+ if (!uiPrefs.collapsed) updateWindowLabel()
1327
+ startTimers()
1328
+ // Fresh boot or long-hidden data: fetch right away so nothing shows stale.
1329
+ if (view.lastAt === 0 || Date.now() - view.lastAt > AUTO_REFRESH_MS) void refresh(false)
1330
+ }
1331
+
1332
+ function hideWindow() {
1333
+ if (root && root.parentNode) root.parentNode.removeChild(root)
1334
+ stopTimers()
1335
+ }
1336
+
1337
+ function syncVisibility() {
1338
+ if (uiPrefs.visible) showWindow()
1339
+ else hideWindow()
1340
+ }
1341
+
1342
+ // ── Settings card (React, registered into settings.section slot) ─────────
1343
+
1344
+ function SettingsCard() {
1345
+ const [visible, setVisible] = React.useState(() => loadPrefs().visible)
1346
+ const [status, setStatus] = React.useState('')
1347
+ const [, setLocaleTick] = React.useState(0)
1348
+
1349
+ React.useEffect(() => {
1350
+ const onSet = (e) => {
1351
+ if (e.detail && typeof e.detail.visible === 'boolean') setVisible(e.detail.visible)
1352
+ }
1353
+ const onStorage = (e) => {
1354
+ if (e.key === LS_UI) setVisible(loadPrefs().visible)
1355
+ }
1356
+ const onRefreshDone = () => {
1357
+ if (view.lastAt > 0) setStatus(t('updatedAt', clockText(new Date(view.lastAt))))
1358
+ }
1359
+ const onLocale = () => setLocaleTick((n) => n + 1)
1360
+ window.addEventListener(EVT_SET, onSet)
1361
+ window.addEventListener(EVT_LOCALE, onLocale)
1362
+ window.addEventListener(EVT_REFRESH, onRefreshDone)
1363
+ window.addEventListener('storage', onStorage)
1364
+ return () => {
1365
+ window.removeEventListener(EVT_SET, onSet)
1366
+ window.removeEventListener(EVT_LOCALE, onLocale)
1367
+ window.removeEventListener(EVT_REFRESH, onRefreshDone)
1368
+ window.removeEventListener('storage', onStorage)
1369
+ }
1370
+ }, [])
1371
+
1372
+ const toggle = (next) => {
1373
+ setVisiblePref(next)
1374
+ window.dispatchEvent(new CustomEvent(EVT_SET, { detail: { visible: next } }))
1375
+ setVisible(next)
1376
+ }
1377
+
1378
+ const doRefresh = () => {
1379
+ if (!uiPrefs.visible) toggle(true)
1380
+ window.dispatchEvent(new CustomEvent(EVT_REFRESH, { detail: { force: true } }))
1381
+ setStatus(t('refreshing'))
1382
+ window.setTimeout(() => {
1383
+ if (view.lastAt > 0) setStatus(t('updatedAt', clockText(new Date(view.lastAt))))
1384
+ }, 1200)
1385
+ }
1386
+
1387
+ const h = React.createElement
1388
+ return h('div', { className: 'ums-card' },
1389
+ h('div', { className: 'ums-desc' },
1390
+ t('settingsDesc'),
1391
+ h('br'),
1392
+ h('small', null, t('settingsDescSmall')),
1393
+ ),
1394
+ h('div', { className: 'ums-row' },
1395
+ h('div', { className: 'ums-main' },
1396
+ h('div', { className: 'ums-name' }, t('settingsShow')),
1397
+ h('div', { className: 'ums-hint' }, visible ? t('settingsShownHint') : t('settingsHiddenHint')),
1398
+ ),
1399
+ h('button', {
1400
+ className: 'ums-switch',
1401
+ type: 'button',
1402
+ role: 'switch',
1403
+ 'aria-checked': visible,
1404
+ title: visible ? t('settingsHide') : t('settingsShowShort'),
1405
+ onClick: () => toggle(!visible),
1406
+ }, h('span', { className: 'knob' })),
1407
+ ),
1408
+ h('div', { className: 'ums-row' },
1409
+ h('div', { className: 'ums-main' },
1410
+ h('div', { className: 'ums-name' }, t('refreshNow')),
1411
+ h('div', { className: 'ums-hint' }, t('settingsRefreshHint')),
1412
+ ),
1413
+ h('div', { className: 'ums-actions' },
1414
+ h('button', { className: 'ums-btn', type: 'button', onClick: doRefresh }, t('refreshNow')),
1415
+ h('span', { className: 'ums-status' }, status),
1416
+ ),
1417
+ ),
1418
+ )
1419
+ }
1420
+
1421
+ // ── plugin apply ─────────────────────────────────────────────────────────
1422
+
1423
+ let settingsDisposers = []
1424
+
1425
+ async function apply(ctx) {
1426
+ if (window.__dshUsageMiniApplied) return () => {}
1427
+ window.__dshUsageMiniApplied = true
1428
+
1429
+ loadPrefs()
1430
+ ensureStyle()
1431
+ locale = detectLocale()
1432
+ startLocaleObserver()
1433
+
1434
+ const onSet = (e) => {
1435
+ if (e.detail && typeof e.detail.visible === 'boolean') {
1436
+ setVisiblePref(e.detail.visible)
1437
+ syncVisibility()
1438
+ }
1439
+ }
1440
+ const onRefresh = (e) => {
1441
+ void refresh(Boolean(e && e.detail && e.detail.force))
1442
+ }
1443
+ const onStorage = (e) => {
1444
+ if (e.key === LS_UI) {
1445
+ loadPrefs()
1446
+ syncVisibility()
1447
+ }
1448
+ }
1449
+ window.addEventListener(EVT_SET, onSet)
1450
+ window.addEventListener(EVT_REFRESH, onRefresh)
1451
+ window.addEventListener('storage', onStorage)
1452
+
1453
+ // Settings page card (best effort; the float still works without slots).
1454
+ try {
1455
+ const slots = ctx && typeof ctx.get === 'function' ? ctx.get('slots') : undefined
1456
+ if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
1457
+ const dispose = slots.inject('settings.section', () => slots.register({
1458
+ name: 'settings.section',
1459
+ id: 'usage-mini',
1460
+ order: 200,
1461
+ label: t('title'),
1462
+ inject: () => ({}),
1463
+ }, SettingsCard))
1464
+ settingsDisposers.push(dispose)
1465
+ }
1466
+ } catch (err) {
1467
+ // eslint-disable-next-line no-console
1468
+ console.warn('[dsh-usage-mini] settings card unavailable:', err && err.message ? err.message : err)
1469
+ }
1470
+
1471
+ syncVisibility()
1472
+
1473
+ const cleanup = () => {
1474
+ window.removeEventListener(EVT_SET, onSet)
1475
+ window.removeEventListener(EVT_REFRESH, onRefresh)
1476
+ window.removeEventListener('storage', onStorage)
1477
+ stopTimers()
1478
+ for (const dispose of settingsDisposers) {
1479
+ try { dispose() } catch { /* noop */ }
1480
+ }
1481
+ settingsDisposers = []
1482
+ if (root && root.parentNode) root.parentNode.removeChild(root)
1483
+ root = null
1484
+ window.__dshUsageMiniApplied = false
1485
+ }
1486
+ if (ctx && typeof ctx.effect === 'function') {
1487
+ ctx.effect(() => cleanup, `${UID}: lifecycle`)
1488
+ }
1489
+ return cleanup
1490
+ }
1491
+
1492
+ exports.apply = apply
1493
+ return module.exports
1494
+ },
1495
+ })