549 lines
17 KiB
JavaScript
549 lines
17 KiB
JavaScript
/**
|
|
* 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 <button> unless `asChild` — nesting
|
|
// it around ours is invalid markup and adds phantom inline space around the
|
|
// chip. Always merge onto our button instead.
|
|
const trigger = jsx('button', {
|
|
type: 'button',
|
|
className: cn(
|
|
'inline-flex h-full items-center rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
|
|
'hover:bg-(--chrome-action-hover)',
|
|
state === 'ok'
|
|
? low
|
|
? 'text-(--ui-warning,var(--ui-accent))'
|
|
: 'text-(--ui-text-tertiary) hover:text-foreground'
|
|
: 'text-(--ui-text-quaternary)'
|
|
),
|
|
onContextMenu: ev => {
|
|
ev.preventDefault()
|
|
promptForToken(t)
|
|
},
|
|
children: label
|
|
})
|
|
|
|
// Without a token there is nothing to show, so clicking prompts instead of
|
|
// opening an empty popover — and no Popover root is mounted at all (a
|
|
// trigger outside its root throws).
|
|
if (state === 'no-token') {
|
|
return jsx(Tip, {
|
|
label: tip,
|
|
children: jsx(PopoverTrigger, {
|
|
asChild: true,
|
|
children: jsxs('button', {
|
|
type: 'button',
|
|
className: trigger.props.className,
|
|
onClick: () => {
|
|
haptic('tap')
|
|
promptForToken(t)
|
|
},
|
|
onContextMenu: trigger.props.onContextMenu,
|
|
children: label
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
return jsx(Popover, {
|
|
children: [
|
|
jsx(Tip, {
|
|
key: 'trigger',
|
|
label: tip,
|
|
children: jsx(PopoverTrigger, {
|
|
asChild: true,
|
|
onClick: () => haptic('tap'),
|
|
children: trigger
|
|
})
|
|
}),
|
|
jsx(PopoverContent, {
|
|
key: 'content',
|
|
align: 'end',
|
|
side: 'top',
|
|
className: 'w-60',
|
|
children:
|
|
state === 'ok'
|
|
? jsx(Details, {
|
|
data,
|
|
busy: isFetching,
|
|
t,
|
|
onRefresh: () => void refetch()
|
|
})
|
|
: jsxs('div', {
|
|
className: 'flex flex-col gap-1 p-1 text-xs',
|
|
children: [
|
|
jsxs('div', {
|
|
className: 'flex items-center justify-between gap-2',
|
|
children: [
|
|
jsx('span', {
|
|
className: 'text-xs font-semibold tracking-wide text-(--ui-text-primary)',
|
|
children: 'UnoRouter'
|
|
}),
|
|
jsx('button', {
|
|
type: 'button',
|
|
'aria-label': t('refreshTip'),
|
|
disabled: isFetching,
|
|
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')
|
|
void refetch()
|
|
},
|
|
children: jsx(icons.RefreshCw, {
|
|
size: 12,
|
|
className: isFetching ? 'animate-spin' : undefined
|
|
})
|
|
})
|
|
]
|
|
}),
|
|
jsx('div', {
|
|
className: 'text-(--ui-text-tertiary)',
|
|
children: state === 'loading' ? t('popLoading') : t('popError')
|
|
}),
|
|
jsx('div', {
|
|
className: 'text-(--ui-text-quaternary)',
|
|
children: state === 'loading' ? '…' : String(state)
|
|
})
|
|
]
|
|
})
|
|
})
|
|
]
|
|
})
|
|
}
|
|
|
|
function promptForToken(t) {
|
|
const next =
|
|
typeof window !== 'undefined' && typeof window.prompt === 'function'
|
|
? window.prompt(t('promptToken'), token || '')
|
|
: null
|
|
if (next === null) return
|
|
setToken(next)
|
|
persistToken()
|
|
queryClient.invalidateQueries({ queryKey: [ID, 'balance'] })
|
|
host.notify({ kind: 'info', message: t('tokenSaved') })
|
|
}
|
|
|
|
export default {
|
|
id: ID,
|
|
name: 'UnoRouter Balance',
|
|
register(ctx) {
|
|
store = ctx.storage
|
|
|
|
ctx.i18n.register({
|
|
en: {
|
|
tipNoToken: 'UnoRouter: no access token — click to set one',
|
|
tipBadToken: 'UnoRouter: token rejected (401/403). Right-click to replace it.',
|
|
tipLoading: 'UnoRouter: loading…',
|
|
tipError: 'UnoRouter: error —',
|
|
tipOpen: 'Click for details',
|
|
tipAvailable: 'available',
|
|
refreshTip: 'Refresh',
|
|
rowAvailable: 'Available',
|
|
rowSubscription: 'Subscription',
|
|
rowPlan: 'Plan',
|
|
rowResets: 'Resets in',
|
|
rowWallet: 'Wallet',
|
|
rowSpent: 'Lifetime spend',
|
|
rowRequests: 'Requests',
|
|
popLoading: 'Loading…',
|
|
popError: 'Could not load balance',
|
|
promptToken: 'UnoRouter access token (unorouter.com → Settings → API access):',
|
|
tokenSaved: 'UnoRouter token saved'
|
|
},
|
|
ru: {
|
|
tipNoToken: 'UnoRouter: нет токена — нажми, чтобы задать',
|
|
tipBadToken: 'UnoRouter: токен отклонён (401/403). ПКМ — заменить.',
|
|
tipLoading: 'UnoRouter: загружаю…',
|
|
tipError: 'UnoRouter: ошибка —',
|
|
tipOpen: 'Нажми для подробностей',
|
|
tipAvailable: 'доступно',
|
|
refreshTip: 'Обновить',
|
|
rowAvailable: 'Доступно',
|
|
rowSubscription: 'Подписка',
|
|
rowPlan: 'План',
|
|
rowResets: 'Сброс через',
|
|
rowWallet: 'Кошелёк',
|
|
rowSpent: 'Потрачено всего',
|
|
rowRequests: 'Запросов',
|
|
popLoading: 'Загружаю…',
|
|
popError: 'Не удалось получить баланс',
|
|
promptToken: 'Access token UnoRouter (unorouter.com → Settings → API access):',
|
|
tokenSaved: 'Токен UnoRouter сохранён'
|
|
}
|
|
})
|
|
|
|
try {
|
|
const saved = ctx.storage.get('token', '')
|
|
if (saved) setToken(saved)
|
|
} catch {
|
|
/* keep DEFAULT_TOKEN */
|
|
}
|
|
|
|
ctx.register({
|
|
id: 'chip',
|
|
area: STATUSBAR_AREAS.right,
|
|
order: 115,
|
|
render: () => jsx(BalanceChip, {})
|
|
})
|
|
|
|
ctx.register({
|
|
id: 'refresh',
|
|
area: PALETTE_AREA,
|
|
data: {
|
|
id: 'unorouter-balance.refresh',
|
|
label: 'UnoRouter: refresh balance',
|
|
keywords: ['unorouter', 'balance', 'quota', 'subscription', 'billing'],
|
|
run: () => {
|
|
queryClient.invalidateQueries({ queryKey: [ID, 'balance'] })
|
|
host.notify({ kind: 'info', message: 'Refreshing UnoRouter balance…' })
|
|
}
|
|
}
|
|
})
|
|
|
|
ctx.register({
|
|
id: 'set-token',
|
|
area: PALETTE_AREA,
|
|
data: {
|
|
id: 'unorouter-balance.setToken',
|
|
label: 'UnoRouter: set access token',
|
|
keywords: ['unorouter', 'token', 'api'],
|
|
run: () => {
|
|
const next =
|
|
typeof window !== 'undefined'
|
|
? window.prompt('UnoRouter access token:', token || '')
|
|
: null
|
|
if (next === null || next === undefined) return
|
|
setToken(next)
|
|
persistToken()
|
|
queryClient.invalidateQueries({ queryKey: [ID, 'balance'] })
|
|
host.notify({ kind: 'info', message: 'UnoRouter token saved' })
|
|
}
|
|
}
|
|
})
|
|
|
|
ctx.register({
|
|
id: 'open-billing',
|
|
area: PALETTE_AREA,
|
|
data: {
|
|
id: 'unorouter-balance.openBilling',
|
|
label: 'UnoRouter: open billing page',
|
|
keywords: ['unorouter', 'topup', 'billing'],
|
|
run: () => void ctx.os.openExternal(BILLING_URL)
|
|
}
|
|
})
|
|
|
|
ctx.onDispose(() => {
|
|
store = null
|
|
})
|
|
}
|
|
}
|