diff --git a/plugin.js b/plugin.js new file mode 100644 index 0000000..28a60e9 --- /dev/null +++ b/plugin.js @@ -0,0 +1,548 @@ +/** + * UnoRouter balance — statusbar chip. + * + * Statusbar shows "left/total (pct%)" of the weekly subscription budget. + * Clicking opens a Popover with the full breakdown; hover shows a one-liner. + * + * Two credit pools exist and the subscription one is spent first when + * billing_preference is "subscription_first": + * - subscription credit (resets weekly, expires, does NOT carry over) + * - wallet balance (top-ups, never expires) + * + * Endpoints (Bearer access token — NOT an sk- API key): + * GET /api/subscription/self -> subscription credit + next reset + * GET /api/user/self -> wallet balance (quota) + * quota units: 500000 == $1 + */ + +import { + cn, + haptic, + host, + icons, + PALETTE_AREA, + Popover, + PopoverContent, + PopoverTrigger, + queryClient, + Separator, + STATUSBAR_AREAS, + Tip, + usePluginI18n, + useQuery +} from '@hermes/plugin-sdk' +import { jsx, jsxs } from 'react/jsx-runtime' + +const ID = 'unorouter-balance' +const API_SUB = 'https://api.unorouter.com/api/subscription/self' +const API_SELF = 'https://api.unorouter.com/api/user/self' +const BILLING_URL = 'https://unorouter.com/en/billing' +const QUOTA_PER_USD = 500000 +const REFRESH_MS = 5 * 60 * 1000 +const LOW_USD = 2 + +// Paste your access token here to skip the prompt on first run. +const DEFAULT_TOKEN = '' + +/** Module-scope token mirror — ctx.storage is the source of truth. */ +let token = DEFAULT_TOKEN || '' +/** Set in register() so module-scope helpers can persist. */ +let store = null + +function setToken(next) { + token = String(next || '').trim() +} + +function persistToken() { + if (!store) return + try { + store.set('token', token) + } catch { + /* storage bridge unavailable — session-only token */ + } +} + +async function apiGet(url) { + let res + try { + res = await fetch(url, { + method: 'GET', + headers: { Authorization: 'Bearer ' + token }, + cache: 'no-store' + }) + } catch (err) { + throw new Error('network:' + ((err && err.message) || err)) + } + if (res.status === 401 || res.status === 403) throw new Error('bad-token') + if (!res.ok) throw new Error('http-' + res.status) + const body = await res.json() + if (!body || body.success === false) { + throw new Error(String((body && body.message) || 'api-error')) + } + return (body && body.data) || {} +} + +/** Pick the live subscription: prefer status active, else the latest end. */ +function pickSub(data) { + const list = data.subscriptions || data.all_subscriptions || [] + const usable = list.filter(e => e && e.subscription) + const active = usable.filter(e => e.subscription.status === 'active') + const pool = active.length ? active : usable + if (!pool.length) return null + pool.sort((a, b) => (b.subscription.end_time || 0) - (a.subscription.end_time || 0)) + return pool[0] +} + +async function fetchBalance() { + if (!token) throw new Error('no-token') + + const [subData, selfData] = await Promise.all([ + apiGet(API_SUB).catch(() => null), + apiGet(API_SELF) + ]) + + const wallet = (Number(selfData.quota) || 0) / QUOTA_PER_USD + const entry = subData ? pickSub(subData) : null + const s = entry ? entry.subscription : null + + let sub = null + if (s) { + const total = (Number(s.amount_total) || 0) / QUOTA_PER_USD + const used = (Number(s.amount_used) || 0) / QUOTA_PER_USD + const left = Math.max(0, total - used) + sub = { + title: entry.plan_title || '', + status: s.status || '', + total, + used, + left, + pct: total > 0 ? (100 * left) / total : 0, + nextReset: Number(s.next_reset_time) || 0, + end: Number(s.end_time) || 0 + } + } + + return { + sub, + wallet, + spent: (Number(selfData.used_quota) || 0) / QUOTA_PER_USD, + requests: Number(selfData.request_count) || 0, + username: selfData.username || '' + } +} + +function money(n) { + const v = Number(n) || 0 + return '$' + v.toFixed(v >= 100 ? 0 : 2) +} + +/** Compact money for the statusbar: no cents on round numbers. */ +function shortMoney(n) { + const v = Number(n) || 0 + return '$' + (Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/0$/, '')) +} + +function shortNum(n) { + const v = Number(n) || 0 + return Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100) +} + +function until(ts) { + if (!ts) return null + const s = ts - Date.now() / 1000 + if (s < 0) return null + const d = s / 86400 + if (d < 1) return Math.max(1, Math.round(s / 3600)) + 'h' + return Math.round(d) + 'd' +} + +/** Statusbar readout: "8.59/10 (86%)". Wallet-only shows spent-able as 100%. */ +function statusLabel(d) { + const subLeft = d.sub ? d.sub.left : 0 + const left = subLeft + d.wallet + // A wallet bigger than the plan makes the budget exceed its own total, so + // the denominator grows with it and the percentage stays meaningful. + const base = d.sub && d.sub.total > 0 ? Math.max(d.sub.total, left) : left + const pct = base > 0 ? Math.round((100 * left) / base) : 0 + return shortNum(left) + '/' + shortNum(base) + ' (' + pct + '%)' +} + +/* ---------- popover ---------- */ + +function Row({ label, value, muted }) { + return jsxs('div', { + className: 'flex items-baseline justify-between gap-3 text-xs', + children: [ + jsx('span', { + className: muted ? 'text-(--ui-text-quaternary)' : 'text-(--ui-text-tertiary)', + children: label + }), + jsx('span', { + className: cn('tabular-nums', muted ? 'text-(--ui-text-tertiary)' : 'text-(--ui-text-primary)'), + children: value + }) + ] + }) +} + +function Bar({ pct }) { + return jsx('div', { + className: 'h-1 w-full overflow-hidden rounded-full bg-(--ui-bg-quaternary)', + children: jsx('div', { + className: 'h-full rounded-full bg-(--ui-accent)', + style: { width: Math.max(0, Math.min(100, pct)) + '%' } + }) + }) +} + +function Details({ data, busy, t, onRefresh }) { + const sub = data.sub + const left = (sub ? sub.left : 0) + data.wallet + const base = sub && sub.total > 0 ? Math.max(sub.total, left) : left + const pct = base > 0 ? Math.max(0, Math.min(100, (100 * left) / base)) : 0 + + const rows = [] + if (sub) { + rows.push(jsx(Row, { label: t('rowSubscription'), value: money(sub.left) + ' / ' + money(sub.total) })) + rows.push(jsx(Row, { label: t('rowPlan'), value: sub.title || '—', muted: true })) + const rl = until(sub.nextReset) + if (rl) rows.push(jsx(Row, { label: t('rowResets'), value: rl, muted: true })) + } + rows.push(jsx(Row, { label: t('rowWallet'), value: money(data.wallet) })) + rows.push(jsx(Row, { label: t('rowSpent'), value: money(data.spent), muted: true })) + rows.push(jsx(Row, { label: t('rowRequests'), value: String(data.requests), muted: true })) + + return jsxs('div', { + className: 'flex flex-col gap-2', + children: [ + jsxs('div', { + className: 'flex items-center justify-between gap-2', + children: [ + // Provider name — the chip shows only numbers, so the popover has + // to say whose budget this is. Primary colour: it's the heading. + jsx('span', { + className: 'text-xs font-semibold tracking-wide text-(--ui-text-primary)', + children: 'UnoRouter' + }), + jsx(Tip, { + label: t('refreshTip'), + children: jsx('button', { + type: 'button', + 'aria-label': t('refreshTip'), + disabled: busy, + className: cn( + 'inline-flex size-5 shrink-0 items-center justify-center rounded-[4px]', + 'text-(--ui-text-tertiary) transition-colors', + 'hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)', + 'disabled:cursor-default disabled:opacity-45' + ), + onClick: () => { + haptic('tap') + onRefresh() + }, + children: jsx(icons.RefreshCw, { + size: 12, + className: busy ? 'animate-spin' : undefined + }) + }) + }) + ] + }), + jsxs('div', { + className: 'flex flex-col gap-1', + children: [ + jsxs('div', { + className: 'flex items-baseline justify-between gap-2', + children: [ + jsx('span', { + className: 'text-xs text-(--ui-text-tertiary)', + children: t('rowAvailable') + }), + jsx('span', { + className: 'text-sm font-medium tabular-nums text-(--ui-text-primary)', + children: money(left) + }) + ] + }), + jsx(Bar, { pct }) + ] + }), + jsx(Separator, {}), + jsxs('div', { className: 'flex flex-col gap-1', children: rows }) + ] + }) +} + +function BalanceChip() { + const t = usePluginI18n(ID) + const { data, error, isFetching, refetch } = useQuery({ + queryKey: [ID, 'balance'], + queryFn: fetchBalance, + refetchInterval: REFRESH_MS, + retry: false, + staleTime: 60 * 1000 + }) + + const spendable = data ? (data.sub ? data.sub.left : 0) + data.wallet : null + const low = spendable !== null && spendable < LOW_USD + const state = error + ? String((error && error.message) || error).split(':')[0] + : isFetching && !data + ? 'loading' + : 'ok' + + // Hover: one line only. + let tip + if (state === 'no-token') tip = t('tipNoToken') + else if (state === 'bad-token') tip = t('tipBadToken') + else if (state === 'loading') tip = t('tipLoading') + else if (state !== 'ok') tip = t('tipError') + ' ' + state + else tip = t('tipOpen') + ' · ' + money(spendable) + ' ' + t('tipAvailable') + + // Statusbar: left/total (pct%) + let label + if (state === 'no-token') label = 'ur · set token' + else if (state === 'bad-token') label = 'ur · bad token' + else if (state === 'loading') label = 'ur · …' + else if (state !== 'ok') label = 'ur · ?' + else label = statusLabel(data) + + // Radix's PopoverTrigger renders its OWN