From 77591416e90c0d4d616f69251bfa32120e2e6a89 Mon Sep 17 00:00:00 2001 From: Max Mitcham Date: Thu, 4 Jun 2026 08:54:57 +0100 Subject: [PATCH] feat(desktop): remote-mode image attach/display + per-profile gateway switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three remote-mode improvements for the desktop app, plus the gateway endpoints they need (so the feature is self-contained on a fresh deploy): 1. Remote image attach — when connected to a remote gateway, upload image BYTES via a new `image.attach_bytes` RPC (tui_gateway/server.py) instead of sending a local path the gateway can't read. Local mode keeps path-based image.attach. 2. Remote image display — agent-created images live on the gateway's disk, which a remote client can't read. New `GET /api/media` (web_server.py) serves an image file as a base64 data URL (auth-gated, extension-allowlisted, size- capped); the desktop fetches via it in remote mode, with graceful fallback. 3. Per-profile gateway switching — connection.json now holds a `remotes[]` list (+ activeRemoteId, auto-migrated from the single remote), each carrying its auth mode (token or OAuth). Settings → Gateway manages them; the titlebar Profiles menu switches between them. Profile + auth mode are auto-detected from the gateway's /api/status. Integrates with the OAuth connection layer. Co-Authored-By: Claude Opus 4.8 --- apps/desktop/electron/main.cjs | 341 ++++++++-- apps/desktop/electron/preload.cjs | 11 + .../app/session/hooks/use-prompt-actions.ts | 48 +- .../src/app/settings/gateway-settings.tsx | 628 +++++++++--------- .../src/app/shell/titlebar-controls.tsx | 53 +- apps/desktop/src/app/types.ts | 6 + .../assistant-ui/directive-text.tsx | 14 +- .../components/assistant-ui/markdown-text.tsx | 8 + apps/desktop/src/global.d.ts | 27 + apps/desktop/src/lib/media.ts | 22 + hermes_cli/web_server.py | 44 ++ tui_gateway/server.py | 67 ++ 12 files changed, 912 insertions(+), 357 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 48a5e1cbd1eaf..9ded7a699854f 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3393,6 +3393,52 @@ function decryptDesktopSecret(secret) { return value } +function genConnectionId() { + return crypto.randomBytes(6).toString('hex') +} + +// Normalize parsed connection.json into the multi-remote schema. Migrates the +// legacy single-`remote` shape ({ mode, remote: { url, token, authMode } }) +// into a one-entry `remotes` list so older configs keep working transparently. +// Each entry carries its own authMode ('oauth' cookie + ws-ticket, or 'token' +// static session token); defaults to 'token' for backward compatibility. +function normalizeConnectionConfig(parsed) { + const mode = parsed?.mode === 'remote' ? 'remote' : 'local' + + const coerceEntry = r => ({ + id: typeof r.id === 'string' && r.id ? r.id : genConnectionId(), + label: typeof r.label === 'string' ? r.label : '', + profile: typeof r.profile === 'string' && r.profile ? r.profile : null, + url: String(r.url || ''), + authMode: r.authMode === 'oauth' ? 'oauth' : 'token', + token: r.token && typeof r.token === 'object' ? r.token : undefined + }) + + let remotes = [] + + if (Array.isArray(parsed?.remotes)) { + remotes = parsed.remotes.filter(r => r && typeof r === 'object' && typeof r.url === 'string').map(coerceEntry) + } else if (parsed?.remote && typeof parsed.remote === 'object' && (parsed.remote.url || parsed.remote.token)) { + remotes = [ + coerceEntry({ + id: genConnectionId(), + label: 'Remote', + profile: null, + url: parsed.remote.url, + authMode: parsed.remote.authMode, + token: parsed.remote.token + }) + ] + } + + const activeRemoteId = + typeof parsed?.activeRemoteId === 'string' && remotes.some(r => r.id === parsed.activeRemoteId) + ? parsed.activeRemoteId + : (remotes[0]?.id ?? null) + + return { mode, remotes, activeRemoteId } +} + function readDesktopConnectionConfig() { // Check if file changed on disk since last read (e.g. modified by another // process or an external tool). Our own writes update the cache inline @@ -3408,22 +3454,14 @@ function readDesktopConnectionConfig() { return connectionConfigCache } - let config = { mode: 'local', remote: {} } + let config = { mode: 'local', remotes: [], activeRemoteId: null } try { const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8') const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object') { - const remote = parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {} - // authMode lives on the remote sub-object: 'oauth' (cookie + ws-ticket) - // or 'token' (legacy static session token). Default to 'token' for - // backward compatibility with configs written before OAuth support. - remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' - config = { - mode: parsed.mode === 'remote' ? 'remote' : 'local', - remote - } + config = normalizeConnectionConfig(parsed) } } catch { // Missing or malformed connection settings should fall back to local. @@ -3435,6 +3473,16 @@ function readDesktopConnectionConfig() { return config } +// The remote connection a remote-mode session will use: the entry matching +// activeRemoteId, falling back to the first one. +function getActiveRemote(config = readDesktopConnectionConfig()) { + if (!config.remotes.length) { + return null + } + + return config.remotes.find(r => r.id === config.activeRemoteId) ?? config.remotes[0] +} + function writeDesktopConnectionConfig(config) { fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true }) writeFileAtomic(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2)) @@ -3442,63 +3490,98 @@ function writeDesktopConnectionConfig(config) { connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } -async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) { - const remoteToken = decryptDesktopSecret(config.remote?.token) - const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token' - const remoteUrl = String(config.remote?.url || '') +async function sanitizeRemoteEntry(remote) { + const token = decryptDesktopSecret(remote.token) + const authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' + const url = String(remote.url || '') - let remoteOauthConnected = false - if (authMode === 'oauth' && remoteUrl) { + let oauthConnected = false + if (authMode === 'oauth' && url) { try { - remoteOauthConnected = await hasOauthSessionCookie(remoteUrl) + oauthConnected = await hasOauthSessionCookie(url) } catch { - remoteOauthConnected = false + oauthConnected = false } } + return { + id: remote.id, + label: remote.label || '', + profile: remote.profile || null, + url, + authMode, + tokenPreview: tokenPreview(token), + tokenSet: Boolean(token), + oauthConnected + } +} + +async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) { + const active = getActiveRemote(config) + const remotes = [] + for (const remote of config.remotes) { + remotes.push(await sanitizeRemoteEntry(remote)) + } + + const activeSanitized = active ? remotes.find(r => r.id === active.id) : null + return { mode: config.mode === 'remote' ? 'remote' : 'local', - remoteAuthMode: authMode, - remoteOauthConnected, - remoteUrl, - remoteTokenPreview: tokenPreview(remoteToken), - remoteTokenSet: Boolean(remoteToken), - envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) + activeRemoteId: config.activeRemoteId, + remotes, + envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL), + // Legacy mirror of the active remote, kept so older callers (the existing + // single-connection gateway UI) keep working against the active remote. + remoteUrl: activeSanitized ? activeSanitized.url : '', + remoteAuthMode: activeSanitized ? activeSanitized.authMode : 'token', + remoteOauthConnected: activeSanitized ? activeSanitized.oauthConnected : false, + remoteTokenPreview: activeSanitized ? activeSanitized.tokenPreview : null, + remoteTokenSet: activeSanitized ? activeSanitized.tokenSet : false } } function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { const persistToken = options.persistToken !== false const mode = input.mode === 'remote' ? 'remote' : 'local' - const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim() - // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. - const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode) + const existingActive = getActiveRemote(existing) + const remoteUrl = String(input.remoteUrl ?? existingActive?.url ?? '').trim() + // authMode: explicit input wins; otherwise inherit the active remote's saved + // value, default 'token'. + const authMode = resolveAuthMode(input.remoteAuthMode, existingActive?.authMode) const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' - const existingToken = existing.remote?.token - const nextRemote = { - url: remoteUrl, - authMode, - token: incomingToken - ? persistToken - ? encryptDesktopSecret(incomingToken) - : { encoding: 'plain', value: incomingToken } - : existingToken + + const remotes = existing.remotes.map(r => ({ ...r })) + let activeRemoteId = existing.activeRemoteId + + // Update (or create) the active remote from the legacy single-remote payload. + let active = remotes.find(r => r.id === activeRemoteId) ?? remotes[0] + + if (!active) { + active = { id: genConnectionId(), label: 'Remote', profile: null, url: '', authMode, token: undefined } + remotes.push(active) + activeRemoteId = active.id } - if (mode === 'remote') { - nextRemote.url = normalizeRemoteBaseUrl(remoteUrl) + active.authMode = authMode + if (incomingToken) { + active.token = persistToken ? encryptDesktopSecret(incomingToken) : { encoding: 'plain', value: incomingToken } + } + + if (remoteUrl) { + active.url = normalizeRemoteBaseUrl(remoteUrl) + } + + if (mode === 'remote') { // OAuth gateways authenticate via the session cookie established by the // login window, NOT a static token — so no token is required here. The // cookie presence is verified at connect time (resolveRemoteBackend). - if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) { + if (authMode !== 'oauth' && !decryptDesktopSecret(active.token)) { throw new Error('Remote gateway session token is required.') } - } else if (remoteUrl) { - nextRemote.url = normalizeRemoteBaseUrl(remoteUrl) } - return { mode, remote: nextRemote } + return { mode, remotes, activeRemoteId } } async function resolveRemoteBackend() { @@ -3531,8 +3614,17 @@ async function resolveRemoteBackend() { return null } - const baseUrl = normalizeRemoteBaseUrl(config.remote?.url) - const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token' + const active = getActiveRemote(config) + + if (!active) { + throw new Error( + 'Remote Hermes gateway is selected, but no connection is configured. ' + + 'Open Settings → Gateway and add one, or switch back to Local.' + ) + } + + const baseUrl = normalizeRemoteBaseUrl(active.url) + const authMode = active.authMode === 'oauth' ? 'oauth' : 'token' if (authMode === 'oauth') { // OAuth gateway: auth comes from the session cookie in the OAuth partition. @@ -3572,11 +3664,11 @@ async function resolveRemoteBackend() { } } - const token = decryptDesktopSecret(config.remote?.token) + const token = decryptDesktopSecret(active.token) if (!token) { throw new Error( - 'Remote Hermes gateway is selected, but no session token is saved. ' + + 'Remote Hermes gateway is selected, but no session token is saved for the active connection. ' + 'Open Settings → Gateway and save a token, or switch back to Local.' ) } @@ -3650,6 +3742,22 @@ async function probeRemoteAuthMode(rawUrl) { } } +// Derive the profile name a gateway is serving from its HERMES_HOME path: +// ``…/profiles/`` → ````; the bare hermes root → ``default``. +function profileFromHermesHome(hermesHome) { + const home = String(hermesHome || '').replace(/[/\\]+$/, '') + + if (!home) { + return null + } + + const parts = home.split(/[/\\]/) + const base = parts[parts.length - 1] + const parent = parts[parts.length - 2] + + return parent === 'profiles' && base ? base : 'default' +} + async function testDesktopConnectionConfig(input = {}) { const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false }) // ``/api/status`` is public on every gateway (no creds needed), so a @@ -3659,9 +3767,10 @@ async function testDesktopConnectionConfig(input = {}) { let baseUrl let token = null if (config.mode === 'remote') { - baseUrl = normalizeRemoteBaseUrl(config.remote.url) - if ((config.remote.authMode || 'token') !== 'oauth') { - token = decryptDesktopSecret(config.remote.token) + const active = getActiveRemote(config) + baseUrl = normalizeRemoteBaseUrl(active?.url) + if ((active?.authMode || 'token') !== 'oauth') { + token = decryptDesktopSecret(active?.token) } } else { const remote = (await resolveRemoteBackend()) || (await startHermes()) @@ -4070,6 +4179,140 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { return sanitizeDesktopConnectionConfig(config) }) +// --- Multi-connection (per-profile remote) management --------------------- +// Each remote connection points at one gateway, which is bound to one Hermes +// profile. Switching profiles = activating a different connection and +// reconnecting. The profile is auto-detected from the gateway's /api/status, +// and each entry carries its own authMode (token vs OAuth). + +function reconnectToActiveBackend() { + resetHermesConnection() + setTimeout(() => mainWindow?.reload(), 150) +} + +// Best-effort: ask a token-authed gateway which profile it is bound to. OAuth +// gateways may not have credentials at add-time, so callers pass null there. +async function detectRemoteProfile(baseUrl, token) { + try { + const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) + + return profileFromHermesHome(status?.hermes_home) + } catch { + return null + } +} + +ipcMain.handle('hermes:connections:get', async () => sanitizeDesktopConnectionConfig()) + +ipcMain.handle('hermes:connections:add', async (_event, payload = {}) => { + const url = normalizeRemoteBaseUrl(String(payload.url || '').trim()) + + if (!url) { + throw new Error('A remote URL is required.') + } + + const rawToken = typeof payload.token === 'string' ? payload.token.trim() : '' + // Probe how this gateway authenticates so the entry records the right mode. + const probe = await probeRemoteAuthMode(url) + const authMode = probe.authMode === 'oauth' ? 'oauth' : 'token' + + if (authMode === 'token' && !rawToken) { + throw new Error('This gateway uses a session token — provide one to add it.') + } + + // Token gateways can report their bound profile right away; OAuth gateways + // typically have no creds yet at add-time, so leave the profile null until a + // sign-in + reconnect detects it. + const profile = authMode === 'token' && rawToken ? await detectRemoteProfile(url, rawToken) : null + + const config = readDesktopConnectionConfig() + const entry = { + id: genConnectionId(), + label: String(payload.label || '').trim() || profile || 'Remote', + profile, + url, + authMode, + token: rawToken ? encryptDesktopSecret(rawToken) : undefined + } + const next = { + mode: config.mode, + remotes: [...config.remotes, entry], + activeRemoteId: config.activeRemoteId ?? entry.id + } + writeDesktopConnectionConfig(next) + + return sanitizeDesktopConnectionConfig(next) +}) + +ipcMain.handle('hermes:connections:remove', async (_event, id) => { + const config = readDesktopConnectionConfig() + const remotes = config.remotes.filter(r => r.id !== id) + const activeRemoteId = config.activeRemoteId === id ? (remotes[0]?.id ?? null) : config.activeRemoteId + const next = { + mode: remotes.length === 0 ? 'local' : config.mode, + remotes, + activeRemoteId + } + writeDesktopConnectionConfig(next) + + return sanitizeDesktopConnectionConfig(next) +}) + +ipcMain.handle('hermes:connections:activate', async (_event, id) => { + const config = readDesktopConnectionConfig() + const target = config.remotes.find(r => r.id === id) + + if (!target) { + throw new Error('Connection not found.') + } + + const next = { mode: 'remote', remotes: config.remotes, activeRemoteId: id } + writeDesktopConnectionConfig(next) + reconnectToActiveBackend() + + return sanitizeDesktopConnectionConfig(next) +}) + +// NOTE: not named `useLocal` — eslint treats `use*` as a React hook. +ipcMain.handle('hermes:connections:activateLocal', async () => { + const config = readDesktopConnectionConfig() + const next = { mode: 'local', remotes: config.remotes, activeRemoteId: config.activeRemoteId } + writeDesktopConnectionConfig(next) + reconnectToActiveBackend() + + return sanitizeDesktopConnectionConfig(next) +}) + +ipcMain.handle('hermes:connections:test', async (_event, payload = {}) => { + const rawUrl = String(payload.url || '').trim() + const saved = payload.id ? readDesktopConnectionConfig().remotes.find(r => r.id === payload.id) : null + const url = normalizeRemoteBaseUrl(rawUrl || saved?.url || '') + + if (!url) { + throw new Error('A remote URL is required to test.') + } + + const authMode = saved?.authMode === 'oauth' ? 'oauth' : 'token' + const rawToken = typeof payload.token === 'string' && payload.token.trim() ? payload.token.trim() : '' + // OAuth gateways are cookie-authed and need no token for the public status + // probe; token gateways use the supplied or saved token. + const token = authMode === 'oauth' ? '' : rawToken || decryptDesktopSecret(saved?.token) + + // ``/api/status`` is public on every gateway, so when we have no token (OAuth + // entries, or a brand-new URL) we probe it credential-free. + const status = token + ? await fetchJson(`${url}/api/status`, token, { timeoutMs: 8_000 }) + : await fetchPublicJson(`${url}/api/status`, { timeoutMs: 8_000 }) + + return { + ok: true, + baseUrl: url, + version: status?.version || null, + profile: profileFromHermesHome(status?.hermes_home), + authMode + } +}) + ipcMain.on('hermes:previewShortcutActive', (_event, active) => { previewShortcutActive = Boolean(active) }) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 65fc591e84131..8c3b812fd205c 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -11,6 +11,17 @@ contextBridge.exposeInMainWorld('hermesDesktop', { probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), + connections: { + get: () => ipcRenderer.invoke('hermes:connections:get'), + add: payload => ipcRenderer.invoke('hermes:connections:add', payload), + remove: id => ipcRenderer.invoke('hermes:connections:remove', id), + activate: id => ipcRenderer.invoke('hermes:connections:activate', id), + activateLocal: () => ipcRenderer.invoke('hermes:connections:activateLocal'), + test: payload => ipcRenderer.invoke('hermes:connections:test', payload), + probe: url => ipcRenderer.invoke('hermes:connection-config:probe', url), + oauthLogin: url => ipcRenderer.invoke('hermes:connection-config:oauth-login', url), + oauthLogout: url => ipcRenderer.invoke('hermes:connection-config:oauth-logout', url) + }, 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/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index aeb8ca2db8f35..283c3337b44b9 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -31,6 +31,7 @@ import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { $busy, + $connection, $messages, $yoloActive, setAwaitingResponse, @@ -69,6 +70,27 @@ function inlineErrorMessage(error: unknown, fallback: string): string { return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() } +function base64FromDataUrl(dataUrl: string): string { + const comma = dataUrl.indexOf(',') + + return comma >= 0 ? dataUrl.slice(comma + 1) : '' +} + +function imageExtFromPath(filePath: string): string { + const match = /\.([a-z0-9]+)$/i.exec(filePath) + + return match ? `.${match[1].toLowerCase()}` : '.png' +} + +// Remote gateway: the local composer-images file does not exist on the gateway +// machine, so read the bytes here and upload them via image.attach_bytes. +async function readImageForRemoteAttach(filePath: string): Promise<{ data: string; ext: string } | null> { + const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + const data = dataUrl ? base64FromDataUrl(dataUrl) : '' + + return data ? { data, ext: imageExtFromPath(filePath) } : null +} + interface PromptActionsOptions { activeSessionId: string | null activeSessionIdRef: MutableRefObject @@ -181,16 +203,34 @@ export function usePromptActions({ ) => { const updateComposerAttachments = options.updateComposerAttachments ?? true const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path) + const remote = $connection.get()?.mode === 'remote' for (const attachment of images) { if (attachment.attachedSessionId === sessionId) { continue } - const result = await requestGateway('image.attach', { - session_id: sessionId, - path: attachment.path - }) + let result: ImageAttachResponse + + if (remote) { + const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null + + if (!payload) { + const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image') + throw new Error(`Could not read ${label}`) + } + + result = await requestGateway('image.attach_bytes', { + session_id: sessionId, + data: payload.data, + ext: payload.ext + }) + } else { + result = await requestGateway('image.attach', { + session_id: sessionId, + path: attachment.path + }) + } if (!result.attached) { const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image') diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 3fed724582f79..672a8c5998d8f 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -1,38 +1,25 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global' -import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons' +import type { DesktopAuthProvider, DesktopConnectionConfig, DesktopConnectionProbeResult, DesktopRemoteConnection } from '@/global' +import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor, Plus, Trash2 } 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' type AuthMode = 'oauth' | 'token' type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' -interface GatewaySettingsState { - envOverride: boolean - mode: Mode - remoteAuthMode: AuthMode - remoteOauthConnected: boolean - remoteTokenPreview: string | null - remoteTokenSet: boolean - remoteUrl: string +interface AddForm { + label: string + token: string + url: string } -const EMPTY_STATE: GatewaySettingsState = { - envOverride: false, - mode: 'local', - remoteAuthMode: 'token', - remoteOauthConnected: false, - remoteTokenPreview: null, - remoteTokenSet: false, - remoteUrl: '' -} +const EMPTY_FORM: AddForm = { label: '', token: '', url: '' } function ModeCard({ active, @@ -52,7 +39,7 @@ function ModeCard({ return ( + ) : null} + + + + ) +} + export function GatewaySettings() { const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) + const [busy, setBusy] = useState(false) const [testing, setTesting] = useState(false) - const [signingIn, setSigningIn] = useState(false) - const [state, setState] = useState(EMPTY_STATE) - const [remoteToken, setRemoteToken] = useState('') + const [config, setConfig] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) const [lastTest, setLastTest] = useState(null) // Auth-mode probe: as the user types a remote URL we ask the gateway (via // its public /api/status) whether it gates with OAuth or a static session - // token, so we can show the right control (login button vs token box). + // token, so the add form can show the right control (token box vs OAuth note). const [probeStatus, setProbeStatus] = useState('idle') const [probe, setProbe] = useState(null) const probeSeq = useRef(0) - useEffect(() => { - let cancelled = false + const reload = useCallback(async () => { const desktop = window.hermesDesktop - if (!desktop?.getConnectionConfig) { + if (!desktop?.connections) { setLoading(false) - return () => void (cancelled = true) + return } - desktop - .getConnectionConfig() - .then(config => { - if (cancelled) { - return - } + try { + setConfig(await desktop.connections.get()) + } catch (err) { + notifyError(err, 'Gateway settings failed to load') + } finally { + setLoading(false) + } + }, []) - setState(config) - }) - .catch(err => notifyError(err, 'Gateway settings failed to load')) - .finally(() => { - if (!cancelled) { - setLoading(false) - } - }) + useEffect(() => { + void reload() + }, [reload]) - return () => void (cancelled = true) - }, []) + const envOverride = Boolean(config?.envOverride) + const localActive = config?.mode === 'local' - // Debounced probe of the entered remote URL. Only runs in remote mode with a - // syntactically plausible URL. The probe result drives whether we render the - // OAuth login button or the session-token entry box. The effective auth mode - // prefers a fresh probe result over the saved value. - const trimmedUrl = state.remoteUrl.trim() + // Debounced probe of the entered remote URL. The probe result drives whether + // the add form shows a session-token box or an OAuth note. + const trimmedUrl = form.url.trim() useEffect(() => { - if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { + if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { setProbeStatus('idle') setProbe(null) @@ -134,7 +188,7 @@ export function GatewaySettings() { const desktop = window.hermesDesktop - if (!desktop?.probeConnectionConfig) { + if (!desktop?.connections?.probe) { return } @@ -142,8 +196,8 @@ export function GatewaySettings() { setProbeStatus('probing') const timer = setTimeout(() => { - desktop - .probeConnectionConfig(trimmedUrl) + desktop.connections + .probe(trimmedUrl) .then(result => { if (seq !== probeSeq.current) { return @@ -163,37 +217,15 @@ export function GatewaySettings() { }, 500) return () => clearTimeout(timer) - }, [state.mode, trimmedUrl]) + }, [trimmedUrl]) - // Effective auth mode: a reachable probe wins; otherwise fall back to the - // saved config's mode so a re-open of settings doesn't flicker. - const authMode: AuthMode = useMemo(() => { + const formAuthMode: AuthMode = useMemo(() => { if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') { return probe.authMode } - return state.remoteAuthMode - }, [probe, probeStatus, state.remoteAuthMode]) - - // Whether we actually KNOW how this gateway authenticates yet. Until we do, - // neither the OAuth button nor the session-token box should render — - // `authMode` defaults to 'token', so without this gate the token box flashes - // for every gateway (including OAuth ones) during the idle/probing window - // before the first probe lands. The scheme is known when either: - // * the live probe finished (probeStatus 'done'), or - // * we're idle but showing a previously-saved remote config (re-opening - // settings for a gateway already signed-in or with a saved token), so - // its control appears immediately with no flicker. - // While probing (or after a probe error), the scheme is unknown and we show - // the probe status row instead of a control. - const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected - const authResolved = useMemo(() => { - if (probeStatus === 'done') { - return true - } - - return probeStatus === 'idle' && hasSavedRemote - }, [probeStatus, hasSavedRemote]) + return 'token' + }, [probe, probeStatus]) const providerLabel = useMemo(() => { const providers: DesktopAuthProvider[] = probe?.providers ?? [] @@ -209,160 +241,154 @@ export function GatewaySettings() { return 'your identity provider' }, [probe]) - const oauthConnected = state.remoteOauthConnected + const switchToLocal = useCallback(async () => { + setBusy(true) - const canUseRemote = useMemo(() => { - if (!trimmedUrl) { - return false - } - - if (authMode === 'oauth') { - return oauthConnected + try { + await window.hermesDesktop.connections.activateLocal() + notify({ kind: 'success', title: 'Switching to local gateway', message: 'Hermes Desktop will reconnect.' }) + } catch (err) { + notifyError(err, 'Could not switch to local gateway') + setBusy(false) } + }, []) - return Boolean(remoteToken.trim()) || state.remoteTokenSet - }, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl]) - - const payload = () => ({ - mode: state.mode, - remoteAuthMode: authMode, - remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl - }) + const activate = useCallback(async (connection: DesktopRemoteConnection) => { + setBusy(true) - const save = async (apply: boolean) => { - if (state.mode === 'remote' && !canUseRemote) { + try { + await window.hermesDesktop.connections.activate(connection.id) notify({ - kind: 'warning', - title: 'Remote gateway incomplete', - message: - authMode === 'oauth' - ? 'Enter a remote URL and sign in before switching to remote.' - : 'Enter a remote URL and session token before switching to remote.' + kind: 'success', + title: 'Switching connection', + message: `Reconnecting to ${connection.label || connection.profile || connection.url}.` }) - - return + } catch (err) { + notifyError(err, 'Could not switch connection') + setBusy(false) } + }, []) - setSaving(true) + const remove = useCallback(async (connection: DesktopRemoteConnection) => { + setBusy(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.' - }) + setConfig(await window.hermesDesktop.connections.remove(connection.id)) + notify({ kind: 'success', title: 'Connection removed', message: connection.label || connection.url }) } catch (err) { - notifyError(err, apply ? 'Could not apply gateway settings' : 'Could not save gateway settings') + notifyError(err, 'Could not remove connection') } finally { - setSaving(false) + setBusy(false) } - } + }, []) + + // OAuth sign-in for an existing entry: open the gateway login window for its + // URL, then refresh so the row reflects the new cookie state. + const signIn = useCallback( + async (connection: DesktopRemoteConnection) => { + setBusy(true) + + try { + const result = await window.hermesDesktop.connections.oauthLogin(connection.url) + + if (result.connected) { + notify({ kind: 'success', title: 'Signed in', message: `Connected to ${connection.label || connection.url}.` }) + } else { + notify({ + kind: 'warning', + title: 'Sign-in incomplete', + message: 'The login window closed before authentication finished.' + }) + } + + await reload() + } catch (err) { + notifyError(err, 'Sign-in failed') + } finally { + setBusy(false) + } + }, + [reload] + ) - // OAuth sign-in: persist the URL + oauth mode first (so the saved config has - // the URL the login window needs), then open the gateway login window and - // refresh the connection status from the saved config once it completes. - const signIn = async () => { + const testForm = useCallback(async () => { if (!trimmedUrl) { - notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' }) + notify({ kind: 'warning', title: 'Connection incomplete', message: 'Enter a URL to test.' }) return } - setSigningIn(true) + setTesting(true) + setLastTest(null) try { - // Save (don't apply/restart) so the login window has a URL to use and the - // oauth mode is persisted, without yet flipping the live connection. - const saved = await window.hermesDesktop.saveConnectionConfig({ - mode: state.mode, - remoteAuthMode: 'oauth', - remoteUrl: trimmedUrl + const result = await window.hermesDesktop.connections.test({ + token: form.token.trim() || undefined, + url: trimmedUrl }) - setState(saved) - - const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) - - if (result.connected) { - const refreshed = await window.hermesDesktop.getConnectionConfig() - setState(refreshed) - notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` }) - } else { - notify({ - kind: 'warning', - title: 'Sign-in incomplete', - message: 'The login window closed before authentication finished.' - }) - } + const profile = result.profile ? ` · profile "${result.profile}"` : '' + const message = `Reachable${result.version ? ` · Hermes ${result.version}` : ''}${profile}` + setLastTest(message) + notify({ kind: 'success', title: 'Gateway reachable', message }) } catch (err) { - notifyError(err, 'Sign-in failed') + notifyError(err, 'Connection test failed') } finally { - setSigningIn(false) + setTesting(false) } - } + }, [form.token, trimmedUrl]) - const signOut = async () => { - setSigningIn(true) + const add = useCallback(async () => { + if (!trimmedUrl) { + notify({ kind: 'warning', title: 'Connection incomplete', message: 'Enter a URL to add a connection.' }) - try { - await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined) - const refreshed = await window.hermesDesktop.getConnectionConfig() - setState(refreshed) - notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' }) - } catch (err) { - notifyError(err, 'Sign-out failed') - } finally { - setSigningIn(false) + return } - } - const testRemote = async () => { - if (!canUseRemote) { + if (formAuthMode === 'token' && !form.token.trim()) { notify({ kind: 'warning', - title: 'Remote gateway incomplete', - message: - authMode === 'oauth' - ? 'Enter a remote URL and sign in before testing.' - : 'Enter a remote URL and session token before testing.' + title: 'Session token required', + message: 'This gateway uses a session token — paste one to add it.' }) return } - setTesting(true) - setLastTest(null) + setBusy(true) try { - const result = await window.hermesDesktop.testConnectionConfig({ - mode: 'remote', - remoteAuthMode: authMode, - remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl + const next = await window.hermesDesktop.connections.add({ + label: form.label.trim() || undefined, + token: form.token.trim() || undefined, + url: trimmedUrl }) - const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}` - setLastTest(message) - notify({ kind: 'success', title: 'Remote gateway reachable', message }) + setConfig(next) + setForm(EMPTY_FORM) + setLastTest(null) + const added = next.remotes[next.remotes.length - 1] + notify({ + kind: 'success', + title: 'Connection added', + message: added?.profile + ? `Detected profile "${added.profile}".` + : added?.authMode === 'oauth' + ? 'OAuth gateway added — sign in to connect.' + : 'Profile could not be auto-detected.' + }) } catch (err) { - notifyError(err, 'Remote gateway test failed') + notifyError(err, 'Could not add connection') } finally { - setTesting(false) + setBusy(false) } - } + }, [form.label, form.token, formAuthMode, trimmedUrl]) if (loading) { return } - if (!window.hermesDesktop?.getConnectionConfig) { + if (!window.hermesDesktop?.connections || !config) { return ( Gateway Connection - {state.envOverride ? env override : null} + {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 backend on another machine or behind a trusted proxy. + Hermes Desktop starts its own local gateway by default. Add a remote connection for each Hermes profile you run + elsewhere — each points at one gateway, the profile is detected automatically, and hosted gateways use OAuth + while self-hosted ones may use a session token. Switch between them here or from the Profiles menu.

