Merge pull request #21995 from NousResearch/feature/desktop-remote-gateway-settings

Add desktop remote gateway settings
This commit is contained in:
brooklyn! 2026-05-08 10:45:39 -07:00 committed by GitHub
commit 94fbfb2019
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 606 additions and 23 deletions

View file

@ -7,6 +7,7 @@ const {
dialog,
ipcMain,
nativeImage,
safeStorage,
session,
shell,
systemPreferences
@ -44,6 +45,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime')
const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json')
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log')
const DESKTOP_LOG_FLUSH_MS = 120
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
@ -205,6 +207,7 @@ app.setAboutPanelOptions({
let mainWindow = null
let hermesProcess = null
let connectionPromise = null
let connectionConfigCache = null
const hermesLog = []
const previewWatchers = new Map()
let previewShortcutActive = false
@ -692,9 +695,16 @@ async function pickPort() {
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http
const req = http.request(
url,
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const req = client.request(
parsed,
{
method: options.method || 'GET',
headers: {
@ -722,6 +732,11 @@ function fetchJson(url, token, options = {}) {
)
req.on('error', reject)
if (options.timeoutMs) {
req.setTimeout(options.timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${options.timeoutMs}ms`))
})
}
if (body) req.write(body)
req.end()
})
@ -1229,32 +1244,247 @@ function installMediaPermissions() {
})
}
function resolveRemoteBackend() {
const rawUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (!rawUrl) return null
if (!rawToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
if (!value) {
throw new Error('Remote gateway URL is required.')
}
let parsed
try {
parsed = new URL(rawUrl)
parsed = new URL(value)
} catch (error) {
throw new Error(`HERMES_DESKTOP_REMOTE_URL is not a valid URL: ${error.message}`)
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`HERMES_DESKTOP_REMOTE_URL must be http:// or https://, got ${parsed.protocol}`)
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
}
const baseUrl = `${parsed.protocol}//${parsed.host}`
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const wsUrl = `${wsScheme}://${parsed.host}/api/ws?token=${encodeURIComponent(rawToken)}`
parsed.hash = ''
parsed.search = ''
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
return { baseUrl, token: rawToken, wsUrl }
return parsed.toString().replace(/\/+$/, '')
}
function buildGatewayWsUrl(baseUrl, token) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
}
function tokenPreview(value) {
const raw = String(value || '')
if (!raw) {
return null
}
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
}
function encryptDesktopSecret(value) {
const raw = String(value || '')
if (!raw) {
return null
}
try {
if (safeStorage.isEncryptionAvailable()) {
return {
encoding: 'safeStorage',
value: safeStorage.encryptString(raw).toString('base64')
}
}
} catch {
// Fall through to plaintext for platforms where Electron cannot encrypt.
}
return { encoding: 'plain', value: raw }
}
function decryptDesktopSecret(secret) {
if (!secret || typeof secret !== 'object') {
return ''
}
const value = String(secret.value || '')
if (!value) {
return ''
}
if (secret.encoding === 'safeStorage') {
try {
return safeStorage.decryptString(Buffer.from(value, 'base64'))
} catch {
return ''
}
}
return value
}
function readDesktopConnectionConfig() {
if (connectionConfigCache) {
return connectionConfigCache
}
let config = { mode: 'local', remote: {} }
try {
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote: parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
}
}
} catch {
// Missing or malformed connection settings should fall back to local.
}
connectionConfigCache = config
return config
}
function writeDesktopConnectionConfig(config) {
fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true })
fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
connectionConfigCache = config
}
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
const remoteToken = decryptDesktopSecret(config.remote?.token)
return {
mode: config.mode === 'remote' ? 'remote' : 'local',
remoteUrl: String(config.remote?.url || ''),
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
}
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig()) {
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
token: incomingToken ? encryptDesktopSecret(incomingToken) : existingToken
}
if (mode === 'remote') {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
if (!decryptDesktopSecret(nextRemote.token)) {
throw new Error('Remote gateway session token is required.')
}
} else if (remoteUrl) {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
}
return { mode, remote: nextRemote }
}
function resolveRemoteBackend() {
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
return {
baseUrl,
mode: 'remote',
source: 'env',
token: rawEnvToken,
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
}
}
const config = readDesktopConnectionConfig()
if (config.mode !== 'remote') {
return null
}
const token = decryptDesktopSecret(config.remote?.token)
if (!token) {
throw new Error(
'Remote Hermes gateway is selected, but no session token is saved. ' +
'Open Settings → Gateway and save a token, or switch back to Local.'
)
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
return {
baseUrl,
mode: 'remote',
source: 'settings',
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input)
const remote = config.mode === 'remote'
? {
baseUrl: normalizeRemoteBaseUrl(config.remote.url),
token: decryptDesktopSecret(config.remote.token)
}
: resolveRemoteBackend() || (await startHermes())
const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 })
return {
ok: true,
baseUrl: remote.baseUrl,
version: status?.version || null
}
}
function resetBootProgressForReconnect() {
updateBootProgress(
{
error: null,
message: 'Restarting desktop connection',
phase: 'backend.resolve',
progress: 4,
running: true
},
{ allowDecrease: true }
)
}
function resetHermesConnection() {
connectionPromise = null
if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM')
}
hermesProcess = null
resetBootProgressForReconnect()
}
async function startHermes() {
@ -1275,6 +1505,8 @@ async function startHermes() {
})
return {
baseUrl: remote.baseUrl,
mode: 'remote',
source: remote.source,
token: remote.token,
wsUrl: remote.wsUrl,
logs: hermesLog.slice(-80),
@ -1368,6 +1600,8 @@ async function startHermes() {
return {
baseUrl,
mode: 'local',
source: 'local',
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
@ -1440,6 +1674,22 @@ function createWindow() {
ipcMain.handle('hermes:connection', async () => startHermes())
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config)
})
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
resetHermesConnection()
setTimeout(() => mainWindow?.reload(), 150)
return sanitizeDesktopConnectionConfig(config)
})
ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
previewShortcutActive = Boolean(active)

