713 lines
31 KiB
JavaScript
713 lines
31 KiB
JavaScript
import {
|
|
Badge,
|
|
Button,
|
|
Codicon,
|
|
EmptyState,
|
|
ErrorState,
|
|
Input,
|
|
PANES_AREA,
|
|
PALETTE_AREA,
|
|
SIDEBAR_NAV_AREA,
|
|
STATUSBAR_AREAS,
|
|
StatusDot,
|
|
Tip,
|
|
host,
|
|
useMutation,
|
|
usePluginI18n,
|
|
useQuery,
|
|
useQueryClient,
|
|
useValue
|
|
} from '@hermes/plugin-sdk'
|
|
import { useState, useRef, useEffect } from 'react'
|
|
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
|
|
|
|
const PLUGIN_ID = 'hermes-relay'
|
|
|
|
const messages = {
|
|
en: {
|
|
'action.cancel': 'Cancel',
|
|
'action.confirm': 'Confirm',
|
|
'action.copy': 'Copy invite',
|
|
'action.open': 'Open Hermes Relay',
|
|
'action.pair': 'Pair Android',
|
|
'action.pairDesktop': 'Desktop compatibility invite',
|
|
'action.refresh': 'Refresh',
|
|
'activity.empty': 'No recent bridge activity.',
|
|
'activity.title': 'Bridge activity',
|
|
'management.empty': 'No paired devices.',
|
|
'management.title': 'Relay management',
|
|
'media.empty': 'No active media deliveries.',
|
|
'media.title': 'Media',
|
|
'nav.label': 'Relay',
|
|
'pairing.copyFailed': 'The Desktop clipboard bridge is unavailable. Select and copy the invite manually.',
|
|
'pairing.expires': 'One-time invite. Keep it private and use it before it expires.',
|
|
'pairing.title': 'Pairing invite',
|
|
'pane.description': 'Profile-scoped views of the existing Hermes-Relay service.',
|
|
'pane.title': 'Hermes Relay',
|
|
'remote.disable': 'Disable Tailscale serving',
|
|
'remote.enable': 'Enable Tailscale serving',
|
|
'remote.probe': 'Probe URL',
|
|
'remote.save': 'Save public URL',
|
|
'remote.title': 'Remote access',
|
|
'session.revoke': 'Revoke',
|
|
'session.revokeConfirm': 'Revoke this device session? The device must pair again.',
|
|
'status.detail': 'Open the profile-scoped Relay management pane',
|
|
'status.label': 'Relay'
|
|
}
|
|
}
|
|
|
|
export function profileQueryKey(profile, resource) {
|
|
return [PLUGIN_ID, profile || 'default', resource]
|
|
}
|
|
|
|
/**
|
|
* Pairing defaults to Dashboard-origin routes discovered by the backend.
|
|
* Current standalone Desktop clients cannot obtain Dashboard WebSocket
|
|
* tickets, so their direct Relay fallback is an explicit, separate action.
|
|
*/
|
|
export function pairingMintBody({ desktopCompatibility = false } = {}) {
|
|
return {
|
|
mode: 'auto',
|
|
...(desktopCompatibility ? { legacy_direct_relay: true } : {})
|
|
}
|
|
}
|
|
|
|
function text(value, fallback = '—') {
|
|
if (value === null || value === undefined || value === '') return fallback
|
|
if (typeof value === 'object') return JSON.stringify(value)
|
|
return String(value)
|
|
}
|
|
|
|
function list(value, key) {
|
|
if (Array.isArray(value)) return value
|
|
if (value && Array.isArray(value[key])) return value[key]
|
|
return []
|
|
}
|
|
|
|
function friendlyError(error) {
|
|
if (!error) return 'Unknown error'
|
|
if (error instanceof Error) return error.message
|
|
return String(error)
|
|
}
|
|
|
|
function formatTime(value) {
|
|
if (!value) return '—'
|
|
const numeric = Number(value)
|
|
const millis = Number.isFinite(numeric) && numeric < 10_000_000_000 ? numeric * 1000 : numeric
|
|
const date = new Date(Number.isFinite(millis) ? millis : value)
|
|
return Number.isNaN(date.getTime()) ? text(value) : date.toLocaleString()
|
|
}
|
|
|
|
function Field({ label, value }) {
|
|
return jsxs('div', {
|
|
className: 'min-w-0 rounded-md border border-(--ui-stroke-tertiary) px-3 py-2',
|
|
children: [
|
|
jsx('div', {
|
|
className: 'text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-quaternary)',
|
|
children: label
|
|
}),
|
|
jsx('div', {
|
|
className: 'mt-1 break-words text-xs text-(--ui-text-secondary)',
|
|
children: text(value)
|
|
})
|
|
]
|
|
})
|
|
}
|
|
|
|
function SectionHeader({ action, description, title }) {
|
|
return jsxs('header', {
|
|
className: 'mb-4 flex flex-wrap items-start justify-between gap-3',
|
|
children: [
|
|
jsxs('div', {
|
|
children: [
|
|
jsx('h2', { className: 'text-sm font-semibold text-(--ui-text-primary)', children: title }),
|
|
description
|
|
? jsx('p', { className: 'mt-1 text-xs leading-5 text-(--ui-text-tertiary)', children: description })
|
|
: null
|
|
]
|
|
}),
|
|
action
|
|
]
|
|
})
|
|
}
|
|
|
|
function QueryState({ empty, query, render }) {
|
|
if (query.isPending) {
|
|
return jsx('p', { className: 'py-8 text-xs text-(--ui-text-tertiary)', role: 'status', children: 'Loading…' })
|
|
}
|
|
if (query.error) {
|
|
return jsx(ErrorState, { description: friendlyError(query.error), title: 'Relay unavailable' })
|
|
}
|
|
if (empty(query.data)) {
|
|
return jsx(EmptyState, { description: '', title: 'Nothing to show' })
|
|
}
|
|
return render(query.data)
|
|
}
|
|
|
|
function createManagement(ctx) {
|
|
return function Management() {
|
|
const t = usePluginI18n(PLUGIN_ID)
|
|
const profile = useValue(host.state.profile)
|
|
const client = useQueryClient()
|
|
const [confirmPrefix, setConfirmPrefix] = useState(null)
|
|
const [pairing, setPairing] = useState(null)
|
|
const [desktopCompatibility, setDesktopCompatibility] = useState(false)
|
|
const [copyError, setCopyError] = useState(null)
|
|
const overviewKey = profileQueryKey(profile, 'overview')
|
|
const sessionsKey = profileQueryKey(profile, 'sessions')
|
|
const overview = useQuery({ queryKey: overviewKey, queryFn: () => ctx.rest('/overview'), retry: false })
|
|
const sessions = useQuery({ queryKey: sessionsKey, queryFn: () => ctx.rest('/sessions'), retry: false })
|
|
const refresh = () => {
|
|
void client.invalidateQueries({ queryKey: overviewKey })
|
|
void client.invalidateQueries({ queryKey: sessionsKey })
|
|
}
|
|
const pair = useMutation({
|
|
mutationFn: options => ctx.rest('/pairing', { method: 'POST', body: pairingMintBody(options) }),
|
|
onSuccess: (result, options) => {
|
|
setCopyError(null)
|
|
setDesktopCompatibility(options?.desktopCompatibility === true)
|
|
setPairing(result)
|
|
}
|
|
})
|
|
const revoke = useMutation({
|
|
mutationFn: prefix => ctx.rest(`/sessions/${encodeURIComponent(prefix)}`, { method: 'DELETE' }),
|
|
onSuccess: () => {
|
|
setConfirmPrefix(null)
|
|
void client.invalidateQueries({ queryKey: sessionsKey })
|
|
}
|
|
})
|
|
const rows = list(sessions.data, 'sessions')
|
|
|
|
const copyInvite = async () => {
|
|
const invite = pairing?.pairing_url || pairing?.qr_payload
|
|
if (!invite) return
|
|
if (!(await ctx.os.writeClipboard(invite))) setCopyError(t('pairing.copyFailed'))
|
|
}
|
|
|
|
return jsxs('section', {
|
|
children: [
|
|
jsx(SectionHeader, {
|
|
title: t('management.title'),
|
|
description: `Profile: ${profile || 'default'}`,
|
|
action: jsxs('div', {
|
|
className: 'flex gap-2',
|
|
children: [
|
|
jsx(Button, { size: 'sm', variant: 'outline', onClick: refresh, children: t('action.refresh') }),
|
|
jsx(Button, {
|
|
size: 'sm',
|
|
disabled: pair.isPending,
|
|
onClick: () => pair.mutate({ desktopCompatibility: false }),
|
|
children: pair.isPending ? 'Creating…' : t('action.pair')
|
|
}),
|
|
jsx(Button, {
|
|
size: 'sm',
|
|
variant: 'outline',
|
|
disabled: pair.isPending,
|
|
onClick: () => pair.mutate({ desktopCompatibility: true }),
|
|
children: t('action.pairDesktop')
|
|
})
|
|
]
|
|
})
|
|
}),
|
|
overview.error
|
|
? jsx(ErrorState, { title: 'Relay unavailable', description: friendlyError(overview.error) })
|
|
: overview.data
|
|
? jsxs('div', {
|
|
className: 'mb-5 grid gap-2 sm:grid-cols-2 lg:grid-cols-4',
|
|
children: [
|
|
jsx(Field, { label: 'Health', value: overview.data.health || overview.data.status }),
|
|
jsx(Field, { label: 'Version', value: overview.data.version }),
|
|
jsx(Field, { label: 'Uptime (seconds)', value: overview.data.uptime_seconds }),
|
|
jsx(Field, { label: 'Connected', value: overview.data.connected_clients ?? overview.data.active_sessions })
|
|
]
|
|
})
|
|
: null,
|
|
pair.error ? jsx(ErrorState, { title: 'Pairing failed', description: friendlyError(pair.error) }) : null,
|
|
pairing
|
|
? jsxs('div', {
|
|
className: 'mb-5 rounded-md border border-(--ui-accent) p-3',
|
|
children: [
|
|
jsxs('div', {
|
|
className: 'flex flex-wrap items-center justify-between gap-2',
|
|
children: [
|
|
jsxs('div', {
|
|
children: [
|
|
jsx('h3', { className: 'text-xs font-semibold text-(--ui-text-primary)', children: t('pairing.title') }),
|
|
jsx('p', { className: 'mt-1 text-xs text-(--ui-text-tertiary)', children: t('pairing.expires') })
|
|
]
|
|
}),
|
|
jsx(Button, { size: 'sm', variant: 'outline', onClick: copyInvite, children: t('action.copy') })
|
|
]
|
|
}),
|
|
pairing.code
|
|
? jsx('div', { className: 'mt-3 font-mono text-xl tracking-widest', children: pairing.code })
|
|
: null,
|
|
desktopCompatibility
|
|
? jsx('p', {
|
|
className: 'mt-2 text-[0.6875rem] text-(--ui-text-tertiary)',
|
|
children: 'Includes an explicit direct Relay fallback for current Desktop clients. Keep that route deliberately reachable; Android pairing does not need it.'
|
|
})
|
|
: jsx('p', {
|
|
className: 'mt-2 text-[0.6875rem] text-(--ui-text-tertiary)',
|
|
children: 'Uses Dashboard-origin routes discovered by the server. Direct Relay port 8767 is not advertised.'
|
|
}),
|
|
jsx('div', {
|
|
className: 'mt-2 select-all break-all font-mono text-[0.6875rem] text-(--ui-text-tertiary)',
|
|
children: pairing.pairing_url || pairing.qr_payload || 'Invite created'
|
|
}),
|
|
copyError ? jsx('p', { className: 'mt-2 text-xs text-destructive', children: copyError }) : null
|
|
]
|
|
})
|
|
: null,
|
|
revoke.error ? jsx(ErrorState, { title: 'Revoke failed', description: friendlyError(revoke.error) }) : null,
|
|
jsx(QueryState, {
|
|
query: sessions,
|
|
empty: data => list(data, 'sessions').length === 0,
|
|
render: () => jsxs('div', {
|
|
className: 'overflow-hidden rounded-md border border-(--ui-stroke-tertiary)',
|
|
children: rows.map((session, index) => {
|
|
const prefix = session.token_prefix || session.prefix || session.id || `session-${index}`
|
|
const confirming = confirmPrefix === prefix
|
|
return jsxs('div', {
|
|
className: 'flex flex-wrap items-center justify-between gap-3 border-b border-(--ui-stroke-tertiary) px-3 py-3 last:border-b-0',
|
|
children: [
|
|
jsxs('div', {
|
|
className: 'min-w-0',
|
|
children: [
|
|
jsxs('div', {
|
|
className: 'flex flex-wrap items-center gap-2',
|
|
children: [
|
|
jsx('span', { className: 'font-medium text-xs text-(--ui-text-primary)', children: session.device_name || session.name || prefix }),
|
|
jsx(Badge, { children: session.device_type || session.client_type || 'device' })
|
|
]
|
|
}),
|
|
jsx('p', {
|
|
className: 'mt-1 text-[0.6875rem] text-(--ui-text-tertiary)',
|
|
children: `${prefix} · paired ${formatTime(session.paired_at)}`
|
|
})
|
|
]
|
|
}),
|
|
confirming
|
|
? jsxs('div', {
|
|
className: 'flex items-center gap-2',
|
|
children: [
|
|
jsx('span', { className: 'max-w-52 text-right text-[0.6875rem] text-(--ui-text-tertiary)', children: t('session.revokeConfirm') }),
|
|
jsx(Button, { size: 'sm', variant: 'ghost', onClick: () => setConfirmPrefix(null), children: t('action.cancel') }),
|
|
jsx(Button, { size: 'sm', disabled: revoke.isPending, onClick: () => revoke.mutate(prefix), children: t('action.confirm') })
|
|
]
|
|
})
|
|
: jsx(Button, { size: 'sm', variant: 'outline', onClick: () => setConfirmPrefix(prefix), children: t('session.revoke') })
|
|
]
|
|
}, prefix)
|
|
})
|
|
})
|
|
})
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
function createActivity(ctx) {
|
|
return function Activity() {
|
|
const t = usePluginI18n(PLUGIN_ID)
|
|
const profile = useValue(host.state.profile)
|
|
const query = useQuery({
|
|
queryKey: profileQueryKey(profile, 'bridge-activity'),
|
|
queryFn: () => ctx.rest('/bridge-activity?limit=100'),
|
|
retry: false
|
|
})
|
|
return jsxs('section', {
|
|
children: [
|
|
jsx(SectionHeader, {
|
|
title: t('activity.title'),
|
|
description: 'Recent Relay-owned bridge commands. No live subscription or background polling.'
|
|
}),
|
|
jsx(QueryState, {
|
|
query,
|
|
empty: data => list(data, 'activity').length === 0,
|
|
render: data => jsx('div', {
|
|
className: 'space-y-2',
|
|
children: list(data, 'activity').map((item, index) => jsxs('div', {
|
|
className: 'rounded-md border border-(--ui-stroke-tertiary) px-3 py-2',
|
|
children: [
|
|
jsxs('div', {
|
|
className: 'flex flex-wrap items-center gap-2',
|
|
children: [
|
|
jsx(StatusDot, { tone: item.ok === false || item.error ? 'bad' : 'good' }),
|
|
jsx('span', { className: 'text-xs font-medium text-(--ui-text-primary)', children: item.command || item.type || item.action || 'Bridge command' }),
|
|
jsx('span', { className: 'ml-auto text-[0.6875rem] text-(--ui-text-quaternary)', children: formatTime(item.timestamp || item.created_at || item.at) })
|
|
]
|
|
}),
|
|
jsx('p', { className: 'mt-1 break-words text-[0.6875rem] text-(--ui-text-tertiary)', children: text(item.summary || item.result || item.error || item.status) })
|
|
]
|
|
}, item.id || `${index}`))
|
|
})
|
|
})
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
function createMedia(ctx) {
|
|
return function Media() {
|
|
const t = usePluginI18n(PLUGIN_ID)
|
|
const profile = useValue(host.state.profile)
|
|
const query = useQuery({
|
|
queryKey: profileQueryKey(profile, 'media'),
|
|
queryFn: () => ctx.rest('/media'),
|
|
retry: false
|
|
})
|
|
return jsxs('section', {
|
|
children: [
|
|
jsx(SectionHeader, {
|
|
title: t('media.title'),
|
|
description: 'Sanitized Relay media metadata. Filesystem paths and bearer tokens are not requested.'
|
|
}),
|
|
jsx(QueryState, {
|
|
query,
|
|
empty: data => list(data, 'media').length === 0,
|
|
render: data => jsx('div', {
|
|
className: 'grid gap-2 sm:grid-cols-2',
|
|
children: list(data, 'media').map((item, index) => jsxs('div', {
|
|
className: 'rounded-md border border-(--ui-stroke-tertiary) px-3 py-3',
|
|
children: [
|
|
jsx('div', { className: 'truncate text-xs font-medium text-(--ui-text-primary)', children: item.filename || item.name || `Media ${index + 1}` }),
|
|
jsx('p', { className: 'mt-1 text-[0.6875rem] text-(--ui-text-tertiary)', children: `${text(item.content_type || item.mime_type)} · ${text(item.size_bytes || item.size, 'unknown size')}` }),
|
|
jsx('p', { className: 'mt-1 text-[0.6875rem] text-(--ui-text-quaternary)', children: `expires ${formatTime(item.expires_at)}` })
|
|
]
|
|
}, item.id || item.token_prefix || `${index}`))
|
|
})
|
|
})
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
export function secureLinkPreflightPath(address, port) {
|
|
const query = new URLSearchParams()
|
|
if (address.trim()) query.set('host', address.trim())
|
|
if (port) query.set('port', String(port))
|
|
return `/remote-access/secure-link/preflight?${query}`
|
|
}
|
|
|
|
export function secureLinkPairingBody(report) {
|
|
if (!report?.pairing_ready || !report.current_url) throw new Error('Check Secure Link before creating an invite.')
|
|
return { mode: 'auto', prefer: 'plugin_proxy', dashboard_url: `${report.current_url}/dashboard` }
|
|
}
|
|
|
|
export function createSecureLinkSetup(ctx) {
|
|
return function SecureLinkSetup({ status }) {
|
|
const current = (() => { try { return new URL(status?.url) } catch { return null } })()
|
|
const [address, setAddress] = useState(current?.hostname || '')
|
|
const [port, setPort] = useState(current?.port || '9443')
|
|
const [report, setReport] = useState(null)
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [invite, setInvite] = useState('')
|
|
const [copied, setCopied] = useState(false)
|
|
const sequence = useRef(0)
|
|
useEffect(() => {
|
|
sequence.current += 1
|
|
setReport(null); setInvite(''); setError(''); setBusy(false); setCopied(false)
|
|
}, [status?.url])
|
|
const edit = (setter, value) => {
|
|
sequence.current += 1
|
|
setter(value); setReport(null); setInvite(''); setError(''); setBusy(false); setCopied(false)
|
|
}
|
|
const check = async () => {
|
|
const request = ++sequence.current
|
|
setBusy('check'); setError(''); setInvite(''); setCopied(false)
|
|
try {
|
|
const value = await ctx.rest(secureLinkPreflightPath(address, port))
|
|
if (request !== sequence.current) return
|
|
if (value?.schema_version !== 1 || !Array.isArray(value.checks)) throw new Error('Update Relay to use Secure Link setup checks.')
|
|
setReport(value)
|
|
if (!address && value.host) setAddress(value.host)
|
|
} catch (e) { if (request === sequence.current) { setReport(null); setError(friendlyError(e)) } }
|
|
finally { if (request === sequence.current) setBusy(false) }
|
|
}
|
|
const pair = async () => {
|
|
const request = ++sequence.current
|
|
setBusy('pair'); setError(''); setInvite(''); setCopied(false)
|
|
try {
|
|
const value = await ctx.rest('/pairing', { method: 'POST', body: secureLinkPairingBody(report) })
|
|
if (request !== sequence.current) return
|
|
const payload = JSON.parse(value.qr_payload)
|
|
if (!payload.endpoints?.some(endpoint => endpoint.proxy?.url === report.current_url)) {
|
|
throw new Error('Secure Link changed. Check it again before pairing.')
|
|
}
|
|
setInvite(value.pairing_url || value.qr_payload)
|
|
} catch (e) { if (request === sequence.current) setError(friendlyError(e)) }
|
|
finally { if (request === sequence.current) setBusy(false) }
|
|
}
|
|
const environment = Object.entries(report?.environment || {}).map(([key, value]) => `${key}=${value}`).join('\n')
|
|
const copy = async value => {
|
|
if (await ctx.os.writeClipboard(value)) setCopied(true)
|
|
else setError('Clipboard unavailable. Select and copy the text below.')
|
|
}
|
|
return jsxs('div', {
|
|
className: 'mt-4 space-y-3 rounded-md border border-(--ui-stroke-tertiary) p-3',
|
|
children: [
|
|
jsx('h3', { className: 'text-sm font-semibold', children: 'Secure Link setup' }),
|
|
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: 'Read-only checks and startup instructions. No service is restarted and no certificate is created here.' }),
|
|
jsx('label', { htmlFor: 'relay-secure-host', className: 'text-xs', children: 'Address the phone will use' }),
|
|
jsx(Input, { id: 'relay-secure-host', value: address, placeholder: '192.168.1.20 or relay.example', onChange: event => edit(setAddress, event.target.value) }),
|
|
jsx('label', { htmlFor: 'relay-secure-port', className: 'text-xs', children: 'HTTPS port' }),
|
|
jsx(Input, { id: 'relay-secure-port', value: port, inputMode: 'numeric', onChange: event => edit(setPort, event.target.value) }),
|
|
jsx(Button, { size: 'sm', variant: 'outline', disabled: !!busy, onClick: check, children: busy === 'check' ? 'Checking…' : report || error ? 'Check again' : 'Check this server' }),
|
|
error ? jsx(ErrorState, { title: 'Secure Link check needed', description: error }) : null,
|
|
...(report?.checks || []).map(item => jsx(Field, { label: `${item.label} · ${item.status}`, value: item.detail }, item.id)),
|
|
report ? jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: `${report.connected_clients} Relay client(s) connected. ${report.restart_notice}` }) : null,
|
|
report?.ready_to_enable && !report.pairing_ready ? jsxs('div', { className: 'space-y-2', children: [
|
|
jsx(Field, { label: 'Proposed HTTPS origin', value: report.url }),
|
|
report.requires_repair ? jsx('p', { className: 'text-xs', children: 'The paired address or port changes. Existing clients must re-pair after activation; this check does not replace certificates.' }) : null,
|
|
jsx('p', { className: 'text-xs', children: 'Add these settings to the existing Relay environment, restart its owner, then check again. Do not start a second Relay.' }),
|
|
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: report.configuration_note }),
|
|
jsx('pre', { className: 'whitespace-pre-wrap break-all select-text text-xs', children: environment }),
|
|
jsx(Button, { size: 'sm', variant: 'outline', onClick: () => copy(environment), children: copied ? 'Copied' : 'Copy settings' })
|
|
] }) : null,
|
|
report?.pairing_ready ? jsxs('div', { className: 'space-y-2', children: [
|
|
jsx(Field, { label: 'Listening', value: report.current_url }),
|
|
jsx('p', { className: 'text-xs', children: 'Create a fresh signed invite, then sign into Dashboard on the client. A healthy listener does not mean Chat is ready.' }),
|
|
jsx(Button, { size: 'sm', disabled: !!busy, onClick: pair, children: busy === 'pair' ? 'Creating invite…' : 'Create pairing invite' }),
|
|
jsx('p', { className: 'text-xs text-(--ui-text-tertiary)', children: 'For a scannable phone QR, use the Dashboard setup flow or hermes pair --png on the host.' })
|
|
] }) : null,
|
|
invite ? jsxs('div', { className: 'space-y-2', children: [
|
|
jsx('p', { className: 'text-xs', children: 'One-time invite. Keep it private and pair before it expires.' }),
|
|
jsx('textarea', { readOnly: true, rows: 3, value: invite, 'aria-label': 'Secure Link pairing invite', className: 'w-full rounded border border-(--ui-stroke-tertiary) bg-transparent p-2 font-mono text-xs' }),
|
|
jsx(Button, { size: 'sm', onClick: () => copy(invite), children: copied ? 'Copied' : 'Copy invite' })
|
|
] }) : null,
|
|
jsxs('details', { className: 'text-xs text-(--ui-text-tertiary)', children: [
|
|
jsx('summary', { children: 'Disable or recover' }),
|
|
jsx('p', { children: 'Set RELAY_SECURE_LINK_ENABLED=0 (or use --no-secure-link), then restart the existing Relay owner. Keep its certificate/key to reuse the same route. Certificate or address changes require re-pairing.' })
|
|
] })
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
function createRemoteAccess(ctx) {
|
|
const SecureLinkSetup = createSecureLinkSetup(ctx)
|
|
return function RemoteAccess() {
|
|
const t = usePluginI18n(PLUGIN_ID)
|
|
const profile = useValue(host.state.profile)
|
|
const client = useQueryClient()
|
|
const key = profileQueryKey(profile, 'remote-access')
|
|
const status = useQuery({ queryKey: key, queryFn: () => ctx.rest('/remote-access/status'), retry: false })
|
|
const [url, setUrl] = useState('')
|
|
const [confirmAction, setConfirmAction] = useState(null)
|
|
const [result, setResult] = useState(null)
|
|
const refresh = () => void client.invalidateQueries({ queryKey: key })
|
|
const mutate = useMutation({
|
|
mutationFn: async action => {
|
|
if (action === 'enable') return ctx.rest('/remote-access/tailscale/enable', { method: 'POST', body: { stack: true } })
|
|
if (action === 'disable') return ctx.rest('/remote-access/tailscale/disable', { method: 'POST', body: { stack: true } })
|
|
if (action === 'save') return ctx.rest('/remote-access/public-url', { method: 'PUT', body: { url: url.trim() || null } })
|
|
if (action === 'probe') return ctx.rest('/remote-access/probe', { method: 'POST', body: { candidates: [url.trim()] } })
|
|
throw new Error('Unknown remote access action')
|
|
},
|
|
onSuccess: value => {
|
|
setResult(value)
|
|
setConfirmAction(null)
|
|
refresh()
|
|
}
|
|
})
|
|
const actionButton = (action, label, destructive = false) => confirmAction === action
|
|
? jsxs('span', {
|
|
className: 'inline-flex items-center gap-1',
|
|
children: [
|
|
jsx(Button, { size: 'sm', variant: 'ghost', onClick: () => setConfirmAction(null), children: t('action.cancel') }),
|
|
jsx(Button, { size: 'sm', disabled: mutate.isPending, onClick: () => mutate.mutate(action), children: t('action.confirm') })
|
|
]
|
|
})
|
|
: jsx(Button, { size: 'sm', variant: destructive ? 'outline' : 'outline', onClick: () => setConfirmAction(action), children: label })
|
|
|
|
return jsxs('section', {
|
|
children: [
|
|
jsx(SectionHeader, {
|
|
title: t('remote.title'),
|
|
description: 'Service changes require confirmation. Secure Link setup checks are read-only.'
|
|
}),
|
|
jsx(QueryState, {
|
|
query: status,
|
|
empty: data => !data,
|
|
render: data => jsxs(Fragment, {
|
|
children: [
|
|
jsxs('div', {
|
|
className: 'grid gap-2 sm:grid-cols-2',
|
|
children: [
|
|
jsx(Field, { label: 'Tailscale', value: data.tailscale?.state || (data.tailscale?.enabled ? 'enabled' : 'disabled') }),
|
|
jsx(Field, { label: 'Public URL', value: data.public?.url || data.public_url }),
|
|
jsx(Field, { label: 'Secure Link', value: data.secure_link?.state || (data.secure_link?.enabled ? 'enabled' : 'disabled') }),
|
|
jsx(Field, { label: 'Upstream helper', value: data.upstream_canonical ? 'available' : 'not detected' })
|
|
]
|
|
}),
|
|
jsx(SecureLinkSetup, { status: data.secure_link }, profile || 'default'),
|
|
jsxs('div', {
|
|
className: 'mt-4 flex flex-wrap gap-2',
|
|
children: [
|
|
actionButton('enable', t('remote.enable')),
|
|
actionButton('disable', t('remote.disable'), true),
|
|
jsx(Button, { size: 'sm', variant: 'ghost', onClick: refresh, children: t('action.refresh') })
|
|
]
|
|
})
|
|
]
|
|
})
|
|
}),
|
|
jsxs('div', {
|
|
className: 'mt-5 rounded-md border border-(--ui-stroke-tertiary) p-3',
|
|
children: [
|
|
jsx('label', { className: 'text-xs font-medium text-(--ui-text-secondary)', htmlFor: 'hermes-relay-public-url', children: 'Public relay URL' }),
|
|
jsx(Input, {
|
|
id: 'hermes-relay-public-url',
|
|
className: 'mt-2',
|
|
placeholder: 'https://relay.example.com',
|
|
value: url,
|
|
onChange: event => setUrl(event.target.value)
|
|
}),
|
|
jsxs('div', {
|
|
className: 'mt-2 flex flex-wrap gap-2',
|
|
children: [
|
|
actionButton('save', t('remote.save')),
|
|
actionButton('probe', t('remote.probe'))
|
|
]
|
|
})
|
|
]
|
|
}),
|
|
mutate.error ? jsx(ErrorState, { title: 'Remote access action failed', description: friendlyError(mutate.error) }) : null,
|
|
result ? jsx('pre', { className: 'mt-3 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-(--ui-stroke-tertiary) p-3 text-[0.6875rem] text-(--ui-text-tertiary)', children: JSON.stringify(result, null, 2) }) : null
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
function createRelayPane(ctx) {
|
|
const Management = createManagement(ctx)
|
|
const Activity = createActivity(ctx)
|
|
const Media = createMedia(ctx)
|
|
const RemoteAccess = createRemoteAccess(ctx)
|
|
|
|
return function RelayPane() {
|
|
const t = usePluginI18n(PLUGIN_ID)
|
|
const [tab, setTab] = useState('management')
|
|
const tabs = [
|
|
['management', 'server', t('management.title')],
|
|
['activity', 'pulse', t('activity.title')],
|
|
['media', 'file-media', t('media.title')],
|
|
['remote', 'remote-explorer', t('remote.title')]
|
|
]
|
|
const content = tab === 'activity'
|
|
? jsx(Activity, {})
|
|
: tab === 'media'
|
|
? jsx(Media, {})
|
|
: tab === 'remote'
|
|
? jsx(RemoteAccess, {})
|
|
: jsx(Management, {})
|
|
|
|
return jsxs('div', {
|
|
className: 'flex h-full min-h-0 flex-col',
|
|
children: [
|
|
jsxs('header', {
|
|
className: 'border-b border-(--ui-stroke-tertiary) px-4 py-3',
|
|
children: [
|
|
jsxs('div', {
|
|
className: 'flex items-center gap-2',
|
|
children: [
|
|
jsx(Codicon, { name: 'radio-tower', size: '1rem' }),
|
|
jsx('h1', { className: 'text-sm font-semibold text-(--ui-text-primary)', children: t('pane.title') })
|
|
]
|
|
}),
|
|
jsx('p', { className: 'mt-1 text-xs text-(--ui-text-tertiary)', children: t('pane.description') }),
|
|
jsx('nav', {
|
|
'aria-label': 'Hermes Relay sections',
|
|
className: 'mt-3 flex flex-wrap gap-1',
|
|
children: tabs.map(([id, icon, label]) => jsx(Button, {
|
|
size: 'sm',
|
|
variant: tab === id ? 'secondary' : 'ghost',
|
|
onClick: () => setTab(id),
|
|
children: jsxs(Fragment, { children: [jsx(Codicon, { name: icon, size: '0.75rem' }), label] })
|
|
}, id))
|
|
})
|
|
]
|
|
}),
|
|
jsx('main', { className: 'min-h-0 flex-1 overflow-y-auto p-4', children: content })
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
function RelayStatus({ open }) {
|
|
const t = usePluginI18n(PLUGIN_ID)
|
|
return jsx(Tip, {
|
|
label: t('status.detail'),
|
|
children: jsxs('button', {
|
|
type: 'button',
|
|
className: 'inline-flex h-full items-center gap-1.5 px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
|
onClick: open,
|
|
children: [jsx(StatusDot, { tone: 'muted' }), jsx('span', { children: t('status.label') })]
|
|
})
|
|
})
|
|
}
|
|
|
|
const plugin = {
|
|
id: PLUGIN_ID,
|
|
name: 'Hermes Relay',
|
|
description: 'Profile-scoped Hermes-Relay management through the official Desktop Plugin SDK.',
|
|
defaultEnabled: false,
|
|
register(ctx) {
|
|
ctx.i18n.register(messages)
|
|
const RelayPane = createRelayPane(ctx)
|
|
let paneRegistered = false
|
|
|
|
const open = () => {
|
|
if (!paneRegistered) {
|
|
ctx.register({
|
|
id: 'management',
|
|
area: PANES_AREA,
|
|
title: ctx.i18n.t('pane.title'),
|
|
data: {
|
|
closeBehavior: 'dismiss',
|
|
placement: 'right',
|
|
dock: { pane: 'workspace', pos: 'right' },
|
|
width: 'min(420px, 42vw)'
|
|
},
|
|
render: () => jsx(RelayPane, {})
|
|
})
|
|
paneRegistered = true
|
|
}
|
|
ctx.panes.reveal('management')
|
|
}
|
|
|
|
ctx.onDispose(() => {
|
|
paneRegistered = false
|
|
})
|
|
|
|
ctx.registerMany([
|
|
{
|
|
id: 'nav',
|
|
area: SIDEBAR_NAV_AREA,
|
|
order: 75,
|
|
data: { codicon: 'radio-tower', label: ctx.i18n.t('nav.label'), onSelect: open }
|
|
},
|
|
{
|
|
id: 'status',
|
|
area: STATUSBAR_AREAS.right,
|
|
order: 110,
|
|
render: () => jsx(RelayStatus, { open })
|
|
},
|
|
{
|
|
id: 'open',
|
|
area: PALETTE_AREA,
|
|
data: {
|
|
id: 'hermes-relay.open',
|
|
label: ctx.i18n.t('action.open'),
|
|
keywords: ['relay', 'phone', 'paired devices', 'bridge', 'media', 'remote access'],
|
|
run: open
|
|
}
|
|
}
|
|
])
|
|
}
|
|
}
|
|
|
|
export default plugin
|