Add desktop remote gateway settings
Make the desktop gateway connection configurable from settings so local remains the default while remote backends can be saved, tested, and applied without environment variables.
This commit is contained in:
+268
-18
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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...'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user