View file

@ -3,6 +3,10 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),

View file

@ -311,8 +311,9 @@ export const MODE_OPTIONS: ModeOption[] = [
{ id: 'system', label: 'System', description: 'Follow macOS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'config' | 'keys' | 'tools', string> = {
export const SEARCH_PLACEHOLDER: Record<'config' | 'gateway' | 'keys' | 'tools', string> = {
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
tools: 'Search skills and tools...'
}

View file

@ -0,0 +1,279 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AlertCircle, Check, Globe, Loader2, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote'
interface GatewaySettingsState {
envOverride: boolean
mode: Mode
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
}
const EMPTY_STATE: GatewaySettingsState = {
envOverride: false,
mode: 'local',
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
}
function ModeCard({
active,
description,
disabled,
icon: Icon,
onSelect,
title
}: {
active: boolean
description: string
disabled?: boolean
icon: typeof Monitor
onSelect: () => void
title: string
}) {
return (
<button
className={cn(
'rounded-2xl border p-4 text-left transition',
active ? 'border-primary bg-primary/10 ring-2 ring-primary/15' : 'border-border bg-background/60 hover:bg-muted/40',
disabled && 'cursor-not-allowed opacity-50'
)}
disabled={disabled}
onClick={onSelect}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{active ? <Check className="ml-auto size-4 text-primary" /> : null}
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{description}</p>
</button>
)
}
export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
if (!desktop?.getConnectionConfig) {
setLoading(false)
return () => void (cancelled = true)
}
desktop
.getConnectionConfig()
.then(config => {
if (cancelled) {
return
}
setState(config)
})
.catch(err => notifyError(err, 'Gateway settings failed to load'))
.finally(() => {
if (!cancelled) {
setLoading(false)
}
})
return () => void (cancelled = true)
}, [])
const canUseRemote = useMemo(
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
[remoteToken, state.remoteTokenSet, state.remoteUrl]
)
const payload = () => ({
mode: state.mode,
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
})
const save = async (apply: boolean) => {
if (state.mode === 'remote' && !canUseRemote) {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message: 'Enter a remote URL and session token before switching to remote.'
})
return
}
setSaving(true)
try {
const next = apply
? await window.hermesDesktop.applyConnectionConfig(payload())
: await window.hermesDesktop.saveConnectionConfig(payload())
setState(next)
setRemoteToken('')
notify({
kind: 'success',
title: apply ? 'Gateway connection restarting' : 'Gateway settings saved',
message: apply ? 'Hermes Desktop will reconnect using the saved settings.' : 'Saved for the next restart.'
})
} catch (err) {
notifyError(err, apply ? 'Could not apply gateway settings' : 'Could not save gateway settings')
} finally {
setSaving(false)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message: 'Enter a remote URL and session token before testing.'
})
return
}
setTesting(true)
setLastTest(null)
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
})
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
setLastTest(message)
notify({ kind: 'success', title: 'Remote gateway reachable', message })
} catch (err) {
notifyError(err, 'Remote gateway test failed')
} finally {
setTesting(false)
}
}
if (loading) {
return <LoadingState label="Loading gateway settings..." />
}
if (!window.hermesDesktop?.getConnectionConfig) {
return <EmptyState description="The desktop IPC bridge does not expose gateway settings." title="Gateway settings unavailable" />
}
return (
<SettingsContent>
<div className="mb-6">
<div className="flex items-center gap-2 text-sm font-medium">
<Globe className="size-4 text-muted-foreground" />
Gateway Connection
{state.envOverride ? <Pill tone="primary">env override</Pill> : null}
</div>
<p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to
control an already-running Hermes dashboard backend on another machine or behind a trusted proxy.
</p>
</div>
{state.envOverride ? (
<div className="mb-5 flex items-start gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Environment variables are controlling this desktop session.</div>
<div className="mt-1 leading-5">
Unset <code>HERMES_DESKTOP_REMOTE_URL</code> and <code>HERMES_DESKTOP_REMOTE_TOKEN</code> to use the saved
setting below.
</div>
</div>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<ModeCard
active={state.mode === 'local'}
description="Start a private Hermes dashboard backend on localhost. This is the default and works offline."
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title="Local gateway"
/>
<ModeCard
active={state.mode === 'remote'}
description="Connect this desktop shell to a remote Hermes dashboard backend using its session token."
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
title="Remote gateway"
/>
</div>
<div className="mt-5 divide-y divide-border/40">
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
placeholder="https://gateway.example.com/hermes"
value={state.remoteUrl}
/>
}
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
title="Remote URL"
/>
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
</div>
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap justify-end gap-3">
<Button disabled={state.envOverride || testing || !canUseRemote} onClick={() => void testRemote()} variant="outline">
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
Test remote
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} variant="outline">
Save for next restart
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)}>
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
Save and reconnect
</Button>
</div>
</SettingsContent>
)
}

