fix(desktop): remove profile scoping from the Gateways settings page

The unified Gateways settings page (from the recent settings merge) still
carried the legacy per-profile gateway-override machinery: an "Applies to"
profile-chip scope switcher, a scope state machine threaded through load/
save/test/sign-in paths, inherit-mode ModeCard variants, and an SSH
remote-profile mapping row.

The page is machine-level gateway management: it decides which gateway
backends this desktop can connect to, and profiles are discovered FROM the
connected gateways. It must not be profile-scoped.

- Delete the scope chips section, ScopeChip component, and the scope/setScope
  state; every scope-conditional collapses to its global (scope === null)
  branch. getConnectionConfig/save/apply/test/sign-in are all unscoped now.
- ModeCard local card always renders the local title/desc (inherit variants
  gone); SSH remote-profile mapping row removed.
- i18n: drop now-unused gateway keys (appliesTo, allProfiles,
  defaultConnection, profileConnection, inheritTitle, inheritDesc,
  sshRemoteProfileTitle, sshRemoteProfileDesc) from types.ts and en/zh/
  zh-hant/ja/ar in sync; rewrite the gateway intro in each locale to say
  connections are machine-level and profiles come from gateways.
- Tests: replace the scope-switching component tests with a machine-level
  assertion (loads getConnectionConfig(null), never a profile scope, no
  scope UI rendered).
- Docs: update desktop.md and multi-connection-desktop.md wording — gateway
  connections are machine-level; per-profile backend routing continues via
  the profile rail / session source surfaces, not the settings page.

