Add plugin
This commit is contained in:
1 parent
d0f11f8a4c
commit
218ca12432
1 file changed
+548
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user