diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a1da176726d1..706611acb1ae 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -30,7 +30,7 @@ Already have the Hermes CLI? Just run: hermes desktop ``` -It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure. +It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model. ### Prebuilt installers @@ -134,6 +134,19 @@ Desktop supports a managed local backend, explicit remote gateways, and Hermes Cloud connections. Remote and cloud modes use the same remote-capability path; authentication and discovery differ, not the renderer feature model. +When no usable local runtime or saved remote connection exists, the first-run +screen offers **Connect to existing Hermes** before starting the local installer. +Desktop probes the gateway to discover token or OAuth authentication, requires a +successful HTTP and WebSocket connection test, and saves the connection using +the same encrypted Desktop configuration used by Settings. A saved remote +connection bypasses this choice on later launches. The regular Desktop build +still includes the local-install option; this is a remote operating mode, not a +separate client-only application. + +In remote mode the gateway host is the execution boundary: agent tools, +terminal commands, and file operations run against the remote Hermes host, not +the computer displaying the Desktop UI. + Projects are the workspace abstraction. A project may own multiple folders, repositories, worktrees, and sessions; a bare new chat remains detached unless the user enters a project or configures a default project directory. Use the diff --git a/apps/desktop/electron/connection-apply.ts b/apps/desktop/electron/connection-apply.ts index 5764b9091e73..579af1a79e97 100644 --- a/apps/desktop/electron/connection-apply.ts +++ b/apps/desktop/electron/connection-apply.ts @@ -1,6 +1,7 @@ async function applyConnectionChange({ cancelAndWait, isPrimary, + rehomePrimary = null, scope, sendApplied, stopPool, @@ -16,6 +17,12 @@ async function applyConnectionChange({ return } + if (rehomePrimary) { + await rehomePrimary() + + return + } + await teardownPrimary() sendApplied() } diff --git a/apps/desktop/electron/first-run-setup-gate.test.ts b/apps/desktop/electron/first-run-setup-gate.test.ts new file mode 100644 index 000000000000..f8b53525f0fb --- /dev/null +++ b/apps/desktop/electron/first-run-setup-gate.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createFirstRunSetupGate } from './first-run-setup-gate' + +const bootstrapBackend = { + activeRoot: '/tmp/hermes-home/hermes-agent', + kind: 'bootstrap-needed', + platform: 'linux' +} + +function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function settledState(promise: Promise) { + return Promise.race([promise.then(() => 'resolved'), delay(10).then(() => 'pending')]) +} + +test('first-run setup gate skips non-bootstrap backends', async () => { + const prompts = [] + const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) + + await gate.wait({ kind: 'remote' }) + await gate.wait(null) + + assert.deepEqual(prompts, []) + assert.equal(gate.hasWaiter(), false) +}) + +test('first-run setup gate prompts once for concurrent waits', async () => { + const prompts = [] + const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) + + const first = gate.wait(bootstrapBackend) + const second = gate.wait(bootstrapBackend) + + assert.equal(gate.hasWaiter(), true) + assert.equal(prompts.length, 1) + assert.equal(await settledState(first), 'pending') + + gate.continueLocal() + + assert.deepEqual(await Promise.all([first, second]), ['continue-local', 'continue-local']) + assert.equal(gate.hasWaiter(), false) + assert.equal(gate.isLocalBootstrapConfirmed(), true) +}) + +test('continueLocal keeps the setup choice visible until bootstrap owns the overlay', async () => { + let hidden = 0 + const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + + gate.continueLocal() + + assert.equal(await pending, 'continue-local') + assert.equal(hidden, 0) + assert.equal(gate.isLocalBootstrapConfirmed(), true) +}) + +test('retry reset preserves the local install confirmation', async () => { + const prompts = [] + const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) + + const pending = gate.wait(bootstrapBackend) + gate.continueLocal() + await pending + + gate.resetForRetry() + await gate.wait(bootstrapBackend) + + assert.equal(gate.isLocalBootstrapConfirmed(), true) + assert.equal(prompts.length, 1) + assert.equal(gate.hasWaiter(), false) +}) + +test('retry reset explicitly settles an active waiter without allowing local bootstrap', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + + gate.resetForRetry() + + assert.equal(await pending, 'reset') + assert.equal(gate.hasWaiter(), false) + assert.equal(gate.isLocalBootstrapConfirmed(), false) +}) + +test('repair reset clears the local install confirmation and shows the gate again', async () => { + const prompts = [] + const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) + + const pending = gate.wait(bootstrapBackend) + gate.continueLocal() + await pending + + gate.resetForRepair() + const next = gate.wait(bootstrapBackend) + + assert.equal(gate.isLocalBootstrapConfirmed(), false) + assert.equal(prompts.length, 2) + assert.equal(gate.hasWaiter(), true) + + gate.continueLocal() + await next +}) + +test('remote apply settles the gated boot for remote re-resolution and hides the choice', async () => { + let hidden = 0 + const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + + const resumedWaiter = gate.abandonForRemoteApply() + + assert.equal(resumedWaiter, true) + assert.equal(hidden, 1) + assert.equal(gate.hasWaiter(), false) + assert.equal(gate.isLocalBootstrapConfirmed(), false) + assert.equal(await pending, 'remote-applied') +}) + +test('remote apply without a waiter has no first-run side effects', async () => { + let hidden = 0 + const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + + gate.continueLocal() + await pending + + assert.equal(gate.abandonForRemoteApply(), false) + assert.equal(hidden, 0) + assert.equal(gate.isLocalBootstrapConfirmed(), true) +}) diff --git a/apps/desktop/electron/first-run-setup-gate.ts b/apps/desktop/electron/first-run-setup-gate.ts new file mode 100644 index 000000000000..2d83cbeafe69 --- /dev/null +++ b/apps/desktop/electron/first-run-setup-gate.ts @@ -0,0 +1,146 @@ +interface FirstRunSetupBackend { + activeRoot?: string + kind?: string + platform?: string +} + +interface FirstRunSetupGateOptions { + hideChoice?: () => void + log?: (message: string) => void + onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void + promptChoice?: (backend: FirstRunSetupBackend) => void + stuckAfterMs?: number +} + +export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' + +export function createFirstRunSetupGate({ + hideChoice, + log, + onStuck, + promptChoice, + stuckAfterMs = 120000 +}: FirstRunSetupGateOptions = {}) { + let localBootstrapConfirmed = false + + let waiter: { + promise: Promise + resolve: (decision: FirstRunSetupDecision) => void + } | null = null + + let stuckTimer: ReturnType | null = null + + const clearStuckTimer = () => { + if (stuckTimer) { + clearTimeout(stuckTimer) + stuckTimer = null + } + } + + const armStuckTimer = (backend: FirstRunSetupBackend) => { + clearStuckTimer() + + if (!Number.isFinite(stuckAfterMs) || stuckAfterMs <= 0 || typeof log !== 'function') { + return + } + + stuckTimer = setTimeout(() => { + onStuck?.(backend, stuckAfterMs) + log( + `[bootstrap] still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)}s ` + + `(platform=${backend?.platform || 'unknown'})` + ) + }, stuckAfterMs) + + if (typeof stuckTimer.unref === 'function') { + stuckTimer.unref() + } + } + + const shouldGate = (backend?: FirstRunSetupBackend | null) => + Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed) + + const wait = async (backend?: FirstRunSetupBackend | null) => { + if (!shouldGate(backend)) { + return 'continue-local' as const + } + + if (waiter) { + return waiter.promise + } + + promptChoice?.(backend) + armStuckTimer(backend) + + let resolveWaiter: (decision: FirstRunSetupDecision) => void = () => {} + + const promise = new Promise(resolve => { + resolveWaiter = resolve + }) + + waiter = { promise, resolve: resolveWaiter } + + return promise + } + + const settleWaiter = (decision: FirstRunSetupDecision) => { + clearStuckTimer() + + if (!waiter) { + return false + } + + const activeWaiter = waiter + waiter = null + activeWaiter.resolve(decision) + + return true + } + + const continueLocal = () => { + localBootstrapConfirmed = true + settleWaiter('continue-local') + } + + const resetForRetry = () => { + // Reset paths are followed by a renderer reload / fresh startHermes() call. + // Settle the old boot explicitly so it cannot fall through into local + // bootstrap and cannot leak a forever-pending connection promise. + settleWaiter('reset') + } + + const resetForRepair = () => { + resetForRetry() + localBootstrapConfirmed = false + } + + const abandonForRemoteApply = () => { + // Resume the gated startHermes() with an explicit remote decision. The + // caller re-resolves the newly-persisted remote config instead of falling + // through into local bootstrap or leaking the original connection promise. + const resumedWaiter = settleWaiter('remote-applied') + + if (!resumedWaiter) { + return false + } + + localBootstrapConfirmed = false + hideChoice?.() + + return true + } + + const isLocalBootstrapConfirmed = () => localBootstrapConfirmed + const hasWaiter = () => Boolean(waiter) + + return { + abandonForRemoteApply, + continueLocal, + hasWaiter, + isLocalBootstrapConfirmed, + resetForRepair, + resetForRetry, + shouldGate, + wait + } +} diff --git a/apps/desktop/electron/first-run-setup-main-process.test.ts b/apps/desktop/electron/first-run-setup-main-process.test.ts new file mode 100644 index 000000000000..1dc1b920ef60 --- /dev/null +++ b/apps/desktop/electron/first-run-setup-main-process.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' + +import { test, vi } from 'vitest' + +import { applyConnectionChange } from './connection-apply' +import { createFirstRunSetupGate } from './first-run-setup-gate' +import { runPrimaryBackendStartup } from './primary-backend-startup' +import { rehomePrimaryConnection } from './primary-connection-rehome' + +test('a first-run bootstrap-needed remote apply connects without ensuring or bootstrapping locally', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + + const bootstrapBackend = { + activeRoot: '/tmp/hermes-home/hermes-agent', + kind: 'bootstrap-needed', + platform: 'linux' + } + + const candidateRemote = { + authMode: 'token', + baseUrl: 'https://gateway.example.com/hermes', + source: 'settings', + token: 'secret', + wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret' + } + + let savedRemote: typeof candidateRemote | null = null + + const resolveRemote = vi.fn(async () => savedRemote) + const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const })) + const runBootstrap = vi.fn() + + const ensureLocalRuntime = vi.fn(async backend => { + await runBootstrap() + + return { ...backend, command: 'hermes' } + }) + + const teardownPrimaryBackend = vi.fn(async () => {}) + const cancelSshBootstrap = vi.fn(async () => {}) + const teardownSsh = vi.fn(async () => {}) + const clearLocalBootstrapFailure = vi.fn() + const notifyConnectionApplied = vi.fn() + const waitForLocalStart = vi.fn(async () => {}) + const prepareLocalBackend = vi.fn(async () => bootstrapBackend) + + const pendingConnection = runPrimaryBackendStartup({ + connectRemote, + ensureLocalRuntime, + prepareLocalBackend, + resolveRemote, + waitForDecision: gate.wait, + waitForLocalStart + }) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + + // Mirrors the IPC handler's production ordering: persist the tested config, + // then re-home. The pending start must re-resolve this saved value. + savedRemote = candidateRemote + + await applyConnectionChange({ + cancelAndWait: cancelSshBootstrap, + isPrimary: true, + rehomePrimary: () => + rehomePrimaryConnection({ + clearLocalBootstrapFailure, + mode: 'remote', + notifyConnectionApplied, + resumeFirstRunRemote: gate.abandonForRemoteApply, + teardownPrimaryBackend + }), + scope: '', + sendApplied: notifyConnectionApplied, + stopPool: vi.fn(), + teardownPrimary: teardownPrimaryBackend, + teardownSsh + }) + + assert.deepEqual(await pendingConnection, { + kind: 'remote', + connection: { ...candidateRemote, mode: 'remote' } + }) + assert.deepEqual(resolveRemote.mock.calls, [[], []]) + assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]]) + assert.deepEqual(waitForLocalStart.mock.calls, [[]]) + assert.deepEqual(prepareLocalBackend.mock.calls, [[]]) + assert.equal(ensureLocalRuntime.mock.calls.length, 0) + assert.equal(runBootstrap.mock.calls.length, 0) + assert.deepEqual(cancelSshBootstrap.mock.calls, [['']]) + assert.deepEqual(teardownSsh.mock.calls, [['']]) + assert.equal(teardownPrimaryBackend.mock.calls.length, 0) + assert.equal(clearLocalBootstrapFailure.mock.calls.length, 1) + assert.equal(notifyConnectionApplied.mock.calls.length, 0) +}) + +test('a primary apply without an active first-run gate tears down before reconnect notification', async () => { + const order: string[] = [] + const clearLocalBootstrapFailure = vi.fn(() => order.push('clear-failure')) + + const teardownPrimaryBackend = vi.fn(async () => { + order.push('teardown') + }) + + const notifyConnectionApplied = vi.fn(() => order.push('notify')) + + assert.deepEqual( + await rehomePrimaryConnection({ + clearLocalBootstrapFailure, + mode: 'remote', + notifyConnectionApplied, + resumeFirstRunRemote: () => false, + teardownPrimaryBackend + }), + { resumedFirstRunRemote: false } + ) + assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]]) + assert.deepEqual(order, ['clear-failure', 'teardown', 'notify']) +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c448e3ac0318..e83fa416fcbc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -79,6 +79,7 @@ import { import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' import { findGitBash as _findGitBash } from './find-git-bash' +import { createFirstRunSetupGate } from './first-run-setup-gate' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' import { scanGitRepos } from './git-repo-scan' @@ -128,6 +129,8 @@ import { import { runNativeLogin } from './native-oauth-login' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' +import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup' +import { rehomePrimaryConnection } from './primary-connection-rehome' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import * as remoteLifecycle from './remote-lifecycle' import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' @@ -1399,13 +1402,17 @@ let bootstrapState = { log: [], startedAt: null, completedAt: null, + setupChoice: null, unsupportedPlatform: null } +let firstRunSetupGate = null + function broadcastBootstrapEvent(ev) { if (ev.type === 'manifest') { bootstrapState.manifest = ev bootstrapState.active = true + bootstrapState.setupChoice = null bootstrapState.startedAt = bootstrapState.startedAt || Date.now() bootstrapState.stages = {} @@ -1433,14 +1440,30 @@ function broadcastBootstrapEvent(ev) { } else if (ev.type === 'failed') { bootstrapState.active = false bootstrapState.error = ev.error || 'unknown error' + bootstrapState.setupChoice = null } else if (ev.type === 'unsupported-platform') { bootstrapState.active = false + bootstrapState.setupChoice = null bootstrapState.unsupportedPlatform = { platform: ev.platform, activeRoot: ev.activeRoot, installCommand: ev.installCommand, docsUrl: ev.docsUrl } + } else if (ev.type === 'setup-choice') { + bootstrapState.active = false + bootstrapState.error = null + bootstrapState.manifest = null + bootstrapState.stages = {} + bootstrapState.setupChoice = ev.active + ? { + platform: ev.platform, + activeRoot: ev.activeRoot + } + : null + bootstrapState.unsupportedPlatform = null + } else if (ev.type === 'dismissed') { + resetBootstrapSnapshot() } if (!mainWindow || mainWindow.isDestroyed()) { @@ -1460,6 +1483,100 @@ function getBootstrapState() { return bootstrapState } +function resetBootstrapSnapshot() { + bootstrapState = { + active: false, + manifest: null, + stages: {}, + error: null, + log: [], + startedAt: null, + completedAt: null, + setupChoice: null, + unsupportedPlatform: null + } +} + +function promptFirstRunSetupChoice(backend) { + broadcastBootstrapEvent({ + type: 'setup-choice', + active: true, + platform: backend.platform || process.platform, + activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT + }) +} + +function hideFirstRunSetupChoice() { + if (bootstrapState.setupChoice) { + broadcastBootstrapEvent({ type: 'setup-choice', active: false }) + } +} + +function getFirstRunSetupGate() { + if (!firstRunSetupGate) { + firstRunSetupGate = createFirstRunSetupGate({ + hideChoice: hideFirstRunSetupChoice, + log: rememberLog, + onStuck: (_backend, stuckAfterMs) => { + updateBootProgress( + { + error: null, + message: `Still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)} seconds`, + phase: 'bootstrap.choice', + progress: 12, + running: true + }, + { allowDecrease: true } + ) + }, + promptChoice: promptFirstRunSetupChoice + }) + } + + return firstRunSetupGate +} + +async function waitForFirstRunSetupChoice(backend) { + const gate = getFirstRunSetupGate() + + if (!gate.shouldGate(backend)) { + return 'continue-local' + } + + updateBootProgress( + { + error: null, + message: 'Waiting for first-run setup choice', + phase: 'bootstrap.choice', + progress: 12, + running: true + }, + { allowDecrease: true } + ) + + return gate.wait(backend) +} + +function continueFirstRunLocalBootstrap() { + getFirstRunSetupGate().continueLocal() +} + +function abandonFirstRunSetupChoiceForRemoteApply() { + const gate = getFirstRunSetupGate() + + if (!gate.hasWaiter()) { + return false + } + + const resumedGatedConnection = gate.abandonForRemoteApply() + + if (resumedGatedConnection) { + broadcastBootstrapEvent({ type: 'dismissed' }) + } + + return resumedGatedConnection +} + function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress @@ -7767,14 +7884,7 @@ async function startHermes() { let attemptedRemote = primaryBackendIsRemote() const connectionPromise = (async () => { - await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) - // Resolve for the desktop's primary profile so a per-profile remote - // override on the active profile is honored (falls back to env / global). - // Re-read once resolved so the classification tracks the value actually used. - attemptedRemote = primaryBackendIsRemote() - const remote = await resolveRemoteBackend(primaryProfileKey()) - - if (remote) { + const connectRemote = async remote => { await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24) await waitForHermes(remote.baseUrl, remote.token) updateBootProgress({ @@ -7800,14 +7910,9 @@ async function startHermes() { } } - // Mutual exclusion with an in-app update (#50238). If this instance was - // relaunched while the Tauri updater is still applying an update, spawning - // a local backend now re-locks the venv shim and gets killed by the - // updater's straggler cleanup — looping. Park until the update finishes (or - // is detected stale), THEN start the backend. Local backends only; remote - // connections returned above and never touch the install tree. - await waitForUpdateToFinish() - + await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) + // Resolve for the desktop's primary profile so a per-profile remote + // override on the active profile is honored (falls back to env / global). const token = crypto.randomBytes(32).toString('base64url') // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0'] @@ -7822,8 +7927,32 @@ async function startHermes() { backendArgs.unshift('--profile', activeProfile) } - await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) - const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) + const setup = await runPrimaryBackendStartup({ + connectRemote, + ensureLocalRuntime: ensureRuntime, + prepareLocalBackend: async () => { + await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) + + return resolveHermesBackend(backendArgs) + }, + resolveRemote: () => { + // Classify immediately before each throwing resolve. This callback runs + // both for an already-saved remote and after first-run remote Apply. + attemptedRemote = primaryBackendIsRemote() + + return resolveRemoteBackend(primaryProfileKey()) + }, + waitForDecision: waitForFirstRunSetupChoice, + // Mutual exclusion with an in-app update (#50238). Remote connections + // return before this waiter; local starts park until the updater exits. + waitForLocalStart: waitForUpdateToFinish + }) + + if (setup.kind === 'remote') { + return setup.connection + } + + const backend = setup.backend // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. backend.args = getBackendArgsForRuntime(backend) const hermesCwd = resolveHermesCwd() @@ -7979,6 +8108,10 @@ async function startHermes() { throw error } + if (error instanceof FirstRunSetupResetError) { + throw error + } + const message = error instanceof Error ? error.message : String(error) // Only latch LOCAL boot failures. A remote failure (lapsed session / mint @@ -8793,16 +8926,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { await teardownPrimaryBackendAndWait() bootstrapFailure = null backendStartFailure = null - bootstrapState = { - active: false, - manifest: null, - stages: {}, - error: null, - log: [], - startedAt: null, - completedAt: null, - unsupportedPlatform: null - } + getFirstRunSetupGate().resetForRetry() + resetBootstrapSnapshot() return { ok: true } }) @@ -8823,10 +8948,17 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { bootstrapFailure = null backendStartFailure = null + getFirstRunSetupGate().resetForRepair() resetHermesConnection() return { ok: true } }) +ipcMain.handle('hermes:bootstrap:continue-local', async () => { + rememberLog('[bootstrap] local install selected by renderer; continuing first-launch bootstrap') + continueFirstRunLocalBootstrap() + + return { ok: true } +}) ipcMain.handle('hermes:bootstrap:cancel', async () => { // Renderer's Cancel button during first-launch install. Abort the running // install script (SIGTERM via the runner's abortSignal). runBootstrap @@ -9005,6 +9137,18 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { await applyConnectionChange({ cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value), isPrimary: !key || key === primaryProfileKey(), + rehomePrimary: () => + rehomePrimaryConnection({ + clearLocalBootstrapFailure: () => { + // A remote connection bypasses local runtime/bootstrap failures. Clear + // the local-install latch so unsupported/failure escape paths can re-home. + bootstrapFailure = null + }, + mode: config.mode, + notifyConnectionApplied: sendConnectionApplied, + resumeFirstRunRemote: abandonFirstRunSetupChoiceForRemoteApply, + teardownPrimaryBackend: teardownPrimaryBackendAndWait + }), scope, sendApplied: sendConnectionApplied, stopPool: stopPoolBackend, diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7652a7688dd2..47d854a1938d 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -237,6 +237,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { // current snapshot via getBootstrapState() to recover after a devtools // reload mid-bootstrap. getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'), + continueBootstrapLocal: () => ipcRenderer.invoke('hermes:bootstrap:continue-local'), resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'), repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'), cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'), diff --git a/apps/desktop/electron/primary-backend-startup.test.ts b/apps/desktop/electron/primary-backend-startup.test.ts new file mode 100644 index 000000000000..6d3f5c8f44de --- /dev/null +++ b/apps/desktop/electron/primary-backend-startup.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' + +import { test, vi } from 'vitest' + +import { createFirstRunSetupGate } from './first-run-setup-gate' +import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup' + +const bootstrapBackend = { + activeRoot: '/tmp/hermes-home/hermes-agent', + kind: 'bootstrap-needed', + platform: 'linux' +} + +function startupOptions(overrides: Record = {}) { + return { + connectRemote: vi.fn(async remote => ({ baseUrl: remote.baseUrl, mode: 'remote' as const })), + ensureLocalRuntime: vi.fn(async backend => ({ ...backend, command: 'hermes' })), + prepareLocalBackend: vi.fn(async () => bootstrapBackend), + resolveRemote: vi.fn(async () => null), + waitForDecision: vi.fn(async () => 'continue-local' as const), + waitForLocalStart: vi.fn(async () => {}), + ...overrides + } +} + +test('remote apply re-resolves the saved connection without ensuring a local runtime', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' } + let configuredRemote: typeof savedRemote | null = null + + const options = startupOptions({ + resolveRemote: vi.fn(async () => configuredRemote), + waitForDecision: gate.wait + }) + + const pending = runPrimaryBackendStartup(options) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + configuredRemote = savedRemote + assert.equal(gate.abandonForRemoteApply(), true) + + assert.deepEqual(await pending, { + kind: 'remote', + connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' } + }) + assert.deepEqual(options.resolveRemote.mock.calls, [[], []]) + assert.deepEqual(options.connectRemote.mock.calls, [[savedRemote]]) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) + +test('an already-saved remote bypasses every local startup step', async () => { + const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' } + const options = startupOptions({ resolveRemote: vi.fn(async () => savedRemote) }) + + assert.deepEqual(await runPrimaryBackendStartup(options), { + kind: 'remote', + connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' } + }) + assert.equal(options.waitForLocalStart.mock.calls.length, 0) + assert.equal(options.prepareLocalBackend.mock.calls.length, 0) + assert.equal(options.waitForDecision.mock.calls.length, 0) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) + +test('remote apply fails clearly when no saved remote can be resolved', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const options = startupOptions({ waitForDecision: gate.wait }) + const pending = runPrimaryBackendStartup(options) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + gate.abandonForRemoteApply() + + await assert.rejects(pending, /without a saved remote backend/) + assert.equal(options.connectRemote.mock.calls.length, 0) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) + +test('continue local waits for update exclusion and ensures the prepared runtime exactly once', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const runtimeBackend = { ...bootstrapBackend, command: 'hermes' } + + const options = startupOptions({ + ensureLocalRuntime: vi.fn(async () => runtimeBackend), + waitForDecision: gate.wait + }) + + const pending = runPrimaryBackendStartup(options) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + gate.continueLocal() + + assert.deepEqual(await pending, { kind: 'local', backend: runtimeBackend }) + assert.deepEqual(options.waitForLocalStart.mock.calls, [[]]) + assert.deepEqual(options.prepareLocalBackend.mock.calls, [[]]) + assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[bootstrapBackend]]) + assert.deepEqual(options.resolveRemote.mock.calls, [[]]) +}) + +test('reset rejects with a typed error and never enters either backend', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const options = startupOptions({ waitForDecision: gate.wait }) + const pending = runPrimaryBackendStartup(options) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + gate.resetForRetry() + + await assert.rejects(pending, error => error instanceof FirstRunSetupResetError && error.firstRunSetupReset) + assert.equal(options.connectRemote.mock.calls.length, 0) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) diff --git a/apps/desktop/electron/primary-backend-startup.ts b/apps/desktop/electron/primary-backend-startup.ts new file mode 100644 index 000000000000..edbe5167b059 --- /dev/null +++ b/apps/desktop/electron/primary-backend-startup.ts @@ -0,0 +1,66 @@ +import type { FirstRunSetupDecision } from './first-run-setup-gate' + +export interface PrimaryBackendStartupOptions { + connectRemote: (remote: Remote) => Promise + ensureLocalRuntime: (backend: Backend) => Promise + prepareLocalBackend: () => Backend | Promise + resolveRemote: () => Promise + waitForDecision: (backend: Backend) => Promise + waitForLocalStart: () => Promise +} + +export type PrimaryBackendStartupResult = + | { kind: 'local'; backend: RuntimeBackend } + | { kind: 'remote'; connection: Connection } + +export class FirstRunSetupResetError extends Error { + readonly firstRunSetupReset = true + + constructor() { + super('First-run setup was reset before a choice completed.') + this.name = 'FirstRunSetupResetError' + } +} + +// Owns the production startHermes path up to the local process spawn. Keeping +// the full ordering here makes the first-run remote boundary executable in a +// test: an already-saved remote wins immediately; otherwise update exclusion +// and local backend resolution happen before the setup gate, and a remote Apply +// re-resolves persisted config without ever entering ensureRuntime/bootstrap. +export async function runPrimaryBackendStartup({ + connectRemote, + ensureLocalRuntime, + prepareLocalBackend, + resolveRemote, + waitForDecision, + waitForLocalStart +}: PrimaryBackendStartupOptions): Promise< + PrimaryBackendStartupResult +> { + const savedRemote = await resolveRemote() + + if (savedRemote) { + return { kind: 'remote', connection: await connectRemote(savedRemote) } + } + + await waitForLocalStart() + + const backend = await prepareLocalBackend() + const decision = await waitForDecision(backend) + + if (decision === 'remote-applied') { + const appliedRemote = await resolveRemote() + + if (!appliedRemote) { + throw new Error('First-run remote setup completed without a saved remote backend.') + } + + return { kind: 'remote', connection: await connectRemote(appliedRemote) } + } + + if (decision === 'reset') { + throw new FirstRunSetupResetError() + } + + return { kind: 'local', backend: await ensureLocalRuntime(backend) } +} diff --git a/apps/desktop/electron/primary-connection-rehome.ts b/apps/desktop/electron/primary-connection-rehome.ts new file mode 100644 index 000000000000..4952b8a90281 --- /dev/null +++ b/apps/desktop/electron/primary-connection-rehome.ts @@ -0,0 +1,35 @@ +export interface PrimaryConnectionRehomeOptions { + clearLocalBootstrapFailure: () => void + mode: string + notifyConnectionApplied: () => void + resumeFirstRunRemote: () => boolean + teardownPrimaryBackend: (options: { soft: boolean }) => Promise +} + +// Production seam shared by the connection-config IPC handler and the +// first-run integration test. A remote apply that resumes the active setup +// gate must keep that connection attempt alive; ordinary mode changes tear the +// current backend down before the renderer is told to reconnect. +export async function rehomePrimaryConnection({ + clearLocalBootstrapFailure, + mode, + notifyConnectionApplied, + resumeFirstRunRemote, + teardownPrimaryBackend +}: PrimaryConnectionRehomeOptions): Promise<{ resumedFirstRunRemote: boolean }> { + let resumedFirstRunRemote = false + + if (mode === 'remote') { + resumedFirstRunRemote = resumeFirstRunRemote() + clearLocalBootstrapFailure() + } + + if (resumedFirstRunRemote) { + return { resumedFirstRunRemote: true } + } + + await teardownPrimaryBackend({ soft: true }) + notifyConnectionApplied() + + return { resumedFirstRunRemote: false } +} diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts index 680445752132..703f3f745856 100644 --- a/apps/desktop/src/components/boot-failure-reauth.ts +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -1,4 +1,5 @@ import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global' +import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth' // Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out // of the .tsx so they can be unit-tested without a React/jsdom render (the @@ -136,18 +137,7 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un isPassword: boolean providerLabel: string } { - const list = providers ?? [] - - if (list.length === 0) { - return { isPassword: false, providerLabel: 'your identity provider' } - } - - const isPassword = list.every(p => Boolean(p.supportsPassword)) - - const providerLabel = - list.length === 1 ? list[0].displayName || list[0].name : list.map(p => p.displayName || p.name).join(' / ') - - return { isPassword, providerLabel } + return deriveRemoteAuthProviderShape(providers) } // Button copy for the remote sign-in action. diff --git a/apps/desktop/src/components/desktop-install-overlay.test.tsx b/apps/desktop/src/components/desktop-install-overlay.test.tsx new file mode 100644 index 000000000000..ce3b9a04548f --- /dev/null +++ b/apps/desktop/src/components/desktop-install-overlay.test.tsx @@ -0,0 +1,499 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DesktopBootstrapEvent, DesktopBootstrapState, DesktopConnectionProbeResult } from '@/global' + +import { DesktopInstallOverlay } from './desktop-install-overlay' + +function bootstrapState(overrides: Partial = {}): DesktopBootstrapState { + return { + active: false, + manifest: null, + stages: {}, + error: null, + log: [], + startedAt: null, + completedAt: null, + setupChoice: null, + unsupportedPlatform: null, + ...overrides + } +} + +function installDesktopMock(state: DesktopBootstrapState) { + const bootstrapListeners = new Set<(event: DesktopBootstrapEvent) => void>() + + const desktop = { + getBootstrapState: vi.fn().mockResolvedValue(state), + onBootstrapEvent: vi.fn((listener: (event: DesktopBootstrapEvent) => void) => { + bootstrapListeners.add(listener) + + return () => bootstrapListeners.delete(listener) + }), + continueBootstrapLocal: vi.fn().mockResolvedValue({ ok: true }), + probeConnectionConfig: vi.fn(), + testConnectionConfig: vi.fn(), + applyConnectionConfig: vi.fn(), + oauthLoginConnectionConfig: vi.fn(), + openExternal: vi.fn(), + emitBootstrapEvent: (event: DesktopBootstrapEvent) => { + for (const listener of bootstrapListeners) { + listener(event) + } + } + } + + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: desktop + }) + + return desktop +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + Reflect.deleteProperty(window, 'hermesDesktop') +}) + +describe('DesktopInstallOverlay first-run setup', () => { + it('shows the remote/local choice without installer progress', async () => { + installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' } + }) + ) + + render() + + expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy() + expect(screen.getByText('Connect to existing Hermes')).toBeTruthy() + expect(screen.getByText('Install Hermes locally')).toBeTruthy() + expect(screen.queryByText(/steps complete/i)).toBeNull() + expect(screen.queryByText(/Fetching installer manifest/i)).toBeNull() + }) + + it('continues local bootstrap only when Install Hermes locally is selected', async () => { + const desktop = installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' } + }) + ) + + render() + + fireEvent.click(await screen.findByText('Install Hermes locally')) + + expect(desktop.continueBootstrapLocal).toHaveBeenCalledTimes(1) + expect(screen.getByText('Set up Hermes Desktop')).toBeTruthy() + + act(() => { + desktop.emitBootstrapEvent({ type: 'manifest', protocolVersion: 1, stages: [] }) + }) + + await waitFor(() => expect(screen.queryByText('Set up Hermes Desktop')).toBeNull()) + expect(screen.getByText(/Fetching installer manifest/i)).toBeTruthy() + }) + + it('surfaces a recoverable error when the local-bootstrap bridge is unavailable', async () => { + const desktop = installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' } + }) + ) + + desktop.continueBootstrapLocal = undefined as never + render() + + const install = (await screen.findByText('Install Hermes locally')).closest('button') as HTMLButtonElement + fireEvent.click(install) + + expect( + await screen.findByText('Local installation could not start. Restart Hermes Desktop and try again.') + ).toBeTruthy() + expect(install.disabled).toBe(false) + }) + + it('opens the remote connection form from the first-run choice', async () => { + installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' } + }) + ) + + render() + + fireEvent.click(await screen.findByText('Connect to existing Hermes')) + + expect(await screen.findByText('Gateway URL')).toBeTruthy() + expect(screen.getByText('Test connection')).toBeTruthy() + expect(screen.getByText('Apply and reconnect')).toBeTruthy() + }) + + it('returns from the remote connection form to the first-run choice', async () => { + installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' } + }) + ) + + render() + + fireEvent.click(await screen.findByText('Connect to existing Hermes')) + expect(await screen.findByText('Gateway URL')).toBeTruthy() + + fireEvent.click(screen.getByText('Back')) + + expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy() + expect(screen.getByText('Install Hermes locally')).toBeTruthy() + }) + + it('requires a successful token connection test before applying remote config', async () => { + const desktop = installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' } + }) + ) + + desktop.probeConnectionConfig.mockResolvedValue({ + authMode: 'token', + baseUrl: 'https://gateway.example.com/hermes', + error: null, + providers: [], + reachable: true, + version: '0.17.0' + }) + desktop.testConnectionConfig.mockResolvedValue({ + baseUrl: 'https://gateway.example.com/hermes', + ok: true, + version: '0.17.0' + }) + desktop.applyConnectionConfig.mockImplementation(async () => { + desktop.emitBootstrapEvent({ type: 'dismissed' }) + + return { mode: 'remote' } + }) + + render() + + fireEvent.click(await screen.findByText('Connect to existing Hermes')) + fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), { + target: { value: 'https://gateway.example.com/hermes' } + }) + + const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement + expect(apply.disabled).toBe(true) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 550)) + }) + + fireEvent.change(await screen.findByPlaceholderText('Paste session token'), { + target: { value: 'session-secret' } + }) + fireEvent.click(screen.getByText('Test connection')) + + await waitFor(() => { + expect(desktop.testConnectionConfig).toHaveBeenCalledWith({ + mode: 'remote', + remoteAuthMode: 'token', + remoteToken: 'session-secret', + remoteUrl: 'https://gateway.example.com/hermes' + }) + }) + + await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).') + expect(apply.disabled).toBe(false) + + fireEvent.click(screen.getByText('Apply and reconnect')) + + await waitFor(() => { + expect(desktop.applyConnectionConfig).toHaveBeenCalledWith({ + mode: 'remote', + remoteAuthMode: 'token', + remoteToken: 'session-secret', + remoteUrl: 'https://gateway.example.com/hermes' + }) + }) + await waitFor(() => expect(screen.queryByText('Gateway URL')).toBeNull()) + }) + + it('ignores a completed probe after the gateway URL becomes invalid', async () => { + const desktop = installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' } + }) + ) + + let resolveProbe: ((result: DesktopConnectionProbeResult) => void) | undefined + + const pendingProbe = new Promise(resolve => { + resolveProbe = resolve + }) + + desktop.probeConnectionConfig.mockReturnValue(pendingProbe) + + render() + + fireEvent.click(await screen.findByText('Connect to existing Hermes')) + const urlInput = await screen.findByPlaceholderText('https://gateway.example.com/hermes') + fireEvent.change(urlInput, { target: { value: 'https://gateway.example.com/hermes' } }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 550)) + }) + expect(desktop.probeConnectionConfig).toHaveBeenCalledTimes(1) + + fireEvent.change(urlInput, { target: { value: 'not-a-url' } }) + await act(async () => { + resolveProbe?.({ + authMode: 'token', + baseUrl: 'https://gateway.example.com/hermes', + error: null, + providers: [], + reachable: true, + version: '0.17.0' + }) + await pendingProbe + }) + + expect(screen.queryByPlaceholderText('Paste session token')).toBeNull() + expect((screen.getByText('Test connection').closest('button') as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement).disabled).toBe(true) + }) + + it('does not enable Apply when credentials change during a connection test', async () => { + const desktop = installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' } + }) + ) + + desktop.probeConnectionConfig.mockResolvedValue({ + authMode: 'token', + baseUrl: 'https://gateway.example.com/hermes', + error: null, + providers: [], + reachable: true, + version: '0.17.0' + }) + + let resolveTest: ((result: { baseUrl: string; ok: boolean; version: string }) => void) | undefined + + const pendingTest = new Promise<{ baseUrl: string; ok: boolean; version: string }>(resolve => { + resolveTest = resolve + }) + + desktop.testConnectionConfig.mockReturnValue(pendingTest) + + render() + + fireEvent.click(await screen.findByText('Connect to existing Hermes')) + fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), { + target: { value: 'https://gateway.example.com/hermes' } + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 550)) + }) + + const tokenInput = await screen.findByPlaceholderText('Paste session token') + const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement + + fireEvent.change(tokenInput, { target: { value: 'token-a' } }) + fireEvent.click(screen.getByText('Test connection')) + await waitFor(() => expect(desktop.testConnectionConfig).toHaveBeenCalledTimes(1)) + + fireEvent.change(tokenInput, { target: { value: 'token-b' } }) + + await act(async () => { + resolveTest?.({ baseUrl: 'https://gateway.example.com/hermes', ok: true, version: '0.17.0' }) + await pendingTest + }) + + expect(screen.queryByText('Connected to https://gateway.example.com/hermes (0.17.0).')).toBeNull() + expect(apply.disabled).toBe(true) + }) + + it('restores remote apply controls when applying the tested connection fails', async () => { + const desktop = installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' } + }) + ) + + desktop.probeConnectionConfig.mockResolvedValue({ + authMode: 'token', + baseUrl: 'https://gateway.example.com/hermes', + error: null, + providers: [], + reachable: true, + version: '0.17.0' + }) + desktop.testConnectionConfig.mockResolvedValue({ + baseUrl: 'https://gateway.example.com/hermes', + ok: true, + version: '0.17.0' + }) + desktop.applyConnectionConfig.mockRejectedValue(new Error('remote apply failed')) + + render() + + fireEvent.click(await screen.findByText('Connect to existing Hermes')) + fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), { + target: { value: 'https://gateway.example.com/hermes' } + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 550)) + }) + + fireEvent.change(await screen.findByPlaceholderText('Paste session token'), { + target: { value: 'session-secret' } + }) + fireEvent.click(screen.getByText('Test connection')) + await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).') + + const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement + fireEvent.click(apply) + + expect(await screen.findByText('remote apply failed')).toBeTruthy() + expect(apply.disabled).toBe(false) + expect(screen.getByText('Gateway URL')).toBeTruthy() + }) + + it('signs in, tests, and applies a password-style remote gateway', async () => { + const desktop = installDesktopMock( + bootstrapState({ + setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' } + }) + ) + + desktop.probeConnectionConfig.mockResolvedValue({ + authMode: 'oauth', + baseUrl: 'https://gateway.example.com/hermes', + error: null, + providers: [{ displayName: 'Username & Password', name: 'password', supportsPassword: true }], + reachable: true, + version: '0.17.0' + }) + desktop.oauthLoginConnectionConfig.mockResolvedValue({ + baseUrl: 'https://gateway.example.com/hermes', + connected: true, + ok: true + }) + desktop.testConnectionConfig.mockResolvedValue({ + baseUrl: 'https://gateway.example.com/hermes', + ok: true, + version: null + }) + desktop.applyConnectionConfig.mockResolvedValue({ mode: 'remote' }) + + render() + + fireEvent.click(await screen.findByText('Connect to existing Hermes')) + fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), { + target: { value: 'https://gateway.example.com/hermes' } + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 550)) + }) + + expect(screen.queryByText('Sign in with Username & Password')).toBeNull() + fireEvent.click(await screen.findByText('Sign in')) + + await waitFor(() => { + expect(desktop.oauthLoginConnectionConfig).toHaveBeenCalledWith('https://gateway.example.com/hermes') + }) + + fireEvent.click(screen.getByText('Test connection')) + + await waitFor(() => { + expect(desktop.testConnectionConfig).toHaveBeenCalledWith({ + mode: 'remote', + remoteAuthMode: 'oauth', + remoteToken: undefined, + remoteUrl: 'https://gateway.example.com/hermes' + }) + }) + + await screen.findByText('Connected to https://gateway.example.com/hermes.') + const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement + expect(apply.disabled).toBe(false) + fireEvent.click(apply) + + await waitFor(() => { + expect(desktop.applyConnectionConfig).toHaveBeenCalledWith({ + mode: 'remote', + remoteAuthMode: 'oauth', + remoteToken: undefined, + remoteUrl: 'https://gateway.example.com/hermes' + }) + }) + }) + + it('offers remote connection from the unsupported packaged install screen', async () => { + const desktop = installDesktopMock( + bootstrapState({ + unsupportedPlatform: { + platform: 'darwin', + activeRoot: '/Users/me/.hermes/hermes-agent', + installCommand: 'curl -fsSL https://example.invalid/install.sh | sh', + docsUrl: 'https://example.invalid/docs' + } + }) + ) + + render() + + expect(await screen.findByText('Hermes needs a one-time install')).toBeTruthy() + + fireEvent.click(screen.getByText('Connect existing')) + + expect(await screen.findByText('Gateway URL')).toBeTruthy() + + desktop.probeConnectionConfig.mockResolvedValue({ + authMode: 'token', + baseUrl: 'https://gateway.example.com/hermes', + error: null, + providers: [], + reachable: true, + version: '0.17.0' + }) + desktop.testConnectionConfig.mockResolvedValue({ + baseUrl: 'https://gateway.example.com/hermes', + ok: true, + version: '0.17.0' + }) + desktop.applyConnectionConfig.mockImplementation(async () => { + desktop.emitBootstrapEvent({ type: 'dismissed' }) + + return { mode: 'remote' } + }) + + fireEvent.change(screen.getByPlaceholderText('https://gateway.example.com/hermes'), { + target: { value: 'https://gateway.example.com/hermes' } + }) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 550)) + }) + + fireEvent.change(await screen.findByPlaceholderText('Paste session token'), { + target: { value: 'session-secret' } + }) + fireEvent.click(screen.getByText('Test connection')) + await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).') + fireEvent.click(screen.getByText('Apply and reconnect')) + + await waitFor(() => expect(screen.queryByText('Gateway URL')).toBeNull()) + expect(screen.queryByText('Hermes needs a one-time install')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index b701fa9ff6a1..8a56f93c8c45 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -15,10 +15,12 @@ import type { DesktopBootstrapState } from '@/global' import { useI18n } from '@/i18n' -import { ChevronDown, ChevronRight, iconSize } from '@/lib/icons' +import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons' import { capitalize } from '@/lib/text' import { cn } from '@/lib/utils' +import { FirstRunRemoteForm } from './first-run-remote-form' + /** * DesktopInstallOverlay * @@ -155,6 +157,10 @@ function StageRow({ descriptor, result, now }: StageRowProps) { ) } +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err || 'Unknown error') +} + const EMPTY_STATE: DesktopBootstrapState = { active: false, manifest: null, @@ -163,10 +169,32 @@ const EMPTY_STATE: DesktopBootstrapState = { log: [], startedAt: null, completedAt: null, + setupChoice: null, unsupportedPlatform: null } function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState { + if (ev.type === 'dismissed') { + return { ...EMPTY_STATE } + } + + if (ev.type === 'setup-choice') { + return { + ...state, + active: false, + manifest: null, + stages: {}, + error: null, + setupChoice: ev.active + ? { + platform: ev.platform || state.setupChoice?.platform || 'unknown', + activeRoot: ev.activeRoot || state.setupChoice?.activeRoot || '' + } + : null, + unsupportedPlatform: null + } + } + if (ev.type === 'manifest') { const stages: Record = {} @@ -180,6 +208,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion }, stages, error: null, + setupChoice: null, startedAt: state.startedAt || Date.now() } } @@ -219,13 +248,14 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De } if (ev.type === 'failed') { - return { ...state, active: false, error: ev.error || 'unknown error' } + return { ...state, active: false, error: ev.error || 'unknown error', setupChoice: null } } if (ev.type === 'unsupported-platform') { return { ...state, active: false, + setupChoice: null, unsupportedPlatform: { platform: ev.platform, activeRoot: ev.activeRoot, @@ -246,6 +276,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP const [logOpen, setLogOpen] = useState(false) const [copied, setCopied] = useState(false) const [cancelling, setCancelling] = useState(false) + const [remoteOpen, setRemoteOpen] = useState(false) + const [localStarting, setLocalStarting] = useState(false) + const [localStartError, setLocalStartError] = useState(null) const [now, setNow] = useState(() => Date.now()) const logEndRef = useRef(null) @@ -312,6 +345,14 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP } }, [state.error]) + // The choice remains mounted while main hands off to local bootstrap. Once + // a manifest/failure takes ownership (or a later repair presents a fresh + // choice), clear the transient button state so it cannot leak across phases. + useEffect(() => { + setLocalStarting(false) + setLocalStartError(null) + }, [state.setupChoice?.activeRoot]) + // Mount logic: show whenever a bootstrap is in flight, completed-with-error, // or actively running with a manifest. Hide entirely after a successful // completion so the rest of the UI can take over. @@ -332,13 +373,96 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP return true } + if (state.setupChoice) { + return true + } + return false - }, [enabled, state.active, state.error, state.unsupportedPlatform]) + }, [enabled, state.active, state.error, state.setupChoice, state.unsupportedPlatform]) if (!shouldShow) { return null } + if (remoteOpen) { + return setRemoteOpen(false)} /> + } + + if (state.setupChoice) { + return ( +
+
+
+ +
+

{copy.setupChoiceTitle}

+

{copy.setupChoiceDesc}

+
+
+ +
+ + + +
+ + {localStartError ? ( +
+ + {localStartError} +
+ ) : null} + +
+ {copy.installTo}{' '} + {state.setupChoice.activeRoot} +
+
+
+ ) + } + // Unsupported-platform branch: macOS/Linux packaged builds hit this when // there's no Hermes Agent installed yet and we can't drive install.sh // (no stage protocol equivalent yet). Show a copy-paste install command @@ -384,9 +508,15 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP {copy.installTo} {ups.activeRoot} - +
+ + +
diff --git a/apps/desktop/src/components/first-run-remote-form.tsx b/apps/desktop/src/components/first-run-remote-form.tsx new file mode 100644 index 000000000000..ced31acdd444 --- /dev/null +++ b/apps/desktop/src/components/first-run-remote-form.tsx @@ -0,0 +1,345 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { BrandMark } from '@/components/brand-mark' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import type { DesktopConnectionProbeResult } from '@/global' +import { useI18n } from '@/i18n' +import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth' +import { AlertCircle, Check, Loader2, LogIn } from '@/lib/icons' + +type AuthMode = 'oauth' | 'token' +type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' + +interface FirstRunRemoteFormProps { + onBack: () => void +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err || 'Unknown error') +} + +export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) { + const { t } = useI18n() + const copy = t.install + const [remoteUrl, setRemoteUrl] = useState('') + const [remoteToken, setRemoteToken] = useState('') + const [probeStatus, setProbeStatus] = useState('idle') + const [probe, setProbe] = useState(null) + const [oauthConnected, setOauthConnected] = useState(false) + const [signingIn, setSigningIn] = useState(false) + const [testing, setTesting] = useState(false) + const [applying, setApplying] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState(null) + const probeSeq = useRef(0) + const testSeq = useRef(0) + + const trimmedUrl = remoteUrl.trim() + + const invalidateTest = useCallback(() => { + testSeq.current += 1 + setTesting(false) + setError(null) + setSuccess(null) + setLastTestedPayloadKey(null) + }, []) + + useEffect(() => { + const seq = ++probeSeq.current + + if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { + setProbeStatus('idle') + setProbe(null) + setOauthConnected(false) + + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.probeConnectionConfig) { + return + } + + setProbeStatus('probing') + + const timer = window.setTimeout(() => { + desktop + .probeConnectionConfig(trimmedUrl) + .then(result => { + if (seq !== probeSeq.current) { + return + } + + invalidateTest() + setProbe(result) + setProbeStatus(result.reachable ? 'done' : 'error') + + if (result.reachable && result.authMode !== 'oauth') { + setOauthConnected(false) + } + }) + .catch(err => { + if (seq !== probeSeq.current) { + return + } + + setProbe(null) + setProbeStatus('error') + setError(errorMessage(err)) + }) + }, 500) + + return () => window.clearTimeout(timer) + }, [invalidateTest, trimmedUrl]) + + const authMode: AuthMode = probeStatus === 'done' && probe?.authMode === 'oauth' ? 'oauth' : 'token' + const authResolved = probeStatus === 'done' && probe?.authMode !== 'unknown' + const authProviderShape = deriveRemoteAuthProviderShape(probe?.providers, copy.identityProvider) + const { isPassword: isPasswordProvider, providerLabel } = authProviderShape + const canRetryProbe = Boolean(trimmedUrl && probeStatus === 'error') + + const canTest = Boolean( + trimmedUrl && (canRetryProbe || (authResolved && (authMode === 'oauth' ? oauthConnected : remoteToken.trim()))) + ) + + const payload = () => ({ + mode: 'remote' as const, + remoteAuthMode: authMode, + remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, + remoteUrl: trimmedUrl + }) + + const currentPayloadKey = JSON.stringify(payload()) + const payloadKeyRef = useRef(currentPayloadKey) + payloadKeyRef.current = currentPayloadKey + const canApply = lastTestedPayloadKey === currentPayloadKey + + const signIn = async () => { + if (!trimmedUrl) { + setError(copy.enterUrlFirst) + + return + } + + setSigningIn(true) + setError(null) + + try { + // Unlike Settings, first-run intentionally does not pre-save remote mode: + // backing out must still allow local install without leaving a remote + // connection selected. The login IPC accepts the raw URL and stores only + // its OAuth cookies; config is persisted once the user applies. + const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) + invalidateTest() + setOauthConnected(Boolean(result.connected)) + + if (!result.connected) { + setError(copy.signInIncomplete) + } + } catch (err) { + setError(errorMessage(err)) + } finally { + setSigningIn(false) + } + } + + const testRemote = async () => { + if (!canTest) { + setError(authMode === 'oauth' ? copy.incompleteSignInTest : copy.incompleteTokenTest) + + return + } + + const seq = ++testSeq.current + const testedPayload = payload() + const testedPayloadKey = JSON.stringify(testedPayload) + + setTesting(true) + setError(null) + setSuccess(null) + setLastTestedPayloadKey(null) + + try { + if (!authResolved) { + const result = await window.hermesDesktop.probeConnectionConfig(trimmedUrl) + + if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) { + return + } + + setProbe(result) + setProbeStatus(result.reachable ? 'done' : 'error') + setError(result.reachable && result.authMode !== 'unknown' ? null : result.error || copy.probeError) + + return + } + + const result = await window.hermesDesktop.testConnectionConfig(testedPayload) + + if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) { + return + } + + setSuccess(copy.testSucceeded(result.baseUrl || trimmedUrl, result.version ?? undefined)) + setLastTestedPayloadKey(testedPayloadKey) + } catch (err) { + if (seq === testSeq.current && testedPayloadKey === payloadKeyRef.current) { + setError(errorMessage(err)) + } + } finally { + if (seq === testSeq.current) { + setTesting(false) + } + } + } + + const applyRemote = async () => { + if (!canApply) { + return + } + + const testedPayload = payload() + + setApplying(true) + setError(null) + let applied = false + + try { + await window.hermesDesktop.applyConnectionConfig(testedPayload) + applied = true + } catch (err) { + setError(errorMessage(err)) + } finally { + setApplying(false) + } + + if (applied) { + onBack() + } + } + + return ( +
+
+
+ +
+

{copy.remoteSetupTitle}

+

{copy.remoteSetupDesc}

+
+
+ +
+ + + {probeStatus === 'probing' ? ( +
+ + {copy.probing} +
+ ) : null} + + {probeStatus === 'error' ? ( +
+ + {probe?.error || copy.probeError} +
+ ) : null} + + {authResolved && authMode === 'oauth' ? ( +
+
+
+
{copy.authTitle}
+

+ {oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)} +

+
+ {oauthConnected ? ( +
+ + {copy.connected} +
+ ) : ( + + )} +
+
+ ) : null} + + {authResolved && authMode === 'token' ? ( + + ) : null} + + {error ? ( +
+ + {error} +
+ ) : null} + + {success ? ( +
+ + {success} +
+ ) : null} +
+ +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d9d9af1ee13c..c0c3c7f246fd 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -211,6 +211,7 @@ declare global { onPowerResume?: (callback: () => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise + continueBootstrapLocal: () => Promise<{ ok: boolean }> resetBootstrap: () => Promise<{ ok: boolean }> repairBootstrap: () => Promise<{ ok: boolean }> cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }> @@ -627,6 +628,11 @@ export interface DesktopBootstrapUnsupportedPlatform { docsUrl: string } +export interface DesktopBootstrapSetupChoice { + platform: string + activeRoot: string +} + export interface DesktopBootstrapState { active: boolean manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null @@ -635,10 +641,18 @@ export interface DesktopBootstrapState { log: Array<{ ts: number; stage: string | null; line: string; stream?: 'stdout' | 'stderr' }> startedAt: number | null completedAt: number | null + setupChoice: DesktopBootstrapSetupChoice | null unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null } export type DesktopBootstrapEvent = + | { type: 'dismissed' } + | { + type: 'setup-choice' + active: boolean + platform?: string + activeRoot?: string + } | { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | { type: 'stage' diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index ca18522043e2..c48f8f5cf14f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2121,6 +2121,40 @@ export const en: Translations = { viewDocs: 'View install docs', installTo: 'Will install to', retryAfterRun: 'I’ve run it -- retry', + setupChoiceTitle: 'Set up Hermes Desktop', + setupChoiceDesc: + 'Connect this app to a Hermes gateway you already run, or install Hermes locally on this computer.', + connectExistingTitle: 'Connect to existing Hermes', + connectExistingShort: 'Connect existing', + connectExistingDesc: 'Use a remote backend with a session token or browser sign-in. No local install will start.', + installLocalTitle: 'Install Hermes locally', + installLocalDesc: 'Download Hermes, create its Python environment, and run the backend on this computer.', + localStartUnavailable: 'Local installation could not start. Restart Hermes Desktop and try again.', + remoteSetupTitle: 'Connect to existing Hermes', + remoteSetupDesc: 'Enter your gateway URL. Hermes Desktop will detect whether it needs a token or browser sign-in.', + remoteUrlTitle: 'Gateway URL', + remoteUrlDesc: 'Use the base URL of the Hermes gateway, including https:// when remote.', + remoteUrlPlaceholder: 'https://gateway.example.com/hermes', + probing: 'Detecting gateway authentication...', + probeError: 'Could not reach that Hermes gateway.', + identityProvider: 'your identity provider', + authTitle: 'Authentication', + authNeedsOauth: provider => `Sign in with ${provider} before testing this gateway.`, + authSignedIn: 'Browser sign-in completed.', + connected: 'Connected', + signIn: 'Sign in', + signInWith: provider => `Sign in with ${provider}`, + enterUrlFirst: 'Enter a gateway URL first.', + signInIncomplete: 'The sign-in window closed before authentication completed.', + tokenTitle: 'Session token', + tokenDesc: 'Paste the session token from the remote gateway .env file.', + pasteSessionToken: 'Paste session token', + incompleteSignInTest: 'Sign in before testing this OAuth-gated gateway.', + incompleteTokenTest: 'Enter a session token before testing this gateway.', + testConnection: 'Test connection', + testSucceeded: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` (${version})` : ''}.`, + applyRemote: 'Apply and reconnect', + backToSetup: 'Back', failedTitle: 'Installation failed', settingUpTitle: 'Setting up Hermes Agent', finishingTitle: 'Finishing up', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index b6ef78614131..9c640512595a 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1989,6 +1989,43 @@ export const ja = defineLocale({ viewDocs: 'インストールドキュメントを見る', installTo: 'インストール先', retryAfterRun: '実行しました — 再試行', + setupChoiceTitle: 'Hermes Desktop をセットアップ', + setupChoiceDesc: + 'すでに実行している Hermes ゲートウェイに接続するか、このコンピューターに Hermes をローカルインストールします。', + connectExistingTitle: '既存の Hermes に接続', + connectExistingShort: '既存環境に接続', + connectExistingDesc: + 'セッショントークンまたはブラウザーサインインでリモートバックエンドを使用します。ローカルインストールは開始されません。', + installLocalTitle: 'Hermes をローカルにインストール', + installLocalDesc: 'Hermes をダウンロードし、Python 環境を作成して、このコンピューターでバックエンドを実行します。', + localStartUnavailable: + 'ローカルインストールを開始できません。Hermes Desktop を再起動して、もう一度お試しください。', + remoteSetupTitle: '既存の Hermes に接続', + remoteSetupDesc: + 'ゲートウェイ URL を入力してください。Hermes Desktop がトークンとブラウザーサインインのどちらが必要かを検出します。', + remoteUrlTitle: 'ゲートウェイ URL', + remoteUrlDesc: 'Hermes ゲートウェイのベース URL を使用します。リモートの場合は https:// を含めてください。', + remoteUrlPlaceholder: 'https://gateway.example.com/hermes', + probing: 'ゲートウェイ認証方式を検出中...', + probeError: 'その Hermes ゲートウェイに到達できませんでした。', + identityProvider: 'ID プロバイダー', + authTitle: '認証', + authNeedsOauth: provider => `このゲートウェイをテストする前に ${provider} でサインインしてください。`, + authSignedIn: 'ブラウザーサインインが完了しました。', + connected: '接続済み', + signIn: 'サインイン', + signInWith: provider => `${provider} でサインイン`, + enterUrlFirst: '先にゲートウェイ URL を入力してください。', + signInIncomplete: '認証が完了する前にサインインウィンドウが閉じられました。', + tokenTitle: 'セッショントークン', + tokenDesc: 'リモートゲートウェイの .env ファイルからセッショントークンを貼り付けます。', + pasteSessionToken: 'セッショントークンを貼り付け', + incompleteSignInTest: 'OAuth で保護されたこのゲートウェイをテストする前にサインインしてください。', + incompleteTokenTest: 'このゲートウェイをテストする前にセッショントークンを入力してください。', + testConnection: '接続をテスト', + testSucceeded: (baseUrl, version) => `${baseUrl}${version ? ` (${version})` : ''} に接続しました。`, + applyRemote: '適用して再接続', + backToSetup: '戻る', failedTitle: 'インストールに失敗しました', settingUpTitle: 'Hermes Agent を設定中', finishingTitle: '仕上げ中', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 3d163d6d5f8e..d7c8bc39f8b5 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1753,6 +1753,39 @@ export interface Translations { viewDocs: string installTo: string retryAfterRun: string + setupChoiceTitle: string + setupChoiceDesc: string + connectExistingTitle: string + connectExistingShort: string + connectExistingDesc: string + installLocalTitle: string + installLocalDesc: string + localStartUnavailable: string + remoteSetupTitle: string + remoteSetupDesc: string + remoteUrlTitle: string + remoteUrlDesc: string + remoteUrlPlaceholder: string + probing: string + probeError: string + identityProvider: string + authTitle: string + authNeedsOauth: (provider: string) => string + authSignedIn: string + connected: string + signIn: string + signInWith: (provider: string) => string + enterUrlFirst: string + signInIncomplete: string + tokenTitle: string + tokenDesc: string + pasteSessionToken: string + incompleteSignInTest: string + incompleteTokenTest: string + testConnection: string + testSucceeded: (baseUrl: string, version?: string) => string + applyRemote: string + backToSetup: string failedTitle: string settingUpTitle: string finishingTitle: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4c07372af168..f94fefc74167 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1930,6 +1930,40 @@ export const zhHant = defineLocale({ viewDocs: '檢視安裝文件', installTo: '將安裝至', retryAfterRun: '我已執行 -- 重試', + setupChoiceTitle: '設定 Hermes Desktop', + setupChoiceDesc: + '將此應用程式連線到您已執行的 Hermes 閘道,或在這台電腦上本機安裝 Hermes。', + connectExistingTitle: '連線到現有 Hermes', + connectExistingShort: '連線現有環境', + connectExistingDesc: '使用工作階段權杖或瀏覽器登入連線遠端後端。不會啟動本機安裝。', + installLocalTitle: '本機安裝 Hermes', + installLocalDesc: '下載 Hermes、建立 Python 環境,並在這台電腦上執行後端。', + localStartUnavailable: '無法啟動本機安裝。請重新啟動 Hermes Desktop 後再試一次。', + remoteSetupTitle: '連線到現有 Hermes', + remoteSetupDesc: '輸入閘道 URL。Hermes Desktop 會偵測需要權杖還是瀏覽器登入。', + remoteUrlTitle: '閘道 URL', + remoteUrlDesc: '使用 Hermes 閘道的基礎 URL;遠端位址請包含 https://。', + remoteUrlPlaceholder: 'https://gateway.example.com/hermes', + probing: '正在偵測閘道驗證方式...', + probeError: '無法連線到該 Hermes 閘道。', + identityProvider: '您的身分提供者', + authTitle: '驗證', + authNeedsOauth: provider => `測試此閘道前請先使用 ${provider} 登入。`, + authSignedIn: '瀏覽器登入已完成。', + connected: '已連線', + signIn: '登入', + signInWith: provider => `使用 ${provider} 登入`, + enterUrlFirst: '請先輸入閘道 URL。', + signInIncomplete: '驗證完成前登入視窗已關閉。', + tokenTitle: '工作階段權杖', + tokenDesc: '貼上遠端閘道 .env 檔案中的工作階段權杖。', + pasteSessionToken: '貼上工作階段權杖', + incompleteSignInTest: '測試此受 OAuth 保護的閘道前請先登入。', + incompleteTokenTest: '測試此閘道前請輸入工作階段權杖。', + testConnection: '測試連線', + testSucceeded: (baseUrl, version) => `已連線到 ${baseUrl}${version ? ` (${version})` : ''}。`, + applyRemote: '套用並重新連線', + backToSetup: '返回', failedTitle: '安裝失敗', settingUpTitle: '正在設定 Hermes Agent', finishingTitle: '正在收尾', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index e81b141bb698..1c3ab3c3a51d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2307,6 +2307,40 @@ export const zh: Translations = { viewDocs: '查看安装文档', installTo: '将安装到', retryAfterRun: '我已运行 -- 重试', + setupChoiceTitle: '设置 Hermes Desktop', + setupChoiceDesc: + '将此应用连接到你已运行的 Hermes 网关,或在这台电脑上本地安装 Hermes。', + connectExistingTitle: '连接到现有 Hermes', + connectExistingShort: '连接现有环境', + connectExistingDesc: '使用会话令牌或浏览器登录连接远程后端。不会启动本地安装。', + installLocalTitle: '本地安装 Hermes', + installLocalDesc: '下载 Hermes,创建 Python 环境,并在这台电脑上运行后端。', + localStartUnavailable: '无法启动本地安装。请重启 Hermes Desktop 后重试。', + remoteSetupTitle: '连接到现有 Hermes', + remoteSetupDesc: '输入网关 URL。Hermes Desktop 会检测需要令牌还是浏览器登录。', + remoteUrlTitle: '网关 URL', + remoteUrlDesc: '使用 Hermes 网关的基础 URL;远程地址请包含 https://。', + remoteUrlPlaceholder: 'https://gateway.example.com/hermes', + probing: '正在检测网关认证方式...', + probeError: '无法连接到该 Hermes 网关。', + identityProvider: '你的身份提供方', + authTitle: '认证', + authNeedsOauth: provider => `测试此网关前请先使用 ${provider} 登录。`, + authSignedIn: '浏览器登录已完成。', + connected: '已连接', + signIn: '登录', + signInWith: provider => `使用 ${provider} 登录`, + enterUrlFirst: '请先输入网关 URL。', + signInIncomplete: '认证完成前登录窗口已关闭。', + tokenTitle: '会话令牌', + tokenDesc: '粘贴远程网关 .env 文件中的会话令牌。', + pasteSessionToken: '粘贴会话令牌', + incompleteSignInTest: '测试此受 OAuth 保护的网关前请先登录。', + incompleteTokenTest: '测试此网关前请输入会话令牌。', + testConnection: '测试连接', + testSucceeded: (baseUrl, version) => `已连接到 ${baseUrl}${version ? ` (${version})` : ''}。`, + applyRemote: '应用并重新连接', + backToSetup: '返回', failedTitle: '安装失败', settingUpTitle: '正在设置 Hermes Agent', finishingTitle: '正在收尾', diff --git a/apps/desktop/src/lib/desktop-remote-auth.test.ts b/apps/desktop/src/lib/desktop-remote-auth.test.ts new file mode 100644 index 000000000000..734cbae4a2aa --- /dev/null +++ b/apps/desktop/src/lib/desktop-remote-auth.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { deriveRemoteAuthProviderShape } from './desktop-remote-auth' + +describe('deriveRemoteAuthProviderShape', () => { + it('uses fallback copy when the gateway has not reported providers', () => { + expect(deriveRemoteAuthProviderShape(null)).toEqual({ + isPassword: false, + providerLabel: 'your identity provider' + }) + expect(deriveRemoteAuthProviderShape([], 'the configured gateway')).toEqual({ + isPassword: false, + providerLabel: 'the configured gateway' + }) + }) + + it('marks providers as password-style only when every provider supports password login', () => { + expect( + deriveRemoteAuthProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }]) + ).toEqual({ + isPassword: true, + providerLabel: 'Username & Password' + }) + }) + + it('keeps OAuth copy for redirect providers and mixed deployments', () => { + expect(deriveRemoteAuthProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])) + .toEqual({ + isPassword: false, + providerLabel: 'Nous Research' + }) + + expect( + deriveRemoteAuthProviderShape([ + { name: 'basic', displayName: 'Username & Password', supportsPassword: true }, + { name: 'nous', displayName: 'Nous Research', supportsPassword: false } + ]) + ).toEqual({ + isPassword: false, + providerLabel: 'Username & Password / Nous Research' + }) + }) + + it('falls back to provider names when display names are missing', () => { + expect(deriveRemoteAuthProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }])).toEqual({ + isPassword: true, + providerLabel: 'basic' + }) + }) +}) diff --git a/apps/desktop/src/lib/desktop-remote-auth.ts b/apps/desktop/src/lib/desktop-remote-auth.ts new file mode 100644 index 000000000000..5616c81e658f --- /dev/null +++ b/apps/desktop/src/lib/desktop-remote-auth.ts @@ -0,0 +1,26 @@ +import type { DesktopAuthProvider } from '@/global' + +export interface RemoteAuthProviderShape { + isPassword: boolean + providerLabel: string +} + +function providerDisplayName(provider: DesktopAuthProvider): string { + return provider.displayName || provider.name +} + +export function deriveRemoteAuthProviderShape( + providers: DesktopAuthProvider[] | null | undefined, + fallback = 'your identity provider' +): RemoteAuthProviderShape { + const list = providers ?? [] + + if (list.length === 0) { + return { isPassword: false, providerLabel: fallback } + } + + return { + isPassword: list.every(provider => Boolean(provider.supportsPassword)), + providerLabel: list.map(providerDisplayName).join(' / ') + } +} diff --git a/scripts/release.py b/scripts/release.py index b37396f6af33..8f1fb1c7e95d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -44,6 +44,8 @@ PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" # This dict is kept only so existing history keeps resolving; the effective # AUTHOR_MAP below merges it with the directory (directory wins). LEGACY_AUTHOR_MAP = { + "declanbatesmith@outlook.com": "cat-thats-fat", # PR #60489 (desktop: first-run remote connection option) + "drbs2004@me.com": "cat-thats-fat", # PR #60489 (desktop: first-run remote connection option; historical merge email) "122438640+ragingbulld@users.noreply.github.com": "ragingbulld", # PR #65606 salvage (non-finite API wait deadlines; #65746) "zzpigpinggai@users.noreply.github.com": "zzpigpinggai", # PR #66017 salvage of #63617 (OpenRouter explicit-provider picker visibility) "stellarisw@users.noreply.github.com": "StellarisW", # PR #66222 salvage (Discord WebSocket liveness + systemd watchdog; #26656 follow-up)