diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 395dccfe165..63e6ab31c4a 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 2c078e6cad6..fec889f6433 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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'), diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 8591664ae20..13383c8b225 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -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...' } diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx new file mode 100644 index 00000000000..b1bc760547b --- /dev/null +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -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 ( + + ) +} + +export function GatewaySettings() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [state, setState] = useState(EMPTY_STATE) + const [remoteToken, setRemoteToken] = useState('') + const [lastTest, setLastTest] = useState(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 + } + + if (!window.hermesDesktop?.getConnectionConfig) { + return + } + + return ( + +
+
+ + Gateway Connection + {state.envOverride ? env override : null} +
+

+ 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. +

+
+ + {state.envOverride ? ( +
+ +
+
Environment variables are controlling this desktop session.
+
+ Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved + setting below. +
+
+
+ ) : null} + +
+ setState(current => ({ ...current, mode: 'local' }))} + title="Local gateway" + /> + setState(current => ({ ...current, mode: 'remote' }))} + title="Remote gateway" + /> +
+ +
+ 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" + /> + 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" + /> +
+ + {lastTest ?
{lastTest}
: null} + +
+ + + +
+
+ ) +} diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 6104e86372c..a68dfa59c05 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -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>({ config: '', + gateway: '', keys: '', tools: '' }) @@ -116,6 +118,12 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) { ) })}
+ setActiveView('gateway')} + /> {activeView === 'config:appearance' ? ( + ) : activeView === 'gateway' ? ( + ) : activeView.startsWith('config:') ? ( > export interface SettingsPageProps { diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 14dbfe724b6..6a12f40ac16 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -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 (

@@ -179,7 +183,16 @@ function Preparing({ boot }: { boot: DesktopBootState }) { {boot.message} {progress}%

- {hasError ?

{boot.error}

: null} + {hasError ? ( +
+

{boot.error}

+
+ +
+
+ ) : null}
) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c52a00d67df..f777b263edf 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -5,6 +5,10 @@ declare global { hermesDesktop: { getConnection: () => Promise getBootProgress: () => Promise + getConnectionConfig: () => Promise + saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise + applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise + testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise api: (request: HermesApiRequest) => Promise notify: (payload: HermesNotification) => Promise requestMicrophoneAccess: () => Promise @@ -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