View file

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Package } from '@/lib/icons'
import { Globe, KeyRound, Package } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { OverlayIconButton } from '../overlays/overlay-chrome'
@ -14,6 +14,7 @@ import { OverlayView } from '../overlays/overlay-view'
import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings'
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { ToolsSettings } from './tools-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
@ -23,6 +24,7 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
config: '',
gateway: '',
keys: '',
tools: ''
})
@ -116,6 +118,12 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
)
})}
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
label="Gateway"
onClick={() => setActiveView('gateway')}
/>
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
@ -157,6 +165,8 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
<OverlayMain className="p-0">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'gateway' ? (
<GatewaySettings />
) : activeView.startsWith('config:') ? (
<ConfigSettings
activeSectionId={activeView.slice('config:'.length)}

View file

@ -3,8 +3,8 @@ import type { Dispatch, SetStateAction } from 'react'
import type { LucideIcon } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'keys' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'config' | 'keys' | 'tools'
export type SettingsView = 'gateway' | 'keys' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'config' | 'gateway' | 'keys' | 'tools'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {

View file

@ -161,6 +161,10 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error)
const resetToLocalGateway = async () => {
await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' })
}
return (
<div className="grid gap-3" role="status">
<p className="text-sm text-muted-foreground">
@ -179,7 +183,16 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
<span className="truncate">{boot.message}</span>
<span>{progress}%</span>
</div>
{hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null}
{hasError ? (
<div className="grid gap-3">
<p className="text-xs text-destructive">{boot.error}</p>
<div>
<Button onClick={() => void resetToLocalGateway()} size="sm" variant="outline">
Use local gateway
</Button>
</div>
</div>
) : null}
</div>
)
}

View file

@ -5,6 +5,10 @@ declare global {
hermesDesktop: {
getConnection: () => Promise<HermesConnection>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
@ -33,12 +37,34 @@ declare global {
export interface HermesConnection {
baseUrl: string
mode?: 'local' | 'remote'
source?: 'env' | 'local' | 'settings'
token: string
wsUrl: string
logs: string[]
windowButtonPosition: { x: number; y: number } | null
}
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
}
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
remoteToken?: string
remoteUrl?: string
}
export interface DesktopConnectionTestResult {
baseUrl: string
ok: boolean
version: string | null
}
export interface DesktopBootProgress {
error: string | null
fakeMode: boolean