Add plugin

This commit is contained in:
admin committed 2026-10-06 12:24:39 +00:00
1 parent d0f11f8a4c
commit 218ca12432
1 file changed
+548
+548
View File
@@ -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 <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
})
}
}