Add first-class profile support to the desktop app without app reloads. - Swap the single live gateway onto a session's profile lazily (spawned on demand by the Electron backend pool), so one backend serves the active profile and others stay cold — no OOM with many profiles. - Aggregate sessions across profiles by reading each profile's state.db read-only; unified "All profiles" view groups sessions per profile with per-profile pagination, while the default view stays scoped to one profile. - Add an Arc-style profile rail at the sidebar foot: a default<->all toggle pinned left, colored named-profile squares scrolling between, Manage pinned right. Profile identity is a deterministic per-name color. - Route profile-scoped REST (config/env/skills/tools/model) to the active gateway profile and invalidate React Query caches on swap. Single-profile users never trigger a swap, so their path is unchanged. Backend: - web_server: profile-aware active/list endpoints + per-profile session totals; hermes_state: session_count(exclude_children); main.py: honor --profile over HERMES_HOME env for pooled backends. UI primitives: - Add a position-aware Tip tooltip (instant, themed) as a drop-in for native title=, and strip redundant tooltips from self-descriptive chrome.
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
import type { HermesConnection } from '@/global'
|
|
|
|
/**
|
|
* The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket
|
|
* URL immediately before every `gateway.connect()`. For OAuth-gated remote
|
|
* gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked
|
|
* into the cached `conn.wsUrl` is stale (and, after the first connect, already
|
|
* consumed). For local/token gateways the URL carries a long-lived token and
|
|
* never needs re-minting.
|
|
*
|
|
* Resolution rules:
|
|
*
|
|
* - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall
|
|
* back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to
|
|
* fail with an opaque "connection closed" error. Instead, let the mint error
|
|
* propagate so the caller can surface the gateway's reauth message
|
|
* ("session has expired… Sign in again").
|
|
*
|
|
* - token / local, or when the preload method is genuinely absent (older
|
|
* preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so
|
|
* the fallback is safe and preserves compatibility.
|
|
*
|
|
* The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so
|
|
* callers can distinguish "the user must re-authenticate" from a generic
|
|
* transport failure.
|
|
*/
|
|
export interface ResolveGatewayWsUrlDeps {
|
|
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The
|
|
* optional profile selects which backend to mint for — critical when swapping
|
|
* to a pooled profile, since the default mint resolves the primary backend. */
|
|
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
|
|
}
|
|
|
|
export class GatewayReauthRequiredError extends Error {
|
|
readonly needsOauthLogin = true
|
|
|
|
constructor(message: string, options?: { cause?: unknown }) {
|
|
super(message, options)
|
|
this.name = 'GatewayReauthRequiredError'
|
|
}
|
|
}
|
|
|
|
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
|
|
return (
|
|
error instanceof GatewayReauthRequiredError ||
|
|
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
|
|
)
|
|
}
|
|
|
|
export async function resolveGatewayWsUrl(
|
|
desktop: ResolveGatewayWsUrlDeps,
|
|
conn: Pick<HermesConnection, 'authMode' | 'profile' | 'wsUrl'>
|
|
): Promise<string> {
|
|
const mint = desktop.getGatewayWsUrl
|
|
// Mint for THIS connection's profile, not the primary. Without it a pooled
|
|
// profile swap re-mints the default backend's URL and connects to the wrong
|
|
// backend.
|
|
const profile = conn.profile ?? null
|
|
|
|
if (conn.authMode === 'oauth') {
|
|
if (!mint) {
|
|
// OAuth gateway but no way to mint a fresh ticket: the cached ticket is
|
|
// dead, so connecting with it cannot succeed. Surface a reauth error
|
|
// rather than silently attempting a doomed connect.
|
|
throw new GatewayReauthRequiredError(
|
|
'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.'
|
|
)
|
|
}
|
|
|
|
try {
|
|
return await mint(profile)
|
|
} catch (error) {
|
|
throw new GatewayReauthRequiredError(
|
|
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
|
|
{ cause: error }
|
|
)
|
|
}
|
|
}
|
|
|
|
// token / local: the URL carries a long-lived token. Re-mint when available
|
|
// (cheap, keeps parity), but the cached URL is a safe fallback.
|
|
if (mint) {
|
|
const fresh = await mint(profile).catch(() => null)
|
|
|
|
if (fresh) {
|
|
return fresh
|
|
}
|
|
}
|
|
|
|
return conn.wsUrl
|
|
}
|