The desktop remote-gateway settings now auto-detect whether a gateway
authenticates with OAuth or a static session token and present the
matching UI + connection mechanism.
Detection: an unauthenticated GET {base}/api/status reads auth_required
(true => OAuth, false => session token); /api/auth/providers supplies the
provider label. The settings UI debounce-probes the entered URL and shows
either a 'Sign in with <provider>' button or the session-token box.
OAuth connection mechanism:
- REST is authed by the HttpOnly session cookie held in a persistent
Electron session partition (persist:hermes-remote-oauth); main-process
REST routes through electron net bound to that partition so the cookie
attaches automatically.
- Login opens a BrowserWindow on {base}/login in that partition and
resolves once the hermes_session_at cookie lands.
- WebSocket upgrades use a single-use ?ticket= minted at
POST /api/auth/ws-ticket (the gateway rejects ?token= in gated mode);
getGatewayWsUrl() re-mints before every (re)connect since tickets are
single-use and short-lived.
- Missing cookie / 401 surfaces needsOauthLogin to prompt re-sign-in
(Nous Portal contract v1 issues no refresh token).
Local and token modes are unchanged.
Pure helpers (URL normalize, ws-url token/ticket builders, auth-mode
classify/resolve, cookie detector) are extracted to a standalone
connection-config.cjs (no electron import) and unit-tested with
node --test (26 tests), matching the backend-probes.cjs pattern.
98 lines
2.5 KiB
TypeScript
98 lines
2.5 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { useCallback, useEffect, useRef } from 'react'
|
|
|
|
import type { HermesGateway } from '@/hermes'
|
|
import { $gatewayState, setConnection } from '@/store/session'
|
|
|
|
export function useGatewayRequest() {
|
|
const gatewayState = useStore($gatewayState)
|
|
const gatewayRef = useRef<HermesGateway | null>(null)
|
|
|
|
const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>(
|
|
null
|
|
)
|
|
|
|
const gatewayStateRef = useRef(gatewayState)
|
|
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
|
|
|
|
useEffect(() => {
|
|
gatewayStateRef.current = gatewayState
|
|
}, [gatewayState])
|
|
|
|
const ensureGatewayOpen = useCallback(async () => {
|
|
const existing = gatewayRef.current
|
|
|
|
if (!existing) {
|
|
return null
|
|
}
|
|
|
|
if (gatewayStateRef.current === 'open') {
|
|
return existing
|
|
}
|
|
|
|
if (reconnectingRef.current) {
|
|
return reconnectingRef.current
|
|
}
|
|
|
|
reconnectingRef.current = (async () => {
|
|
const desktop = window.hermesDesktop
|
|
|
|
if (!desktop) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const conn = await desktop.getConnection()
|
|
connectionRef.current = conn
|
|
setConnection(conn)
|
|
// Re-mint the WS URL before reconnecting — OAuth tickets are single-use
|
|
// and short-lived, so the cached conn.wsUrl ticket is stale here.
|
|
const wsUrl = (await desktop.getGatewayWsUrl?.().catch(() => null)) || conn.wsUrl
|
|
await existing.connect(wsUrl)
|
|
|
|
return existing
|
|
} catch {
|
|
connectionRef.current = null
|
|
setConnection(null)
|
|
|
|
return null
|
|
} finally {
|
|
reconnectingRef.current = null
|
|
}
|
|
})()
|
|
|
|
return reconnectingRef.current
|
|
}, [])
|
|
|
|
const requestGateway = useCallback(
|
|
async <T>(method: string, params: Record<string, unknown> = {}) => {
|
|
const gateway = gatewayRef.current
|
|
|
|
if (!gateway) {
|
|
throw new Error('Hermes gateway unavailable')
|
|
}
|
|
|
|
try {
|
|
return await gateway.request<T>(method, params)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
|
|
if (!/not connected|connection closed/i.test(message)) {
|
|
throw error
|
|
}
|
|
|
|
const recovered = await ensureGatewayOpen()
|
|
|
|
if (!recovered) {
|
|
throw error
|
|
}
|
|
|
|
return recovered.request<T>(method, params)
|
|
}
|
|
},
|
|
[ensureGatewayOpen]
|
|
)
|
|
|
|
return { connectionRef, gatewayRef, requestGateway }
|
|
}
|