mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
Merge pull request #70907 from NousResearch/bb/desktop-first-run-remote
feat(desktop): add "Connect to existing Hermes" option to first-run onboarding
This commit is contained in:
commit
fb8d824bcb
23 changed files with 2049 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
133
apps/desktop/electron/first-run-setup-gate.test.ts
Normal file
133
apps/desktop/electron/first-run-setup-gate.test.ts
Normal file
|
|
@ -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<unknown>) {
|
||||
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)
|
||||
})
|
||||
146
apps/desktop/electron/first-run-setup-gate.ts
Normal file
146
apps/desktop/electron/first-run-setup-gate.ts
Normal file
|
|
@ -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<FirstRunSetupDecision>
|
||||
resolve: (decision: FirstRunSetupDecision) => void
|
||||
} | null = null
|
||||
|
||||
let stuckTimer: ReturnType<typeof setTimeout> | 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<FirstRunSetupDecision>(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
|
||||
}
|
||||
}
|
||||
119
apps/desktop/electron/first-run-setup-main-process.test.ts
Normal file
119
apps/desktop/electron/first-run-setup-main-process.test.ts
Normal file
|
|
@ -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'])
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
110
apps/desktop/electron/primary-backend-startup.test.ts
Normal file
110
apps/desktop/electron/primary-backend-startup.test.ts
Normal file
|
|
@ -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<string, unknown> = {}) {
|
||||
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)
|
||||
})
|
||||
66
apps/desktop/electron/primary-backend-startup.ts
Normal file
66
apps/desktop/electron/primary-backend-startup.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { FirstRunSetupDecision } from './first-run-setup-gate'
|
||||
|
||||
export interface PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection> {
|
||||
connectRemote: (remote: Remote) => Promise<Connection>
|
||||
ensureLocalRuntime: (backend: Backend) => Promise<RuntimeBackend>
|
||||
prepareLocalBackend: () => Backend | Promise<Backend>
|
||||
resolveRemote: () => Promise<Remote | null>
|
||||
waitForDecision: (backend: Backend) => Promise<FirstRunSetupDecision>
|
||||
waitForLocalStart: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type PrimaryBackendStartupResult<RuntimeBackend, Connection> =
|
||||
| { 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<Backend, RuntimeBackend, Remote, Connection>({
|
||||
connectRemote,
|
||||
ensureLocalRuntime,
|
||||
prepareLocalBackend,
|
||||
resolveRemote,
|
||||
waitForDecision,
|
||||
waitForLocalStart
|
||||
}: PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection>): Promise<
|
||||
PrimaryBackendStartupResult<RuntimeBackend, Connection>
|
||||
> {
|
||||
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) }
|
||||
}
|
||||
35
apps/desktop/electron/primary-connection-rehome.ts
Normal file
35
apps/desktop/electron/primary-connection-rehome.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export interface PrimaryConnectionRehomeOptions {
|
||||
clearLocalBootstrapFailure: () => void
|
||||
mode: string
|
||||
notifyConnectionApplied: () => void
|
||||
resumeFirstRunRemote: () => boolean
|
||||
teardownPrimaryBackend: (options: { soft: boolean }) => Promise<void>
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
499
apps/desktop/src/components/desktop-install-overlay.test.tsx
Normal file
499
apps/desktop/src/components/desktop-install-overlay.test.tsx
Normal file
|
|
@ -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> = {}): 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(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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<DesktopConnectionProbeResult>(resolve => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
|
||||
desktop.probeConnectionConfig.mockReturnValue(pendingProbe)
|
||||
|
||||
render(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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(<DesktopInstallOverlay />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, DesktopBootstrapStageResult> = {}
|
||||
|
||||
|
|
@ -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<string | null>(null)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const logEndRef = useRef<HTMLDivElement | null>(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 <FirstRunRemoteForm onBack={() => setRemoteOpen(false)} />
|
||||
}
|
||||
|
||||
if (state.setupChoice) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
|
||||
<div className="w-full max-w-2xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
|
||||
<div className="flex items-start gap-4">
|
||||
<BrandMark className="size-11 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{copy.setupChoiceTitle}</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{copy.setupChoiceDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover)"
|
||||
onClick={() => setRemoteOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
<span>{copy.connectExistingTitle}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.connectExistingDesc}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover) disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={localStarting}
|
||||
onClick={async () => {
|
||||
setLocalStarting(true)
|
||||
setLocalStartError(null)
|
||||
|
||||
try {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop || typeof desktop.continueBootstrapLocal !== 'function') {
|
||||
throw new Error(copy.localStartUnavailable)
|
||||
}
|
||||
|
||||
await desktop.continueBootstrapLocal()
|
||||
} catch (err) {
|
||||
setLocalStartError(errorMessage(err))
|
||||
setLocalStarting(false)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{localStarting ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Monitor className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{copy.installLocalTitle}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.installLocalDesc}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{localStartError ? (
|
||||
<div className="mt-4 flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{localStartError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 text-xs text-muted-foreground">
|
||||
{copy.installTo}{' '}
|
||||
<code className="font-mono text-(--ui-text-secondary)">{state.setupChoice.activeRoot}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
|||
<span className="text-xs text-muted-foreground">
|
||||
{copy.installTo} <code className="font-mono text-(--ui-text-secondary)">{ups.activeRoot}</code>
|
||||
</span>
|
||||
<Button onClick={() => window.location.reload()} size="sm" variant="default">
|
||||
{copy.retryAfterRun}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setRemoteOpen(true)} size="sm" variant="secondary">
|
||||
<Globe className="size-4" />
|
||||
{copy.connectExistingShort}
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} size="sm" variant="default">
|
||||
{copy.retryAfterRun}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
345
apps/desktop/src/components/first-run-remote-form.tsx
Normal file
345
apps/desktop/src/components/first-run-remote-form.tsx
Normal file
|
|
@ -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<ProbeStatus>('idle')
|
||||
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(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<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState<string | null>(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 (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
|
||||
<div className="flex w-full max-w-xl flex-col rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
|
||||
<div className="flex items-start gap-4">
|
||||
<BrandMark className="size-11 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{copy.remoteSetupTitle}</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{copy.remoteSetupDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{copy.remoteUrlTitle}</span>
|
||||
<Input
|
||||
autoComplete="url"
|
||||
disabled={applying}
|
||||
onChange={event => {
|
||||
invalidateTest()
|
||||
setRemoteUrl(event.target.value)
|
||||
}}
|
||||
placeholder={copy.remoteUrlPlaceholder}
|
||||
value={remoteUrl}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{copy.remoteUrlDesc}</span>
|
||||
</label>
|
||||
|
||||
{probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{copy.probing}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{probeStatus === 'error' ? (
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{probe?.error || copy.probeError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{authResolved && authMode === 'oauth' ? (
|
||||
<div className="rounded-md border border-(--ui-stroke-tertiary) p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{copy.authTitle}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)}
|
||||
</p>
|
||||
</div>
|
||||
{oauthConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-primary">
|
||||
<Check className="size-4" />
|
||||
{copy.connected}
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={signingIn || applying} onClick={() => void signIn()} size="sm">
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||
{isPasswordProvider ? copy.signIn : copy.signInWith(providerLabel)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{authResolved && authMode === 'token' ? (
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{copy.tokenTitle}</span>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
disabled={applying}
|
||||
onChange={event => {
|
||||
invalidateTest()
|
||||
setRemoteToken(event.target.value)
|
||||
}}
|
||||
placeholder={copy.pasteSessionToken}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{copy.tokenDesc}</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{success ? (
|
||||
<div className="flex items-center gap-2 text-sm text-primary">
|
||||
<Check className="size-4" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-wrap items-center justify-between gap-3">
|
||||
<Button disabled={applying} onClick={onBack} size="sm" variant="ghost">
|
||||
{copy.backToSetup}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={testing || applying || !canTest}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{copy.testConnection}
|
||||
</Button>
|
||||
<Button disabled={applying || !canApply} onClick={() => void applyRemote()} size="sm">
|
||||
{applying ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{copy.applyRemote}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
apps/desktop/src/global.d.ts
vendored
14
apps/desktop/src/global.d.ts
vendored
|
|
@ -211,6 +211,7 @@ declare global {
|
|||
onPowerResume?: (callback: () => void) => () => void
|
||||
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
|
||||
getBootstrapState: () => Promise<DesktopBootstrapState>
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '仕上げ中',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '正在收尾',
|
||||
|
|
|
|||
|
|
@ -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: '正在收尾',
|
||||
|
|
|
|||
50
apps/desktop/src/lib/desktop-remote-auth.test.ts
Normal file
50
apps/desktop/src/lib/desktop-remote-auth.test.ts
Normal file
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
26
apps/desktop/src/lib/desktop-remote-auth.ts
Normal file
26
apps/desktop/src/lib/desktop-remote-auth.ts
Normal file
|
|
@ -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(' / ')
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue