WIPipw wipwip

This commit is contained in:
ethernet 2026-06-26 22:07:54 -04:00
parent 4dce531189
commit 0a8d4da69a
14 changed files with 560 additions and 27 deletions

View file

@ -10,6 +10,7 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla
import { Pane, PaneMain } from '@/components/pane-shell'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { useMediaQuery } from '@/hooks/use-media-query'
import { isThinClient } from '@/lib/build-mode'
import { cn } from '@/lib/utils'
import { useSkinCommand } from '@/themes/use-skin-command'
@ -1108,7 +1109,7 @@ export function DesktopController() {
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
enabled={isThinClient() || gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()

View file

@ -0,0 +1,238 @@
// ─── Gateway connect form ───────────────────────────────────────────────
// Shown on thin-client first run (the only onboarding path) and on the
// thick client when the user picks "Connect to a remote gateway". Reuses
// the same desktop bridge IPC (probe / save / apply / oauth-login) as
// Settings → Gateway, but wrapped in the onboarding card chrome.
import { useEffect, useRef, useState } from "react"
import type { DesktopConnectionProbeResult } from "@/global"
import { useI18n } from "@/i18n"
import { AlertCircle, ChevronLeft, Globe, Loader2, LogIn } from "@/lib/icons"
import { gatewayOauthLogin, saveGatewayConnection } from "@/store/onboarding"
import { Button } from "./ui/button"
import { ErrorIcon } from "./ui/error-state"
import { Input } from "./ui/input"
type GatewayProbeStatus = 'idle' | 'probing' | 'done' | 'error'
function useGatewayProbe(url: string) {
const [status, setStatus] = useState<GatewayProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const seq = useRef(0)
useEffect(() => {
const trimmed = url.trim()
if (!trimmed || !/^https?:\/\//i.test(trimmed)) {
setStatus('idle')
setProbe(null)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
const current = ++seq.current
setStatus('probing')
const timer = setTimeout(() => {
desktop
.probeConnectionConfig(trimmed)
.then(result => {
if (seq.current !== current) {
return
}
setProbe(result)
setStatus(result.reachable ? 'done' : 'error')
})
.catch(() => {
if (seq.current !== current) {
return
}
setProbe(null)
setStatus('error')
})
}, 500)
return () => clearTimeout(timer)
}, [url])
return { status, probe }
}
export function GatewayConnectForm({ onBack }: { onBack: null | (() => void) }) {
const { t } = useI18n()
const g = t.onboarding.gateway
const [url, setUrl] = useState('')
const [token, setToken] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<'save' | 'signin' | null>(null)
const { status: probeStatus, probe } = useGatewayProbe(url)
const trimmedUrl = url.trim()
const hasUrl = Boolean(trimmedUrl) && /^https?:\/\//i.test(trimmedUrl)
// Effective auth mode: a reachable probe wins; otherwise fall back to
// 'token' so the token box is visible by default.
const authMode = probeStatus === 'done' && probe && probe.authMode !== 'unknown'
? probe.authMode
: 'token'
const authResolved = probeStatus === 'done'
const providers = probe?.providers ?? []
const providerLabel = providers.length === 1
? (providers[0].displayName || providers[0].name)
: providers.length > 1
? providers.map(p => p.displayName || p.name).join(' / ')
: g.identityProvider
const isPasswordProvider = providers.length > 0 && providers.every(p => p.supportsPassword)
const canSubmit = hasUrl && (authMode === 'oauth' ? false : Boolean(token.trim()))
const submit = async () => {
if (!hasUrl || busy) {
return
}
setBusy('save')
setError(null)
const result = await saveGatewayConnection(trimmedUrl, authMode, token.trim() || undefined)
if (!result.ok) {
setError(result.message ?? g.saveFailed)
setBusy(null)
}
// On success, applyConnectionConfig reloads the window — no need to clear busy.
}
const signIn = async () => {
if (!hasUrl || busy) {
return
}
setBusy('signin')
setError(null)
const result = await gatewayOauthLogin(trimmedUrl)
if (!result.ok) {
setError(result.message ?? g.signInFailed)
setBusy(null)
}
// On success, applyConnectionConfig reloads the window.
}
return (
<div className="grid gap-4">
{onBack ? (
<Button className="-mt-1 self-start font-medium" onClick={onBack} size="xs" type="button" variant="text">
<ChevronLeft className="size-3" />
{g.backToProviders}
</Button>
) : null}
<div className="grid gap-2">
<label className="text-sm font-medium text-muted-foreground" htmlFor="gw-url">
{g.urlLabel}
</label>
<Input
autoComplete="off"
autoFocus
className={`font-mono`}
// className={`font-mono ${hasUrl ? (probeStatus === 'probing' ? 'border-orange' : 'border-green') : ''}`}
id="gw-url"
onChange={e => setUrl(e.target.value)}
placeholder="https://gateway.example.com/hermes"
value={url}
/>
<p className="text-xs text-muted-foreground">{g.urlHint}</p>
</div>
{/* Probe status */}
{hasUrl && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{g.probing}
</div>
) : null}
{hasUrl && probeStatus === 'error' ? (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{g.probeError}
</div>
) : null}
{/* OAuth / password gateways: show sign-in button */}
{hasUrl && authResolved && authMode === 'oauth' ? (
<div className="grid gap-2">
<div className="flex items-center gap-2">
<Button disabled={Boolean(busy)} onClick={() => void signIn()}>
{busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{isPasswordProvider ? g.passwordHint : g.oauthHint(providerLabel)}
</p>
</div>
) : null}
{/* Token gateways: show token input */}
{hasUrl && authResolved && authMode === 'token' ? (
<div className="grid gap-2">
<label className="text-sm font-medium text-muted-foreground" htmlFor="gw-token">
{g.tokenLabel}
</label>
<Input
autoComplete="off"
className="font-mono"
id="gw-token"
onChange={e => setToken(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={g.tokenPlaceholder}
type="password"
value={token}
/>
<p className="text-xs text-muted-foreground">{g.tokenHint}</p>
</div>
) : null}
{/* While probing (or probe error) and no saved config, neither auth UI shows —
show a hint instead of a blank gap. */}
{hasUrl && !authResolved ? (
<p className="text-xs text-muted-foreground">{g.probing}</p>
) : null}
{error ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<ErrorIcon className="mt-0.5 shrink-0" size="0.875rem" />
{error}
</div>
) : null}
<div className="flex items-center justify-between gap-3 pt-1">
<div />
<Button disabled={!canSubmit || Boolean(busy)} onClick={() => void submit()}>
{busy === 'save' ? <Loader2 className="animate-spin" /> : <Globe />}
{busy === 'save' ? g.connecting : g.connect}
</Button>
</div>
</div>
)
}

View file

@ -38,10 +38,16 @@ export function BootFailureOverlay() {
const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
const visible = Boolean(boot.error) && !boot.running
// While first-run onboarding owns the picker/flow we let it surface its own
// progress; the recovery overlay is for hard failures, which it covers via a
// higher z-index regardless of onboarding state.
const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error'
// Thin client: when the gateway connect form is showing (no remote configured
// yet), suppress the boot-failure overlay — the "no remote" error is expected
// and the user is already looking at the form to fix it.
const suppressed =
(onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error') ||
onboarding.gatewayMode
useEffect(() => {
if (!visible) {

View file

@ -27,7 +27,8 @@ function setProviders(providers: OAuthProvider[]) {
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
} satisfies DesktopOnboardingState)
}
@ -51,7 +52,8 @@ afterEach(() => {
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
})
})

View file

@ -8,9 +8,11 @@ import { Codicon } from '@/components/ui/codicon'
import { ErrorIcon } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Loader } from '@/components/ui/loader'
import type { DesktopConnectionProbeResult } from '@/global'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Terminal } from '@/lib/icons'
import { isThinClient } from '@/lib/build-mode'
import { AlertCircle, Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, Globe, KeyRound, Loader2, LogIn, Terminal } from '@/lib/icons'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
@ -25,19 +27,24 @@ import {
DEFAULT_MANUAL_ONBOARDING_REASON,
DEFAULT_ONBOARDING_REASON,
dismissFirstRunOnboarding,
exitGatewayMode,
gatewayOauthLogin,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
recheckExternalSignin,
refreshOnboarding,
saveGatewayConnection,
saveOnboardingApiKey,
setOnboardingCode,
setOnboardingMode,
setOnboardingModel,
startGatewayOnboarding,
startProviderOAuth,
submitOnboardingCode
} from '@/store/onboarding'
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
import { GatewayConnectForm } from './OnboardingGatewayConnection'
interface DesktopOnboardingOverlayProps {
enabled: boolean
@ -267,7 +274,10 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
// The user chose "I'll choose a provider later" on first run. Stay out of the
// way on every subsequent launch — they re-enter via Settings → Providers
// (manual mode), which sets manual=true and bypasses this gate.
if (onboarding.firstRunSkipped && !onboarding.manual) {
// Thin client: never respect the skip — there's no "choose later" escape from
// the gateway form (it's the only path), and a skipped thin client has no
// working backend to fall back on.
if (onboarding.firstRunSkipped && !onboarding.manual && !isThinClient()) {
return null
}
@ -288,7 +298,9 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
// In manual mode the app is already configured, so the flow is "ready"
// immediately — no runtime gate needed. Otherwise wait for the readiness
// check (configured === false) before showing the picker.
const ready = onboarding.manual || (enabled && onboarding.configured === false)
// Gateway mode (thin client or user picked "remote gateway") is also ready
// immediately — there's no provider list to wait for.
const ready = onboarding.manual || onboarding.gatewayMode || (enabled && onboarding.configured === false)
const showPicker = flow.status === 'idle' || flow.status === 'success'
// The final "you're in" screen drops the card chrome and floats centered on
// the surface — same bare, cinematic treatment as the connecting overlay.
@ -332,7 +344,11 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
<div className="grid gap-3 p-5">
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? (
showPicker ? (
onboarding.gatewayMode && showPicker ? (
<GatewayConnectForm
onBack={isThinClient() ? null : exitGatewayMode}
/>
) : showPicker ? (
<Picker ctx={ctx} />
) : (
<FlowPanel ctx={ctx} flow={flow} leaving={leaving} onBegin={finalizeOnboarding} />
@ -397,6 +413,7 @@ function Header() {
)
}
export const FEATURED_ID = 'nous'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
@ -491,15 +508,27 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
In manual mode the overlay already has a close affordance, so the
"choose later" escape would be redundant — hide it. */}
{manual ? <span /> : <ChooseLaterLink />}
<Button
className="-mr-2 font-medium"
onClick={() => setOnboardingMode('apikey')}
size="xs"
type="button"
variant="text"
>
{t.onboarding.haveApiKey}
</Button>
<div className="flex items-center gap-2">
<Button
className="font-medium"
onClick={() => startGatewayOnboarding()}
size="xs"
type="button"
variant="text"
>
<Globe className="size-3" />
{t.onboarding.connectRemoteGateway}
</Button>
<Button
className="-mr-2 font-medium"
onClick={() => setOnboardingMode('apikey')}
size="xs"
type="button"
variant="text"
>
{t.onboarding.haveApiKey}
</Button>
</div>
</div>
</div>
)
@ -811,6 +840,10 @@ function FlowPanel({
return <DecodedLabel text={t.onboarding.connectedPicking(title)} />
}
if (flow.status === 'gateway_connected') {
return <Status>{t.onboarding.gateway.connecting}</Status>
}
if (flow.status === 'confirming_model') {
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
}

View file

@ -42,7 +42,8 @@ function resetStores() {
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
})
}

View file

@ -1735,7 +1735,27 @@ export const en: Translations = {
price: (input, output) => `${input} in / ${output} out per Mtok`,
change: 'Change',
startChatting: 'Begin',
docs: provider => `${provider} docs`
docs: provider => `${provider} docs`,
connectRemoteGateway: 'Connect to a remote gateway',
gateway: {
urlLabel: 'Gateway URL',
urlHint: 'Base URL for the remote Hermes gateway. Path prefixes are supported, e.g. /hermes.',
probing: 'Checking how this gateway authenticates…',
probeError: 'Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.',
signIn: 'Sign in',
signInWith: provider => `Sign in with ${provider}`,
passwordHint: 'This gateway uses a username and password. Sign in to authorize this app.',
oauthHint: provider => `This gateway uses OAuth. Sign in with ${provider} to authorize this app.`,
tokenLabel: 'Session token',
tokenPlaceholder: 'Paste session token',
tokenHint: 'The dashboard session token used for REST and WebSocket access.',
connect: 'Connect',
connecting: 'Connecting',
saveFailed: 'Could not save the gateway connection.',
signInFailed: 'Sign-in failed. Try again.',
backToProviders: 'Back to providers',
identityProvider: 'the gateway'
}
},
modelPicker: {

View file

@ -1859,7 +1859,27 @@ export const ja = defineLocale({
price: (input, output) => `${input} 入力 / ${output} 出力 per Mtok`,
change: '変更',
startChatting: '始める',
docs: provider => `${provider} ドキュメント`
docs: provider => `${provider} ドキュメント`,
connectRemoteGateway: 'リモートゲートウェイに接続',
gateway: {
urlLabel: 'ゲートウェイ URL',
urlHint: 'リモート Hermes ゲートウェイのベース URL。パスプレフィックス対応(例: /hermes)。',
probing: 'ゲートウェイの認証方式を確認中…',
probeError: 'ゲートウェイに接続できません。URL を確認してください — 応答後に認証方式が表示されます。',
signIn: 'サインイン',
signInWith: provider => `${provider} でサインイン`,
passwordHint: 'このゲートウェイはユーザー名とパスワードを使用します。サインインしてアプリを認証してください。',
oauthHint: provider => `このゲートウェイは OAuth を使用します。${provider} でサインインしてアプリを認証してください。`,
tokenLabel: 'セッショントークン',
tokenPlaceholder: 'セッショントークンを貼り付け',
tokenHint: 'REST および WebSocket アクセスに使用されるダッシュボードセッショントークン。',
connect: '接続',
connecting: '接続中',
saveFailed: 'ゲートウェイ接続を保存できませんでした。',
signInFailed: 'サインインに失敗しました。再試行してください。',
backToProviders: 'プロバイダーに戻る',
identityProvider: 'ゲートウェイ'
}
},
modelPicker: {

View file

@ -1393,6 +1393,26 @@ export interface Translations {
change: string
startChatting: string
docs: (provider: string) => string
connectRemoteGateway: string
gateway: {
urlLabel: string
urlHint: string
probing: string
probeError: string
signIn: string
signInWith: (provider: string) => string
passwordHint: string
oauthHint: (provider: string) => string
tokenLabel: string
tokenPlaceholder: string
tokenHint: string
connect: string
connecting: string
saveFailed: string
signInFailed: string
backToProviders: string
identityProvider: string
}
}
modelPicker: {

View file

@ -1797,7 +1797,27 @@ export const zhHant = defineLocale({
price: (input, output) => `${input} 輸入 / ${output} 輸出 每 Mtok`,
change: '變更',
startChatting: '開始',
docs: provider => `${provider} 文件`
docs: provider => `${provider} 文件`,
connectRemoteGateway: '連線到遠端閘道',
gateway: {
urlLabel: '閘道 URL',
urlHint: '遠端 Hermes 閘道的基礎 URL。支援路徑前綴,例如 /hermes。',
probing: '正在檢查閘道認證方式…',
probeError: '暫時無法連線到此閘道。請檢查 URL — 認證方式將在閘道回應後顯示。',
signIn: '登入',
signInWith: provider => `使用 ${provider} 登入`,
passwordHint: '此閘道使用使用者名稱和密碼。登入以授權此應用。',
oauthHint: provider => `此閘道使用 OAuth。使用 ${provider} 登入以授權此應用。`,
tokenLabel: '工作階段權杖',
tokenPlaceholder: '貼上工作階段權杖',
tokenHint: '用於 REST 和 WebSocket 存取的儀表板工作階段權杖。',
connect: '連線',
connecting: '正在連線',
saveFailed: '無法儲存閘道連線。',
signInFailed: '登入失敗。請重試。',
backToProviders: '返回提供商列表',
identityProvider: '閘道'
}
},
modelPicker: {

View file

@ -1909,7 +1909,27 @@ export const zh: Translations = {
price: (input, output) => `${input} 输入 / ${output} 输出每 Mtok`,
change: '更改',
startChatting: '开始',
docs: provider => `${provider} 文档`
docs: provider => `${provider} 文档`,
connectRemoteGateway: '连接到远程网关',
gateway: {
urlLabel: '网关地址',
urlHint: '远程 Hermes 网关的基础 URL。支持路径前缀,例如 /hermes。',
probing: '正在检查网关认证方式…',
probeError: '暂时无法连接到此网关。请检查 URL — 认证方式将在网关响应后显示。',
signIn: '登录',
signInWith: provider => `使用 ${provider} 登录`,
passwordHint: '此网关使用用户名和密码。登录以授权此应用。',
oauthHint: provider => `此网关使用 OAuth。使用 ${provider} 登录以授权此应用。`,
tokenLabel: '会话令牌',
tokenPlaceholder: '粘贴会话令牌',
tokenHint: '用于 REST 和 WebSocket 访问的仪表板会话令牌。',
connect: '连接',
connecting: '正在连接',
saveFailed: '无法保存网关连接。',
signInFailed: '登录失败。请重试。',
backToProviders: '返回提供商列表',
identityProvider: '网关'
}
},
modelPicker: {

View file

@ -35,6 +35,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
firstRunSkipped: false,
manual: false,
localEndpoint: false,
gatewayMode: false,
...overrides
}
}

View file

@ -12,6 +12,7 @@ import {
submitOAuthCode,
validateProviderCredential
} from '@/hermes'
import { isThinClient } from '@/lib/build-mode'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { notify, notifyError } from '@/store/notifications'
import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes'
@ -20,7 +21,7 @@ type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
export type OnboardingMode = 'apikey' | 'oauth'
export type OnboardingMode = 'apikey' | 'oauth' | 'gateway'
export type OnboardingFlow =
| { status: 'idle' }
@ -34,6 +35,14 @@ export type OnboardingFlow =
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' }
| {
// Gateway onboarding: a remote gateway was configured (URL + auth),
// and the connection was saved+applied. The overlay shows a brief
// "connected" confirm before completing — the apply already reloads
// the window, so this is mostly a transitional state.
gatewayUrl: string
status: 'gateway_connected'
}
| {
// After successful credential acquisition, before completing
// onboarding: show the user which model they're getting and let
@ -77,6 +86,11 @@ export interface DesktopOnboardingState {
* custom endpoint"). Forces the API-key form with the local option
* preselected instead of the OAuth picker. */
localEndpoint: boolean
/** True when the overlay should show the gateway-connection form instead
* of the provider picker. Set on thin-client builds (where
* there's no local backend to configure providers for), or when the user
* picks "Connect to a remote gateway" from the thick-client picker. */
gatewayMode: boolean
}
export interface OnboardingContext {
@ -156,7 +170,8 @@ const INITIAL: DesktopOnboardingState = {
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
}
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
@ -430,6 +445,105 @@ export function startManualLocalEndpoint(reason: null | string = null) {
})
}
// Open the onboarding overlay on the gateway-connection form. Used on the
// thin client's first run (no local backend → must configure a remote
// gateway), and on the thick client when the user picks "Connect to a
// remote gateway" from the provider picker. The form drives the same
// desktop bridge IPC (probe / save / apply / oauth-login / test) as the
// Settings → Gateway page — just wrapped in the onboarding chrome.
export function startGatewayOnboarding(reason: null | string = null) {
pendingProviderOAuthId = null
patch({
gatewayMode: true,
manual: false,
mode: 'gateway',
requested: true,
localEndpoint: false,
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
}
// Save + apply a remote gateway connection from the onboarding form. On
// success, `applyConnectionConfig` reloads the window — so we just set the
// transitional "gateway_connected" flow state and let the reload handle
// the rest. On failure, surface the error in the form.
export async function saveGatewayConnection(
url: string,
authMode: 'oauth' | 'token',
token: string | undefined
): Promise<{ ok: boolean; message?: string }> {
const trimmedUrl = url.trim()
if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
return { ok: false, message: 'Enter a valid gateway URL (https://…).' }
}
try {
await window.hermesDesktop?.saveConnectionConfig({
mode: 'remote',
remoteUrl: trimmedUrl,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? token?.trim() || undefined : undefined
})
// applyConnectionConfig restarts the backend / reloads the window.
await window.hermesDesktop?.applyConnectionConfig({
mode: 'remote',
remoteUrl: trimmedUrl,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? token?.trim() || undefined : undefined
})
setFlow({ status: 'gateway_connected', gatewayUrl: trimmedUrl })
return { ok: true }
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
return { ok: false, message: msg }
}
}
// Initiate OAuth sign-in against a remote gateway from the onboarding form.
// Mirrors GatewaySettings.signIn: save the URL + oauth mode first (so the login
// window has a target), then open the login window. On success, apply the
// connection which reloads the window.
export async function gatewayOauthLogin(
url: string
): Promise<{ ok: boolean; connected: boolean; message?: string }> {
const trimmedUrl = url.trim()
if (!trimmedUrl) {
return { ok: false, connected: false, message: 'Enter a gateway URL first.' }
}
try {
await window.hermesDesktop?.saveConnectionConfig({
mode: 'remote',
remoteUrl: trimmedUrl,
remoteAuthMode: 'oauth'
})
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(trimmedUrl)
if (result?.connected) {
await window.hermesDesktop?.applyConnectionConfig({
mode: 'remote',
remoteUrl: trimmedUrl,
remoteAuthMode: 'oauth'
})
setFlow({ status: 'gateway_connected', gatewayUrl: trimmedUrl })
return { ok: true, connected: true }
}
return { ok: false, connected: false, message: 'Sign-in was not completed.' }
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
return { ok: false, connected: false, message: msg }
}
}
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
@ -461,7 +575,7 @@ export function clearPendingProviderOAuth() {
export function closeManualOnboarding() {
pendingProviderOAuthId = null
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
patch({ manual: false, requested: false, localEndpoint: false, gatewayMode: false, flow: { status: 'idle' } })
}
export function completeDesktopOnboarding() {
@ -479,7 +593,8 @@ export function completeDesktopOnboarding() {
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
localEndpoint: false,
gatewayMode: false
})
}
@ -492,13 +607,21 @@ export function completeDesktopOnboarding() {
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, gatewayMode: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
patch({ mode })
}
// Exit the gateway connect form and return to the provider picker.
// Only used on the thick client where the user can go back from the
// gateway form to the provider list. On thin client the gateway form
// is the only option, so the back button is hidden.
export function exitGatewayMode() {
patch({ gatewayMode: false, mode: 'oauth', flow: { status: 'idle' } })
}
export async function refreshOnboarding(ctx: OnboardingContext) {
// Manual mode (user opened the selector from a working app): never
// auto-dismiss on runtime-ready — the whole point is to let them add /
@ -510,6 +633,34 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
return false
}
// Thin client: there's no local backend, so the runtime check always
// fails. Instead of showing the provider picker, check if a remote
// gateway is already configured. If it is, the boot will succeed and
// onCompleted fires. If not, show the gateway connect form.
if (isThinClient()) {
const config = await window.hermesDesktop?.getConnectionConfig?.().catch(() => null)
if (config?.mode === 'remote' && config.remoteUrl) {
// A remote gateway is configured — let the boot proceed. The gateway
// boot hook will either connect or surface a reauth failure.
completeDesktopOnboarding()
ctx.onCompleted?.()
return true
}
// No remote gateway configured — show the gateway connect form.
writeCachedConfigured(false)
patch({
configured: false,
gatewayMode: true,
mode: 'gateway',
reason: 'Connect to a remote gateway to get started.'
})
return false
}
const runtime = await checkRuntime(ctx)
if (runtime.ready) {

0
scripts/slice_tests.py Normal file
View file