- {state.envOverride ? ( + {envOverride ? (
Environment variables are controlling this desktop session.
- Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved - setting below. + Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to manage + connections below.
) : null} -
- setState(current => ({ ...current, mode: 'local' }))} - title="Local gateway" - /> - setState(current => ({ ...current, mode: 'remote' }))} - title="Remote gateway" - /> + void switchToLocal()} + title="Local gateway" + /> + +
+ Remote connections
-
- setState(current => ({ ...current, remoteUrl: event.target.value }))} - placeholder="https://gateway.example.com/hermes" - value={state.remoteUrl} + {config.remotes.length === 0 ? ( +

+ No remote connections yet. Add one below to connect to a profile running on another machine. +

+ ) : ( +
+ {config.remotes.map(connection => ( + void activate(connection)} + onRemove={() => void remove(connection)} + onSignIn={() => void signIn(connection)} /> - } - description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes." - title="Remote URL" - /> - - {state.mode === 'remote' && probeStatus === 'probing' ? ( -
- - Checking how this gateway authenticates… -
- ) : null} - - {state.mode === 'remote' && probeStatus === 'error' ? ( -
- - Could not reach this gateway yet. Check the URL — the auth method will appear once it responds. -
- ) : null} + ))} +
+ )} - {/* OAuth gateways: present a sign-in button + connection status. */} - {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( +
+
+ + Add a connection +
+
- - Signed in - - -
- ) : ( - - ) - } - description={ - oauthConnected - ? 'This gateway uses OAuth. You are signed in; the session refreshes automatically.' - : `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.` + setForm(current => ({ ...current, url: event.target.value }))} + placeholder="https://gateway.example.com/profile" + value={form.url} + /> } - title="Authentication" + description="Base URL for the remote dashboard. Path prefixes are supported, e.g. /coder." + title="Remote URL" /> - ) : null} - {/* Session-token gateways: keep the existing token entry box. */} - {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + {probeStatus === 'probing' ? ( +
+ + Checking how this gateway authenticates… +
+ ) : null} + + {probeStatus === 'error' ? ( +
+ + Could not reach this gateway yet. Check the URL — the auth method will appear once it responds. +
+ ) : null} + + {/* OAuth gateways: no token needed at add-time; sign in from the row. */} + {probeStatus === 'done' && formAuthMode === 'oauth' ? ( +
+ + This gateway uses OAuth ({providerLabel}). Add it, then click "Sign in" on its row to authorize this + desktop app. +
+ ) : null} + + {/* Session-token gateways: collect the token up front. */} + {formAuthMode === 'token' ? ( + setForm(current => ({ ...current, token: event.target.value }))} + placeholder="Paste session token" + type="password" + value={form.token} + /> + } + description="The dashboard session token (HERMES_DASHBOARD_SESSION_TOKEN) for that gateway." + title="Session token" + /> + ) : null} + setRemoteToken(event.target.value)} - placeholder={ - state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token' - } - type="password" - value={remoteToken} + className={cn('h-8', CONTROL_TEXT)} + disabled={envOverride} + onChange={event => setForm(current => ({ ...current, label: event.target.value }))} + placeholder="Defaults to the detected profile" + value={form.label} /> } - description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token." - title="Session token" + description="Optional friendly name for this connection." + title="Label" /> - ) : null} -
+
- {lastTest ?
{lastTest}
: null} - -
- - - + {lastTest ?
{lastTest}
: null} + +
+ + +
-
+
void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong"> + diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 43496794619f1..422e96e60ae21 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -1,5 +1,6 @@ import { useStore } from '@nanostores/react' import type { ComponentProps, ReactNode } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { Button } from '@/components/ui/button' @@ -12,7 +13,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import type { DesktopConnectionConfig } from '@/global' import { triggerHaptic } from '@/lib/haptics' +import { Check, Globe, Monitor } from '@/lib/icons' import { cn } from '@/lib/utils' import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' import { @@ -194,8 +197,37 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: } function ProfilesMenuButton({ navigate }: { navigate: ReturnType }) { + const [config, setConfig] = useState(null) + + const refresh = useCallback(() => { + void window.hermesDesktop?.connections + ?.get() + .then(setConfig) + .catch(() => undefined) + }, []) + + useEffect(() => { + refresh() + }, [refresh]) + + const localActive = config?.mode === 'local' + + const switchLocal = () => { + if (localActive) { + return + } + + triggerHaptic('open') + void window.hermesDesktop?.connections?.activateLocal().catch(() => undefined) + } + + const switchRemote = (id: string) => { + triggerHaptic('open') + void window.hermesDesktop?.connections?.activate(id).catch(() => undefined) + } + return ( - + open && refresh()}>