The electron main-process per-profile override mechanism
(getConnectionConfig(profileName), route map) and the profile-rail connect
flows are intentionally untouched; only the settings page loses the
affordance.
This commit is contained in:
Teknium
2026-08-17 18:05:25 -07:00
parent c9ce66e25e
commit 6170f844c4
10 changed files with 33 additions and 259 deletions
@@ -1,17 +1,8 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProfileInfo } from '@/types/hermes'
const getConnectionConfig = vi.fn()
const saveConnectionConfig = vi.fn()
const profiles = atom<ProfileInfo[]>([])
vi.mock('@/store/profile', () => ({
$profiles: profiles,
refreshActiveProfile: vi.fn()
}))
const localConnection = {
cloudOrg: '',
@@ -25,26 +16,6 @@ const localConnection = {
}
beforeEach(() => {
profiles.set([
{
has_env: false,
is_default: true,
model: null,
name: 'default',
path: '/tmp/hermes',
provider: null,
skill_count: 0
},
{
has_env: false,
is_default: false,
model: null,
name: 'work',
path: '/tmp/hermes/profiles/work',
provider: null,
skill_count: 0
}
])
getConnectionConfig.mockResolvedValue(localConnection)
saveConnectionConfig.mockResolvedValue(localConnection)
Object.defineProperty(window, 'hermesDesktop', {
@@ -59,7 +30,7 @@ afterEach(() => {
})
describe('GatewaySettings', () => {
it('labels local mode as default inheritance for a named profile', async () => {
it('loads the machine-level connection config (no profile scoping)', async () => {
const { GatewaySettings } = await import('./gateway-settings')
render(<GatewaySettings />)
@@ -68,54 +39,14 @@ describe('GatewaySettings', () => {
screen.getByText('Start a private Hermes backend on localhost. This is the default and works offline.')
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'work' }))
// The page manages the machine's gateway connections; it must load the
// global config, never a per-profile override.
await waitFor(() => expect(getConnectionConfig).toHaveBeenCalledWith(null))
expect(getConnectionConfig).not.toHaveBeenCalledWith(expect.any(String))
await waitFor(() => expect(getConnectionConfig).toHaveBeenLastCalledWith('work'))
expect(await screen.findByText('Use default gateway')).toBeTruthy()
expect(screen.getByText("Remove this profile's override and use the default connection.")).toBeTruthy()
expect(
screen.queryByText('Start a private Hermes backend on localhost. This is the default and works offline.')
).toBeNull()
})
it('shows and clears an SSH remote-profile mapping for a named Desktop profile', async () => {
getConnectionConfig.mockImplementation(async profile =>
profile === 'work'
? {
...localConnection,
mode: 'ssh',
profile: 'work',
sshHost: 'remote-box',
sshUser: 'alice',
sshPort: 22,
sshKeyPath: '',
sshRemoteHermesPath: '/opt/hermes/bin/hermes',
sshRemoteProfile: 'default'
}
: localConnection
)
saveConnectionConfig.mockReturnValue(new Promise(() => {}))
const { GatewaySettings } = await import('./gateway-settings')
render(<GatewaySettings />)
fireEvent.click(await screen.findByRole('button', { name: 'work' }))
await waitFor(() => expect(getConnectionConfig).toHaveBeenLastCalledWith('work'))
expect(await screen.findByText('Remote profile (optional)')).toBeTruthy()
const input = screen.getByPlaceholderText('work')
expect((input as HTMLInputElement).value).toBe('default')
fireEvent.change(input, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' }))
await waitFor(() =>
expect(saveConnectionConfig).toHaveBeenCalledWith(
expect.objectContaining({
profile: 'work',
sshRemoteProfile: ''
})
)
)
// The legacy per-profile scope switcher must not render.
expect(screen.queryByText('Applies to')).toBeNull()
expect(screen.queryByText('All profiles')).toBeNull()
expect(screen.queryByText('Use default gateway')).toBeNull()
})
})
@@ -1,4 +1,3 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -26,7 +25,6 @@ import { coerceRemoteUrlScheme } from '@/lib/remote-url'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { notify, notifyError, readableError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
import { ConnectionsRegistrySection } from './connections-registry'
import { CONTROL_TEXT } from './constants'
@@ -196,16 +194,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
setCloudOrgState(value)
}
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
const [scope, setScope] = useState<null | string>(null)
const profiles = useStore($profiles)
useEffect(() => {
void refreshActiveProfile()
}, [])
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
// its public /api/status) whether it gates with OAuth or a static session
// token, so we can show the right control (login button vs token box).
@@ -224,13 +212,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
setLoading(true)
// Clear scope-local entry state so a token from one scope can't leak into
// the next when switching profiles.
setRemoteToken('')
setLastTest(null)
desktop
.getConnectionConfig(scope)
.getConnectionConfig(null)
.then(config => {
if (cancelled) {
return
@@ -246,8 +230,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on scope change only; copy is stable
}, [scope])
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
}, [])
// Debounced probe of the entered remote URL. Only runs in remote mode with a
// syntactically plausible URL. The probe result drives whether we render the
@@ -368,10 +352,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
// The 'default' profile uses the global ("All profiles") connection, so the
// per-profile scopes are the named, non-default profiles.
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
useEffect(() => {
// One-directional: a saved host that isn't in the suggestions must render
// the free-text input (rehydration). Never force custom OFF here — that
@@ -414,7 +394,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
cloudConnectSeq.current += 1
setLastTest(null)
}, [
scope,
state.mode,
state.sshHost,
state.sshUser,
@@ -440,7 +419,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
const payload = (allowPlainTextToken?: boolean) => ({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl,
@@ -565,7 +543,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
@@ -583,7 +560,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
const refreshed = await window.hermesDesktop.getConnectionConfig(null)
acceptSavedConfig(refreshed)
notify({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) })
} else {
@@ -610,7 +587,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
const refreshed = await window.hermesDesktop.getConnectionConfig(null)
if (seq !== signingSeq.current) {
return
@@ -713,7 +690,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
void discoverCloud()
}
// On entering cloud mode (or scope change), read the portal session status and
// On entering cloud mode, read the portal session status and
// auto-discover when already signed in, so the picker is populated on open.
useEffect(() => {
if (state.mode !== 'cloud') {
@@ -762,8 +739,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only
}, [state.mode, scope])
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode change only
}, [state.mode])
const cloudSignIn = async () => {
const desktop = window.hermesDesktop
@@ -873,7 +850,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
// discovery in this same render tick is captured, not a stale null.
const next = await desktop.applyConnectionConfig({
mode: 'cloud',
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: agent.dashboardUrl,
cloudOrg: cloudOrgRef.current ?? undefined
@@ -1002,7 +978,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -1056,49 +1031,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
</div>
)}
{/* Per-profile gateway overrides: an explicit subsection (not an ambient
page-scope chip row) — pick which target the connection controls
below edit: the default connection or one named profile's override. */}
{namedProfiles.length > 0 ? (
<div className="mb-5 grid gap-1">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.profileOverridesTitle}
</div>
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{g.profileOverridesDesc}
</p>
<ListRow
action={
scope === null ? (
<Pill tone="primary">{g.overrideEditing}</Pill>
) : (
<Button aria-label={g.allProfiles} onClick={() => setScope(null)} size="sm" variant="outline">
{g.overrideEdit}
</Button>
)
}
description={g.defaultConnection}
title={g.allProfiles}
/>
{namedProfiles.map(profile => (
<ListRow
action={
scope === profile.name ? (
<Pill tone="primary">{g.overrideEditing}</Pill>
) : (
<Button aria-label={profile.name} onClick={() => setScope(profile.name)} size="sm" variant="outline">
{g.overrideEdit}
</Button>
)
}
description={scope === profile.name ? g.profileConnection(profile.name) : g.overrideSelectHint}
key={profile.name}
title={profile.name}
/>
))}
</div>
) : null}
{state.envOverride ? (
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
@@ -1116,11 +1048,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
<div className="grid auto-rows-fr grid-cols-1 gap-2 sm:grid-cols-2 min-[72rem]:grid-cols-4">
<ModeCard
active={state.mode === 'local'}
description={scope === null ? g.localDesc : g.inheritDesc}
description={g.localDesc}
disabled={state.envOverride}
icon={Monitor}
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={scope === null ? g.localTitle : g.inheritTitle}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'cloud'}
@@ -1499,20 +1431,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
description={g.sshHermesPathDesc}
title={g.sshHermesPathTitle}
/>
{scope !== null ? (
<ListRow
action={
<Input
className={cn('h-8 font-mono', CONTROL_TEXT)}
onChange={event => setState(current => ({ ...current, sshRemoteProfile: event.target.value }))}
placeholder={scope}
value={state.sshRemoteProfile}
/>
}
description={g.sshRemoteProfileDesc}
title={g.sshRemoteProfileTitle}
/>
) : null}
</div>
) : null}
+1 -13
View File
@@ -683,23 +683,11 @@ export const ar = defineLocale({
title: 'اتصال البوابة',
envOverride: 'تجاوز من البيئة',
intro:
'يشغّل Hermes Desktop بوابة محلية خاصة افتراضياً. استخدم بوابة بعيدة عندما تريد أن يتحكم هذا التطبيق بخلفية Hermes تعمل مسبقاً على جهاز آخر أو خلف وكيل موثوق.',
allProfiles: 'كل الملفات الشخصية',
defaultConnection: 'الاتصال الافتراضي لكل ملف شخصي لا يملك تجاوزاً خاصاً.',
profileConnection: profile =>
`الاتصال المستخدم فقط عندما يكون "${profile}" هو الملف الشخصي النشط. اختر "استخدام البوابة الافتراضية" لإزالة التجاوز الخاص به.`,
profileOverridesTitle: 'تجاوزات لكل ملف شخصي',
profileOverridesDesc:
'يمكن لكل ملف شخصي أن يشير إلى بوابته الخاصة. عناصر التحكم بالاتصال أدناه تحرر الهدف المحدد؛ بقية هذه الصفحة تنطبق على التطبيق بأكمله.',
overrideEdit: 'تحرير',
overrideEditing: 'قيد التحرير',
overrideSelectHint: 'اختر لعرض أو تغيير البوابة التي يستخدمها هذا الملف الشخصي.',
'يشغّل Hermes Desktop بوابة محلية خاصة افتراضياً. استخدم بوابة بعيدة عندما تريد أن يتحكم هذا التطبيق بخلفية Hermes تعمل مسبقاً على جهاز آخر أو خلف وكيل موثوق. اتصالات البوابة إعداد على مستوى الجهاز؛ ويتم اكتشاف الملفات الشخصية من البوابات المتصلة.',
envOverrideTitle: 'متغيرات البيئة تتحكم في جلسة سطح المكتب هذه.',
envOverrideDesc: 'أزل HERMES_DESKTOP_REMOTE_URL و HERMES_DESKTOP_REMOTE_TOKEN لاستخدام الإعداد المحفوظ أدناه.',
localTitle: 'بوابة محلية',
localDesc: 'تشغيل خلفية Hermes خاصة على localhost. هذا هو الافتراضي ويعمل دون اتصال.',
inheritTitle: 'استخدام البوابة الافتراضية',
inheritDesc: 'إزالة التجاوز الخاص بهذا الملف الشخصي واستخدام الاتصال الافتراضي.',
remoteTitle: 'بوابة بعيدة',
remoteDesc:
'صل واجهة سطح المكتب هذه بخلفية Hermes بعيدة. البوابات المستضافة تستخدم OAuth أو اسم مستخدم وكلمة مرور، والبوابات الذاتية قد تستخدم رمز جلسة.',
+1 -15
View File
@@ -715,25 +715,13 @@ export const en: Translations = {
title: 'Gateway Connection',
envOverride: 'env override',
intro:
'Local by default. Use remote when this app should drive a Hermes backend elsewhere. Per-profile overrides below.',
allProfiles: 'All profiles',
defaultConnection: 'Default connection for every profile that has no override of its own.',
profileConnection: profile =>
`Connection used only when “${profile}” is the active profile. Choose Use default gateway to remove its override.`,
profileOverridesTitle: 'Per-profile overrides',
profileOverridesDesc:
'Each profile can point at its own gateway. The connection controls below edit the selected target; everything else on this page is app-wide.',
overrideEdit: 'Edit',
overrideEditing: 'Editing',
overrideSelectHint: 'Select to view or change the gateway this profile uses.',
'Local by default. Use remote when this app should drive a Hermes backend elsewhere. Gateway connections are machine-level; profiles are discovered from the gateways you connect.',
envOverrideTitle: 'Environment variables are controlling this desktop session.',
envOverrideDesc:
'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.',
modeTitle: 'Connection mode',
localTitle: 'Local gateway',
localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.',
inheritTitle: 'Use default gateway',
inheritDesc: "Remove this profile's override and use the default connection.",
remoteTitle: 'Remote gateway',
remoteDesc: 'Connect this desktop shell to a remote Hermes backend.',
remoteAuthHint: 'Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
@@ -840,8 +828,6 @@ export const en: Translations = {
sshHermesPathTitle: 'Hermes path (optional)',
sshHermesPathDesc: 'Full path to the remote hermes binary. Blank = auto-detect.',
sshHermesPathPlaceholder: 'auto-detect',
sshRemoteProfileTitle: 'Remote profile (optional)',
sshRemoteProfileDesc: 'Profile name on the remote host. Blank = use the Desktop profile name.',
sshTestConnection: 'Test SSH',
sshConnect: 'Connect',
sshButtonsHint: 'Save applies on the next launch. Connect reconnects now.',
+1 -15
View File
@@ -729,25 +729,13 @@ export const ja = defineLocale({
title: 'ゲートウェイ接続',
envOverride: 'env オーバーライド',
intro:
'Hermes Desktop はデフォルトで独自のローカルゲートウェイを起動します。別のマシンや信頼できるプロキシの背後で既に動作している Hermes バックエンドをこのアプリで制御する場合は、リモートゲートウェイを使用してください。以下でプロファイルを選択して、それぞれのリモートホストを設定します。',
allProfiles: 'すべてのプロファイル',
defaultConnection: '独自のオーバーライドがないすべてのプロファイルのデフォルト接続。',
profileConnection: profile =>
`"${profile}" がアクティブプロファイルのときのみ使用される接続。「デフォルトゲートウェイを使用」を選ぶとオーバーライドが削除されます。`,
profileOverridesTitle: 'プロファイルごとのオーバーライド',
profileOverridesDesc:
'各プロファイルは独自のゲートウェイを指定できます。下の接続コントロールは選択した対象を編集します。このページのその他の設定はアプリ全体に適用されます。',
overrideEdit: '編集',
overrideEditing: '編集中',
overrideSelectHint: '選択すると、このプロファイルが使用するゲートウェイを表示・変更できます。',
'Hermes Desktop はデフォルトで独自のローカルゲートウェイを起動します。別のマシンや信頼できるプロキシの背後で既に動作している Hermes バックエンドをこのアプリで制御する場合は、リモートゲートウェイを使用してください。ゲートウェイ接続はマシン単位の設定で、プロファイルは接続したゲートウェイから検出されます。',
envOverrideTitle: '環境変数がこのデスクトップセッションを制御しています。',
envOverrideDesc:
'保存された設定を使用するには HERMES_DESKTOP_REMOTE_URL と HERMES_DESKTOP_REMOTE_TOKEN の設定を解除してください。',
localTitle: 'ローカルゲートウェイ',
localDesc:
'ローカルホストでプライベートな Hermes バックエンドを起動します。これがデフォルトで、オフラインでも動作します。',
inheritTitle: 'デフォルトゲートウェイを使用',
inheritDesc: 'このプロファイルのオーバーライドを削除し、デフォルト接続を使用します。',
remoteTitle: 'リモートゲートウェイ',
remoteDesc:
'このデスクトップシェルをリモートの Hermes バックエンドに接続します。ホスト型ゲートウェイは OAuth またはユーザー名とパスワードを使用します。自己ホスト型はセッショントークンを使用する場合があります。',
@@ -828,8 +816,6 @@ export const ja = defineLocale({
sshHermesPathTitle: 'Hermes パス(任意)',
sshHermesPathDesc: 'リモートの hermes バイナリへのフルパス。空欄 = 自動検出。',
sshHermesPathPlaceholder: '自動検出',
sshRemoteProfileTitle: 'リモートプロファイル(任意)',
sshRemoteProfileDesc: 'リモートホスト上のプロファイル名。空欄 = Desktop のプロファイル名を使用。',
sshTestConnection: 'SSH をテスト',
sshConnect: '接続',
sshButtonsHint: '「保存」は次回起動時に適用され、「接続」は今すぐ再接続します。',
-12
View File
@@ -601,21 +601,11 @@ export interface Translations {
title: string
envOverride: string
intro: string
allProfiles: string
defaultConnection: string
profileConnection: (profile: string) => string
profileOverridesTitle: string
profileOverridesDesc: string
overrideEdit: string
overrideEditing: string
overrideSelectHint: string
envOverrideTitle: string
envOverrideDesc: string
modeTitle: string
localTitle: string
localDesc: string
inheritTitle: string
inheritDesc: string
remoteTitle: string
remoteDesc: string
remoteAuthHint: string
@@ -714,8 +704,6 @@ export interface Translations {
sshHermesPathTitle: string
sshHermesPathDesc: string
sshHermesPathPlaceholder: string
sshRemoteProfileTitle: string
sshRemoteProfileDesc: string
sshTestConnection: string
sshConnect: string
sshButtonsHint: string
+1 -14
View File
@@ -714,22 +714,11 @@ export const zhHant = defineLocale({
title: '閘道連線',
envOverride: '環境變數覆寫',
intro:
'Hermes Desktop 預設會啟動自己的本機閘道。如果您希望此應用程式控制另一台機器或可信代理後面已執行的 Hermes 後端,請使用遠端閘道。在下方按設定檔指定各自的遠端主機。',
allProfiles: '全部設定檔',
defaultConnection: '預設連線適用於所有沒有自訂覆寫的設定檔。',
profileConnection: profile => `僅當「${profile}」為作用中設定檔時使用此連線。選擇「使用預設閘道」可移除其覆寫。`,
profileOverridesTitle: '依設定檔覆寫',
profileOverridesDesc:
'每個設定檔都可以指向自己的閘道。下方的連線控制項會編輯所選目標;此頁其餘設定為整個應用程式範圍。',
overrideEdit: '編輯',
overrideEditing: '編輯中',
overrideSelectHint: '選取即可檢視或變更此設定檔使用的閘道。',
'Hermes Desktop 預設會啟動自己的本機閘道。如果您希望此應用程式控制另一台機器或可信代理後面已執行的 Hermes 後端,請使用遠端閘道。閘道連線屬於本機層級設定;設定檔是從已連線的閘道中探索出來的。',
envOverrideTitle: '環境變數正在控制此桌面工作階段。',
envOverrideDesc: '取消設定 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 後才會使用下方儲存的設定。',
localTitle: '本機閘道',
localDesc: '在 localhost 啟動私有 Hermes 後端。這是預設方式,可離線使用。',
inheritTitle: '使用預設閘道',
inheritDesc: '移除此設定檔的自訂覆寫並使用預設連線。',
remoteTitle: '遠端閘道',
remoteDesc:
'將此桌面殼層連線至遠端 Hermes 後端。託管閘道使用 OAuth 或帳號密碼;自託管閘道也可使用工作階段 Token。',
@@ -804,8 +793,6 @@ export const zhHant = defineLocale({
sshHermesPathTitle: 'Hermes 路徑(選用)',
sshHermesPathDesc: '遠端 hermes 執行檔的完整路徑。留空 = 自動偵測。',
sshHermesPathPlaceholder: '自動偵測',
sshRemoteProfileTitle: '遠端設定檔(選用)',
sshRemoteProfileDesc: '遠端主機上的設定檔名稱。留空 = 使用 Desktop 設定檔名稱。',
sshTestConnection: '測試 SSH',
sshConnect: '連線',
sshButtonsHint: '「儲存」會在下次啟動時生效,「連線」則立即重新連線。',
+1 -13
View File
@@ -918,22 +918,12 @@ export const zh: Translations = {
title: '网关连接',
envOverride: '环境变量覆盖',
intro:
'Hermes Desktop 默认会启动自己的本地网关。当你希望此应用控制另一台机器上或可信代理后的现有 Hermes 后端时,可以使用远程网关。下面可按 profile 指定各自的远程主机。',
allProfiles: '所有 profile',
defaultConnection: '默认连接会用于所有没有自定义覆盖的 profile。',
profileConnection: profile => `仅当“${profile}”是当前 profile 时使用此连接。选择“使用默认网关”可移除其覆盖。`,
profileOverridesTitle: '按 profile 覆盖',
profileOverridesDesc: '每个 profile 都可以指向自己的网关。下面的连接控件编辑所选目标;此页其余设置为全应用范围。',
overrideEdit: '编辑',
overrideEditing: '正在编辑',
overrideSelectHint: '选择以查看或更改此 profile 使用的网关。',
'Hermes Desktop 默认会启动自己的本地网关。当你希望此应用控制另一台机器上或可信代理后的现有 Hermes 后端时,可以使用远程网关。网关连接属于本机级设置;profile 是从所连接的网关中发现的。',
envOverrideTitle: '环境变量正在控制此桌面会话。',
envOverrideDesc: '取消设置 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 后才会使用下面保存的设置。',
modeTitle: '连接模式',
localTitle: '本地网关',
localDesc: '在 localhost 启动私有 Hermes 后端。这是默认方式,并且可离线工作。',
inheritTitle: '使用默认网关',
inheritDesc: '移除此 profile 的自定义覆盖并使用默认连接。',
remoteTitle: '远程网关',
remoteDesc: '将此桌面外壳连接到远程 Hermes 后端。',
remoteAuthHint: '托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
@@ -1039,8 +1029,6 @@ export const zh: Translations = {
sshHermesPathTitle: 'Hermes 路径(可选)',
sshHermesPathDesc: '远程 hermes 可执行文件的完整路径。留空 = 自动检测。',
sshHermesPathPlaceholder: '自动检测',
sshRemoteProfileTitle: '远程配置文件(可选)',
sshRemoteProfileDesc: '远程主机上的配置文件名称。留空 = 使用 Desktop 配置文件名称。',
sshTestConnection: '测试 SSH',
sshConnect: '连接',
sshButtonsHint: '“保存”将在下次启动时生效,“连接”则立即重新连接。',
+2 -2
View File
@@ -295,7 +295,7 @@ Everything connection-related lives on one settings page: **Settings → Gateway
- **Remote gateway** — enter the URL of a `hermes serve` backend you run yourself and sign in. This is the mode the rest of this section walks through.
- **Hermes Cloud** — sign in once to Hermes Cloud and pick from the agents on your account; no URL to paste. The app discovers your agents (with an organization picker if your account spans several orgs), and connecting to one switches the session over automatically. The status bar shows the cloud connection while it's active.
Connection modes are configured **per profile** — the page's **Per-profile overrides** subsection lists the default connection and each named profile, with an **Edit** affordance per row, so one profile can point at a remote or cloud backend while others stay local (**Use default gateway** removes an override).
Gateway connections are **machine-level**: the Gateways page manages which gateway backends this desktop can connect to, and profiles are discovered *from* the gateways you connect. Per-profile backend routing continues to work — it lives in the profile rail's connect flow and the session source switcher, not in the settings page.
### The multi-connection registry
@@ -304,7 +304,7 @@ Further down the same **Settings → Gateways** page, the connections registry m
- **Every connection needs a unique name** (a device name such as "Homelab" or "Work laptop"). When the same profile name exists on several registered sources, surfaces disambiguate it as `@profile-device` (e.g. `@research-homelab`).
- **Add / edit / remove / test** connections from the panel. The **Add** flow offers all four kinds — **Local**, **Hermes Cloud**, **Remote gateway**, and **SSH** (the Local button is disabled while the app-managed local entry exists, and a hint points cloud adds at the sign-in/discovery flow above). The local entry is managed by the app and cannot be removed. **Test** probes the connection's own HTTP and WebSocket legs directly.
- **Duplicates are rejected at save time**: only one **local** entry ever; remote and cloud entries are deduplicated on the normalized URL (trimmed, trailing slashes stripped, lowercased — across both kinds); SSH entries on the normalized `user@host:port` plus remote profile.
- Existing settings are **imported automatically** the first time you run a build with the registry: your current global connection and any per-profile overrides become named entries. The legacy settings file is left untouched, so older builds keep working.
- Existing settings are **imported automatically** the first time you run a build with the registry: your current global connection and any legacy per-profile overrides become named entries. The legacy settings file is left untouched, so older builds keep working.
- Tokens are stored encrypted with the OS keyring (with an explicit plain-text opt-in on keyring-less Linux).
Side-by-side routing is live: each registered source dials its own backends and sockets on demand (keyed per connection + profile), the plugin SDK exposes the union agent roster (`host.agents()` / `host.ensureAgent()`), and **Update all instances** on the Gateways page dispatches `hermes update` to every eligible source at once — Hermes Cloud entries are skipped (the platform updates them), and each instance reports its own result.
@@ -23,7 +23,7 @@ redirect there). Three doors lead to it:
- **Settings → Gateways** — the page itself (**Cmd/Ctrl+,**, then
**Gateways** in the settings nav). The connections registry is a section
of that page, below the connection-mode and per-profile override controls.
of that page, below the machine-level connection-mode controls.
- **The sidebar profile rail** — the plug button at the right end of the rail
(tooltip: **"Connect another Hermes gateway…"**) deep-links straight to
the Gateways page. It is always visible, even before you have created
@@ -121,9 +121,11 @@ and Tailscale guidance.
### Migrating from the single-connection settings
The first launch of a registry-capable build imports your existing settings
automatically: the global connection mode and any per-profile overrides from
the old Gateway settings become named registry entries (deduplicated by
URL/host).
automatically: the global connection mode and any legacy per-profile
overrides from Settings → Gateway become named registry entries (deduplicated
by URL/host). (Newer builds no longer offer per-profile overrides in the
Gateways settings page — gateway connections are machine-level, and profiles
are discovered from the gateways you connect.)
The legacy settings file is left untouched, so older builds on the same
machine keep working. If a migrated name collided, it was suffixed
(`Homelab 2`).