Files
aiturk-hermes-ide/apps/desktop/src/store/profile-remote-override.ts
T

133 lines
4.5 KiB
TypeScript

import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
import { notify } from '@/store/notifications'
// ── Per-profile remote overrides (connection.json `profiles.<name>`) ────────
// The Electron main already routes a profile with a remote entry to its own
// pooled backend (profileRemoteOverride → resolveProfileBackendRoute case 2);
// until now the only way to WRITE that entry was hand-editing connection.json
// (#91349). This store is the renderer-side cache of which profiles carry an
// override, so the rail can badge them and the dialog can be opened from
// anywhere (context menu, auth-failure toast) without threading callbacks.
//
// Deliberately no import from store/profile: profile.ts imports THIS module
// for the auth-failure toast, so the dependency must stay one-way.
export interface ProfileRemoteOverride {
/** hostname[:port] parsed from the override URL — the display label. */
host: string
url: string
}
/** profile key → its remote override, refreshed from Electron on demand. */
export const $profileRemoteOverrides = atom<Record<string, ProfileRemoteOverride>>({})
// The profile whose "Connect to a remote host" dialog is open, or null. An
// atom (not component state) so the rail's context menu AND the re-enter-token
// toast action can both open the same dialog.
export const $remoteOverrideDialogProfile = atom<null | string>(null)
export function openRemoteOverrideDialog(profile: string): void {
$remoteOverrideDialogProfile.set(profile)
}
export function closeRemoteOverrideDialog(): void {
$remoteOverrideDialogProfile.set(null)
}
/** hostname[:port] for a gateway URL, or '' when it does not parse. */
export function remoteHostLabel(url: string): string {
try {
const parsed = new URL(String(url || ''))
if (!parsed.hostname) {
return ''
}
return parsed.port && parsed.port !== '80' && parsed.port !== '443'
? `${parsed.hostname}:${parsed.port}`
: parsed.hostname
} catch {
return ''
}
}
/**
* Re-pull each named profile's connection scope from Electron and publish the
* ones that resolve to a remote/cloud override. Best-effort per profile: a
* single failed read keeps that profile unbadged rather than failing the lot.
*/
export async function refreshProfileRemoteOverrides(names: string[]): Promise<void> {
const getConnectionConfig = window.hermesDesktop?.getConnectionConfig
if (!getConnectionConfig) {
return
}
const next: Record<string, ProfileRemoteOverride> = {}
await Promise.all(
names.map(async name => {
const key = String(name || '').trim()
if (!key) {
return
}
try {
const config = await getConnectionConfig(key)
if ((config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl) {
next[key] = { host: remoteHostLabel(config.remoteUrl) || config.remoteUrl, url: config.remoteUrl }
}
} catch {
// Backend bridge hiccup — leave this profile unbadged until the next refresh.
}
})
)
$profileRemoteOverrides.set(next)
}
// The token-rotation failure shape: the remote host answered but refused the
// credentials. Mirrors the auth-vocabulary used by suggestion-providers/repair
// — connectivity failures (timeout, refused, DNS) stay generic so a down host
// doesn't misread as a revoked token.
const AUTH_FAILURE_RE =
/\b(401|403|unauthorized|forbidden)\b|invalid[_ ]?token|token .*(expired|invalid|rejected)|authenticat\w+ (failed|required|expired)/i
/**
* When switching to a profile with a remote override fails because the host
* rejected the saved token (rotated/revoked), surface a re-enter-token toast
* whose action reopens the override dialog — never a silently dead profile.
* Returns true when the toast was shown (callers keep their generic handling
* for everything else).
*/
export function notifyRemoteOverrideAuthFailure(profile: string, error: unknown): boolean {
const key = String(profile || '').trim()
const override = key ? $profileRemoteOverrides.get()[key] : undefined
if (!override) {
return false
}
const message = error instanceof Error ? error.message : String(error ?? '')
if (!AUTH_FAILURE_RE.test(message)) {
return false
}
notify({
kind: 'error',
title: translateNow('profiles.remoteOverride.authFailedTitle'),
message: translateNow('profiles.remoteOverride.authFailedMessage', key, override.host),
action: {
label: translateNow('profiles.remoteOverride.updateToken'),
onClick: () => openRemoteOverrideDialog(key)
}
})
return true
}