From 2b105ea112fe8e10af44d004a430510d4da072e9 Mon Sep 17 00:00:00 2001 From: "Bing.Z" Date: Thu, 9 Jul 2026 15:55:32 +0800 Subject: [PATCH 1/5] feat(runtime): show and edit Remote Orca Server connection details List preferred endpoints with Tailscale/Direct labels, and allow renaming or re-pairing a saved server in place so workspaces keep the same id. --- src/main/ipc/runtime-environments.test.ts | 43 ++++ src/main/ipc/runtime-environments.ts | 38 +++- src/preload/api-types.ts | 8 + src/preload/index.ts | 10 + .../settings/RuntimeEnvironmentsPane.tsx | 190 +++++++++++++++-- .../settings/RuntimeServerEditDialog.tsx | 192 ++++++++++++++++++ .../runtime-server-endpoint-labels.ts | 21 ++ src/renderer/src/web/web-preload-api.ts | 51 +++++ ...ntime-environment-endpoint-display.test.ts | 70 +++++++ .../runtime-environment-endpoint-display.ts | 19 ++ src/shared/runtime-environment-store.test.ts | 60 +++++- src/shared/runtime-environment-store.ts | 39 ++++ 12 files changed, 725 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/settings/RuntimeServerEditDialog.tsx create mode 100644 src/renderer/src/components/settings/runtime-server-endpoint-labels.ts create mode 100644 src/shared/runtime-environment-endpoint-display.test.ts create mode 100644 src/shared/runtime-environment-endpoint-display.ts diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 3dbcac7a7ec..f86f582342c 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -121,6 +121,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:rename', + 'runtimeEnvironments:updateFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', @@ -140,6 +142,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([ 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:rename', + 'runtimeEnvironments:updateFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', @@ -217,6 +221,45 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }]) }) + it('renames a saved server and re-pairs its connection without changing id', async () => { + registerRuntimeEnvironmentHandlers(store as never) + + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string; endpoints: { endpoint: string }[] } } + >('runtimeEnvironments:addFromPairingCode') + const added = await add(null, { + name: 'desk', + pairingCode: pairingCode('ws://192.168.1.10:6768') + }) + + const rename = handler< + { selector: string; name: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:rename') + expect(await rename(null, { selector: added.environment.id, name: 'lab' })).toMatchObject({ + environment: { id: added.environment.id, name: 'lab' } + }) + + const update = handler< + { selector: string; pairingCode: string }, + { environment: { id: string; name: string; endpoints: { endpoint: string }[] } } + >('runtimeEnvironments:updateFromPairingCode') + const updated = await update(null, { + selector: added.environment.id, + pairingCode: pairingCode('ws://100.64.0.5:6768') + }) + expect(updated).toMatchObject({ + environment: { + id: added.environment.id, + name: 'lab', + endpoints: [{ endpoint: 'ws://100.64.0.5:6768' }] + } + }) + expect(JSON.stringify(updated)).not.toContain('device-token') + expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) + }) + it('marks environments owned by ephemeral VM runtimes in the public list', async () => { registerRuntimeEnvironmentHandlers(store as never) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 0c1aca30cd5..240d38937e0 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -4,7 +4,9 @@ import { addEnvironmentFromPairingCode, listEnvironments, removeEnvironment, - resolveEnvironment + renameEnvironment, + resolveEnvironment, + updateEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' import { redactRuntimeEnvironment, @@ -27,6 +29,8 @@ import { const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', + 'runtimeEnvironments:rename', + 'runtimeEnvironments:updateFromPairingCode', 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', @@ -86,6 +90,38 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args)) }) ) + ipcMain.handle( + 'runtimeEnvironments:rename', + ( + _event, + args: { selector: string; name: string } + ): { environment: PublicKnownRuntimeEnvironment } => ({ + environment: redactRuntimeEnvironment( + renameEnvironment(getUserDataPath(), args.selector, { name: args.name }) + ) + }) + ) + ipcMain.handle( + 'runtimeEnvironments:updateFromPairingCode', + ( + _event, + args: { selector: string; pairingCode: string } + ): { environment: PublicKnownRuntimeEnvironment } => { + const environment = updateEnvironmentFromPairingCode(getUserDataPath(), args.selector, { + pairingCode: args.pairingCode + }) + // Why: re-pair replaces the endpoint/token; drop live sockets so the next + // connect/status probe uses the newly saved offer instead of a stale path. + closeRemoteRuntimeRequestConnection(environment.id) + clearSharedControlSupport(environment.id) + if (args.selector !== environment.id) { + closeRemoteRuntimeRequestConnection(args.selector) + clearSharedControlSupport(args.selector) + } + closeSubscriptionsForEnvironment(environment.id) + return { environment: redactRuntimeEnvironment(environment) } + } + ) ipcMain.handle( 'runtimeEnvironments:resolve', (_event, args: { selector: string }): PublicKnownRuntimeEnvironment => diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 633ecc45be1..72c2e3cab2b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2844,6 +2844,14 @@ export type PreloadApi = { name: string pairingCode: string }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> + rename: (args: { + selector: string + name: string + }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> + updateFromPairingCode: (args: { + selector: string + pairingCode: string + }) => Promise<{ environment: PublicKnownRuntimeEnvironment }> resolve: (args: { selector: string }) => Promise remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> disconnect: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 5cd262973fb..b3af358f38c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3801,6 +3801,16 @@ const api = { pairingCode: string }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + rename: (args: { + selector: string + name: string + }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:rename', args), + updateFromPairingCode: (args: { + selector: string + pairingCode: string + }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:updateFromPairingCode', args), resolve: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:resolve', args), remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 116dc02c5be..4921995f7f4 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, ChevronDown, Loader2, + Pencil, Plus, RefreshCw, Server, @@ -52,6 +53,18 @@ import { getRuntimeEnvironmentsSearchEntry, getWebRuntimeEnvironmentsSearchEntry } from './runtime-environments-search' +import { + getPreferredPublicRuntimeEndpoint, + getRuntimeEndpointTransportKind +} from '../../../../shared/runtime-environment-endpoint-display' +import { + getRuntimeEndpointTransportLabel, + getRuntimeServerEndpointDisplay +} from './runtime-server-endpoint-labels' +import { + RuntimeServerEditDialog, + type RuntimeServerEditSaveArgs +} from './RuntimeServerEditDialog' import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' @@ -261,11 +274,14 @@ export function RuntimeEnvironmentsPane({ const [disconnectingId, setDisconnectingId] = useState(null) const [pendingSwitchValue, setPendingSwitchValue] = useState(null) const [pendingRemove, setPendingRemove] = useState(null) + const [pendingEdit, setPendingEdit] = useState(null) const [addServerFormOpen, setAddServerFormOpen] = useState(false) const [shareServerFormOpen, setShareServerFormOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) const [switchError, setSwitchError] = useState(null) const [removeError, setRemoveError] = useState(null) + const [editError, setEditError] = useState(null) + const [isEditing, setIsEditing] = useState(false) const [name, setName] = useState('') const [pairingCode, setPairingCode] = useState('') const mountedRef = useMountedRef() @@ -274,6 +290,7 @@ export function RuntimeEnvironmentsPane({ (allowLocalRuntime ? LOCAL_RUNTIME_VALUE : NO_RUNTIME_VALUE) const isBusy = isSaving || + isEditing || connectingId !== null || switchingValue !== null || removingId !== null || @@ -536,6 +553,96 @@ export function RuntimeEnvironmentsPane({ } } + const saveEditedEnvironment = async (args: RuntimeServerEditSaveArgs): Promise => { + const environment = pendingEdit + if (!environment) { + return + } + const nextName = args.name.trim() + if (!nextName) { + setEditError( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.editNameRequired', + 'Server name is required.' + ) + ) + return + } + const renameNeeded = nextName !== environment.name + const rePairNeeded = args.pairingCode != null && args.pairingCode.length > 0 + if (!renameNeeded && !rePairNeeded) { + setPendingEdit(null) + setEditError(null) + return + } + const duplicate = environments.find( + (entry) => + entry.id !== environment.id && entry.name.trim().toLowerCase() === nextName.toLowerCase() + ) + if (duplicate) { + setEditError( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.5ef712f407', + 'A server named "{{value0}}" already exists.', + { value0: duplicate.name } + ) + ) + return + } + + setIsEditing(true) + setEditError(null) + try { + let latest = environment + if (renameNeeded) { + const renamed = await window.api.runtimeEnvironments.rename({ + selector: environment.id, + name: nextName + }) + latest = renamed.environment + } + if (rePairNeeded && args.pairingCode) { + const updated = await window.api.runtimeEnvironments.updateFromPairingCode({ + selector: latest.id, + pairingCode: args.pairingCode + }) + latest = updated.environment + } + await loadEnvironments() + if (mountedRef.current) { + toast.success( + rePairNeeded + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.editServerUpdatedConnection', + 'Updated {{value0}}. Reconnect if you changed the address.', + { value0: latest.name } + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.editServerRenamed', + 'Renamed server to {{value0}}.', + { value0: latest.name } + ) + ) + setPendingEdit(null) + } + } catch (error) { + if (mountedRef.current) { + setEditError( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.editServerFailed', + 'Failed to update server.' + ) + ) + } + } finally { + if (mountedRef.current) { + setIsEditing(false) + } + } + } + const disconnectEnvironment = async ( environment: PublicKnownRuntimeEnvironment ): Promise => { @@ -875,6 +982,10 @@ export function RuntimeEnvironmentsPane({ const connectionState = getRuntimeServerConnectionState(details) // A connected host exposes Disconnect; otherwise Connect. const isReachable = connectionState === 'connected' + const endpoint = getPreferredPublicRuntimeEndpoint(environment) + const transportLabel = getRuntimeEndpointTransportLabel( + getRuntimeEndpointTransportKind(endpoint) + ) const actionBusy = connectingId === environment.id || switchingValue === environment.id || @@ -901,7 +1012,15 @@ export function RuntimeEnvironmentsPane({ ) : null} -

+

+ + {transportLabel} + + + {getRuntimeServerEndpointDisplay(endpoint)} + +
+

{isActive ? translate( 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', @@ -962,6 +1081,24 @@ export function RuntimeEnvironmentsPane({ )} )} + + + + + + ) +} diff --git a/src/renderer/src/components/settings/runtime-server-endpoint-labels.ts b/src/renderer/src/components/settings/runtime-server-endpoint-labels.ts new file mode 100644 index 00000000000..017a453af04 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-endpoint-labels.ts @@ -0,0 +1,21 @@ +import type { RuntimeEndpointTransportKind } from '../../../../shared/runtime-environment-endpoint-display' +import { translate } from '@/i18n/i18n' + +export function getRuntimeEndpointTransportLabel(kind: RuntimeEndpointTransportKind): string { + return kind === 'tailscale' + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.endpointTransportTailscale', + 'Tailscale' + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.endpointTransportDirect', + 'Direct' + ) +} + +export function getRuntimeServerEndpointDisplay(endpoint: string | null): string { + return ( + endpoint ?? + translate('auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', 'No endpoint') + ) +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index da8fe718293..6c250c0feef 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1154,6 +1154,57 @@ function createRuntimeEnvironmentsApi(): NonNullable['runtim saveStoredWebRuntimeEnvironment(activeEnvironment) return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) } }, + rename: async ({ selector, name }) => { + const environment = resolveEnvironment(selector) + const trimmed = name.trim() + if (!trimmed) { + throw new Error('Server name is required.') + } + const next = { + ...environment, + name: trimmed, + updatedAt: Date.now() + } + if (activeEnvironment?.id === environment.id) { + activeEnvironment = next + saveStoredWebRuntimeEnvironment(next) + } + return { environment: redactStoredWebRuntimeEnvironment(next) } + }, + updateFromPairingCode: async ({ selector, pairingCode }) => { + const environment = resolveEnvironment(selector) + const offer = parseWebPairingInput(pairingCode) + if (!offer) { + throw new Error('Invalid Orca pairing code.') + } + // Why: web clients only keep one active env; re-pair must drop the live + // socket so the next call uses the newly stored endpoint/token. + if (activeEnvironment?.id === environment.id) { + closeActiveRuntimeClients() + } + const now = Date.now() + const endpointId = environment.preferredEndpointId || `ws-${environment.id}` + const next = { + ...environment, + updatedAt: now, + preferredEndpointId: endpointId, + endpoints: [ + { + id: endpointId, + kind: 'websocket' as const, + label: environment.endpoints[0]?.label ?? 'WebSocket', + endpoint: offer.endpoint, + deviceToken: offer.deviceToken, + publicKeyB64: offer.publicKeyB64 + } + ] + } + if (activeEnvironment?.id === environment.id) { + activeEnvironment = next + saveStoredWebRuntimeEnvironment(next) + } + return { environment: redactStoredWebRuntimeEnvironment(next) } + }, resolve: async ({ selector }) => redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), remove: async ({ selector }) => { diff --git a/src/shared/runtime-environment-endpoint-display.test.ts b/src/shared/runtime-environment-endpoint-display.test.ts new file mode 100644 index 00000000000..5720d75de4e --- /dev/null +++ b/src/shared/runtime-environment-endpoint-display.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + getPreferredPublicRuntimeEndpoint, + getRuntimeEndpointTransportKind +} from './runtime-environment-endpoint-display' +import type { PublicKnownRuntimeEnvironment } from './runtime-environments' + +function makeEnvironment( + overrides?: Partial +): PublicKnownRuntimeEnvironment { + return { + id: 'env-1', + name: 'desk', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + preferredEndpointId: 'ws-1', + endpoints: [ + { + id: 'ws-1', + kind: 'websocket', + label: 'WebSocket', + endpoint: 'ws://192.168.1.10:6768' + } + ], + ...overrides + } +} + +describe('getPreferredPublicRuntimeEndpoint', () => { + it('prefers preferredEndpointId over the first list entry', () => { + const environment = makeEnvironment({ + preferredEndpointId: 'ws-2', + endpoints: [ + { + id: 'ws-1', + kind: 'websocket', + label: 'LAN', + endpoint: 'ws://192.168.1.10:6768' + }, + { + id: 'ws-2', + kind: 'websocket', + label: 'Tailscale', + endpoint: 'ws://100.64.0.5:6768' + } + ] + }) + expect(getPreferredPublicRuntimeEndpoint(environment)).toBe('ws://100.64.0.5:6768') + }) + + it('falls back to the first endpoint when preferred is missing', () => { + const environment = makeEnvironment({ preferredEndpointId: 'missing' }) + expect(getPreferredPublicRuntimeEndpoint(environment)).toBe('ws://192.168.1.10:6768') + }) +}) + +describe('getRuntimeEndpointTransportKind', () => { + it('classifies Tailscale endpoints', () => { + expect(getRuntimeEndpointTransportKind('ws://100.64.0.5:6768')).toBe('tailscale') + expect(getRuntimeEndpointTransportKind('wss://box.tailnet.ts.net')).toBe('tailscale') + }) + + it('classifies non-Tailscale endpoints as direct', () => { + expect(getRuntimeEndpointTransportKind('ws://192.168.1.10:6768')).toBe('direct') + expect(getRuntimeEndpointTransportKind('wss://orca.example.com')).toBe('direct') + expect(getRuntimeEndpointTransportKind(null)).toBe('direct') + }) +}) diff --git a/src/shared/runtime-environment-endpoint-display.ts b/src/shared/runtime-environment-endpoint-display.ts new file mode 100644 index 00000000000..7202a2243a9 --- /dev/null +++ b/src/shared/runtime-environment-endpoint-display.ts @@ -0,0 +1,19 @@ +import { isTailscaleEndpoint } from './remote-runtime-tailscale-hint' +import type { PublicKnownRuntimeEnvironment } from './runtime-environments' + +export type RuntimeEndpointTransportKind = 'tailscale' | 'direct' + +export function getPreferredPublicRuntimeEndpoint( + environment: Pick +): string | null { + const preferred = + environment.endpoints.find((entry) => entry.id === environment.preferredEndpointId) ?? + environment.endpoints[0] + return preferred?.endpoint?.trim() || null +} + +export function getRuntimeEndpointTransportKind( + endpoint: string | null | undefined +): RuntimeEndpointTransportKind { + return isTailscaleEndpoint(endpoint) ? 'tailscale' : 'direct' +} diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts index 32108a58453..f8e4e5d7dc2 100644 --- a/src/shared/runtime-environment-store.test.ts +++ b/src/shared/runtime-environment-store.test.ts @@ -7,7 +7,9 @@ import { RuntimeEnvironmentStoreError, addEnvironmentFromPairingCode, listEnvironments, - markEnvironmentUsed + markEnvironmentUsed, + renameEnvironment, + updateEnvironmentFromPairingCode } from './runtime-environment-store' function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { @@ -95,4 +97,60 @@ describe('runtime environment store', () => { runtimeId: 'runtime-2' }) }) + + it('renames a saved server while keeping its id and endpoint', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + const env = addEnvironmentFromPairingCode(userDataPath, { + name: 'dev box', + pairingCode: pairingCode('ws://127.0.0.1:6768') + }) + + const renamed = renameEnvironment(userDataPath, env.id, { name: 'lab box', now: 9_000 }) + expect(renamed).toMatchObject({ + id: env.id, + name: 'lab box', + updatedAt: 9_000 + }) + expect(renamed.endpoints[0]?.endpoint).toBe('ws://127.0.0.1:6768') + expect(listEnvironments(userDataPath)[0]?.name).toBe('lab box') + }) + + it('rejects rename to an existing server name', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + addEnvironmentFromPairingCode(userDataPath, { + name: 'alpha', + pairingCode: pairingCode('ws://127.0.0.1:6768') + }) + const beta = addEnvironmentFromPairingCode(userDataPath, { + name: 'beta', + pairingCode: pairingCode('ws://127.0.0.1:6769') + }) + + expect(() => renameEnvironment(userDataPath, beta.id, { name: 'Alpha' })).toThrow( + RuntimeEnvironmentStoreError + ) + expect(listEnvironments(userDataPath).map((entry) => entry.name)).toEqual(['alpha', 'beta']) + }) + + it('updates connection details from a new pairing code without changing id or name', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + const env = addEnvironmentFromPairingCode(userDataPath, { + name: 'dev box', + pairingCode: pairingCode('ws://192.168.1.10:6768') + }) + + const updated = updateEnvironmentFromPairingCode(userDataPath, env.id, { + pairingCode: pairingCode('ws://100.64.0.5:6768'), + now: 12_000 + }) + expect(updated).toMatchObject({ + id: env.id, + name: 'dev box', + updatedAt: 12_000 + }) + expect(updated.endpoints[0]?.endpoint).toBe('ws://100.64.0.5:6768') + }) }) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 09a75400287..37e4573ab2a 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -122,6 +122,45 @@ export function updateEnvironmentFromPairingCode( return next } +export function renameEnvironment( + userDataPath: string, + selector: string, + args: { name: string; now?: number } +): KnownRuntimeEnvironment { + const name = args.name.trim() + if (!name) { + throw new RuntimeEnvironmentStoreError('invalid_argument', 'Server name is required.') + } + const store = readEnvironmentStore(userDataPath) + const existing = resolveEnvironmentFromStore(store, selector) + if (existing.name === name) { + return existing + } + // Why: names are the human selector in CLI/UI; duplicates make resolve-by-name ambiguous. + const duplicate = store.environments.find( + (entry) => entry.id !== existing.id && entry.name.toLowerCase() === name.toLowerCase() + ) + if (duplicate) { + throw new RuntimeEnvironmentStoreError( + 'invalid_argument', + `A server named "${duplicate.name}" already exists.` + ) + } + const now = args.now ?? Date.now() + const next: KnownRuntimeEnvironment = { + ...existing, + name, + updatedAt: now + } + writeEnvironmentStore(userDataPath, { + version: 1, + environments: store.environments + .map((entry) => (entry.id === existing.id ? next : entry)) + .sort((a, b) => a.name.localeCompare(b.name)) + }) + return next +} + export function resolveEnvironment( userDataPath: string, selector: string From 2a64d76b6e8eecff532e5511ef46c17bb463f5ed Mon Sep 17 00:00:00 2001 From: "Bing.Z" Date: Thu, 9 Jul 2026 16:04:25 +0800 Subject: [PATCH 2/5] feat(status-bar): show Remote Orca Server endpoint in hosts menu Surface Tailscale/Direct transport and the preferred websocket URL in the bottom-right hosts popover so users can tell LAN vs tailnet without opening Settings. --- .../status-bar/RuntimeHostStatusRow.test.tsx | 25 ++++++++++ .../status-bar/RuntimeHostStatusRow.tsx | 48 +++++++++++++++---- .../status-bar/SshStatusSegment.tsx | 4 ++ 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx index 46803ebde03..213614674f4 100644 --- a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx +++ b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx @@ -39,4 +39,29 @@ describe('RuntimeHostStatusRow', () => { expect(markup).toContain('Connected') expect(markup).toContain('Disconnect') }) + + it('renders Tailscale and Direct connection addresses when provided', () => { + const tailscale = renderToStaticMarkup( + {}} + /> + ) + expect(tailscale).toContain('Tailscale') + expect(tailscale).toContain('ws://100.64.0.5:6768') + expect(tailscale).not.toContain('Remote Server') + + const direct = renderToStaticMarkup( + {}} + /> + ) + expect(direct).toContain('Direct') + expect(direct).toContain('ws://192.168.1.10:6768') + }) }) diff --git a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx index 3b5019f4719..b624dbd62a3 100644 --- a/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx +++ b/src/renderer/src/components/status-bar/RuntimeHostStatusRow.tsx @@ -1,7 +1,8 @@ import { useCallback, useState } from 'react' import { Loader2 } from 'lucide-react' -import { translate } from '@/i18n/i18n' -import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '../../i18n/i18n' +import { useMountedRef } from '../../hooks/useMountedRef' +import { getRuntimeEndpointTransportKind } from '../../../../shared/runtime-environment-endpoint-display' export type RuntimeHostConnectionState = 'connected' | 'checking' | 'reconnecting' | 'disconnected' @@ -59,18 +60,35 @@ export function RuntimeHostStatusRow({ label, state, detail, + endpoint, onConnect, onDisconnect }: { label: string state: RuntimeHostConnectionState detail?: string + /** Preferred WebSocket endpoint for this paired Remote Orca Server. */ + endpoint?: string | null onConnect?: () => Promise onDisconnect?: () => Promise }): React.JSX.Element { const [busy, setBusy] = useState(false) const mountedRef = useMountedRef() const actionLabel = runtimeActionLabel(state) + const hasEndpoint = Boolean(endpoint?.trim()) + const transportLabel = + getRuntimeEndpointTransportKind(endpoint) === 'tailscale' + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.endpointTransportTailscale', + 'Tailscale' + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.endpointTransportDirect', + 'Direct' + ) + const endpointDisplay = + endpoint?.trim() || + translate('auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', 'No endpoint') const handleAction = useCallback(async () => { const action = state === 'connected' ? onDisconnect : onConnect @@ -92,14 +110,26 @@ export function RuntimeHostStatusRow({

{label}
+ {hasEndpoint ? ( +
+ + {transportLabel} + + {endpointDisplay} +
+ ) : null}
- - {translate( - 'auto.components.status.bar.SshStatusSegment.remote_server', - 'Remote Server' - )} - - + {!hasEndpoint ? ( + <> + + {translate( + 'auto.components.status.bar.SshStatusSegment.remote_server', + 'Remote Server' + )} + + + + ) : null} {state === 'checking' || state === 'reconnecting' ? ( diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index d8d15f821a8..57f1978db9b 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -17,6 +17,7 @@ import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { getPreferredPublicRuntimeEndpoint } from '../../../../shared/runtime-environment-endpoint-display' import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow' import { SshTargetStatusRow } from './SshTargetStatusRow' import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' @@ -192,6 +193,7 @@ export function SshStatusSegment({ return { id: environment.id, label: override || environment.name || environment.id, + endpoint: getPreferredPublicRuntimeEndpoint(environment), hasStatus: Boolean(statusEntry), online: Boolean(statusEntry?.status), active: settings?.activeRuntimeEnvironmentId === environment.id, @@ -353,6 +355,7 @@ export function SshStatusSegment({ key={host.id} label={host.label} state={host.state} + endpoint={host.endpoint} detail={runtimeHostConnectionDetail(host.remoteControl)} onConnect={() => connectRuntimeHost(host.id)} onDisconnect={() => disconnectRuntimeHost(host.id, host.active)} @@ -372,6 +375,7 @@ export function SshStatusSegment({ key={host.id} label={host.label} state={host.state} + endpoint={host.endpoint} detail={runtimeHostConnectionDetail(host.remoteControl)} onConnect={() => connectRuntimeHost(host.id)} onDisconnect={() => disconnectRuntimeHost(host.id, host.active)} From cbf63e3fd7211c8cf33275bd933c1807adbf91d8 Mon Sep 17 00:00:00 2001 From: BingZ Date: Thu, 9 Jul 2026 16:05:36 +0800 Subject: [PATCH 3/5] fix(runtime): reload environments after partial edit failure --- .../src/components/settings/RuntimeEnvironmentsPane.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 4921995f7f4..eebffd6dff7 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -61,10 +61,7 @@ import { getRuntimeEndpointTransportLabel, getRuntimeServerEndpointDisplay } from './runtime-server-endpoint-labels' -import { - RuntimeServerEditDialog, - type RuntimeServerEditSaveArgs -} from './RuntimeServerEditDialog' +import { RuntimeServerEditDialog, type RuntimeServerEditSaveArgs } from './RuntimeServerEditDialog' import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' @@ -626,6 +623,9 @@ export function RuntimeEnvironmentsPane({ setPendingEdit(null) } } catch (error) { + // Why: rename may have succeeded before re-pair failed; reload so the UI + // reflects partial backend changes instead of a stale server name. + await loadEnvironments() if (mountedRef.current) { setEditError( error instanceof Error From 2e65d9b831ae4337f8716701510c134855a9b947 Mon Sep 17 00:00:00 2001 From: bbingz Date: Fri, 10 Jul 2026 08:52:06 +0800 Subject: [PATCH 4/5] fix(runtime): synchronize server editor localization --- src/renderer/src/i18n/locales/en.json | 15 ++++++++++++++- src/renderer/src/i18n/locales/es.json | 15 ++++++++++++++- src/renderer/src/i18n/locales/ja.json | 15 ++++++++++++++- src/renderer/src/i18n/locales/ko.json | 15 ++++++++++++++- src/renderer/src/i18n/locales/zh.json | 15 ++++++++++++++- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7c8045033f8..dac585a4cf4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6274,7 +6274,20 @@ "serverDetails": "Server details", "advertiseThisApp": "Advertise this app as a server", "advertiseThisAppHelp": "Create access links for browsers, mobile clients, or another Orca client to connect back to this running app.", - "runtimeReachable": "{{value0}} is reachable." + "runtimeReachable": "{{value0}} is reachable.", + "editNameRequired": "Server name is required.", + "editServerUpdatedConnection": "Updated {{value0}}. Reconnect if you changed the address.", + "editServerRenamed": "Renamed server to {{value0}}.", + "editServerFailed": "Failed to update server.", + "editServerAria": "Edit {{value0}}", + "editServerTitle": "Edit Server", + "editServerDescription": "Rename this server, or paste a new pairing code to change its connection address without removing workspaces.", + "currentEndpoint": "Current connection", + "newPairingCode": "New pairing code (optional)", + "editPairingCodeHelp": "Leave blank to keep the current address. To switch LAN ↔ Tailscale, generate a new pairing URL on the server with the desired --pairing-address.", + "editServerSave": "Save Changes", + "endpointTransportTailscale": "Tailscale", + "endpointTransportDirect": "Direct" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copy {{value0}}" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ffd31bdd327..90ce3413318 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6237,7 +6237,20 @@ "serverDetails": "Detalles del servidor", "advertiseThisApp": "Anunciar esta app como servidor", "advertiseThisAppHelp": "Crea enlaces de acceso para que navegadores, clientes móviles u otro cliente de Orca se conecten a esta app en ejecución.", - "runtimeReachable": "{{value0}} está accesible." + "runtimeReachable": "{{value0}} está accesible.", + "editNameRequired": "Server name is required.", + "editServerUpdatedConnection": "Updated {{value0}}. Reconnect if you changed the address.", + "editServerRenamed": "Renamed server to {{value0}}.", + "editServerFailed": "Failed to update server.", + "editServerAria": "Edit {{value0}}", + "editServerTitle": "Edit Server", + "editServerDescription": "Rename this server, or paste a new pairing code to change its connection address without removing workspaces.", + "currentEndpoint": "Current connection", + "newPairingCode": "New pairing code (optional)", + "editPairingCodeHelp": "Leave blank to keep the current address. To switch LAN ↔ Tailscale, generate a new pairing URL on the server with the desired --pairing-address.", + "editServerSave": "Save Changes", + "endpointTransportTailscale": "Tailscale", + "endpointTransportDirect": "Direct" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copiar {{value0}}" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 360123c88e7..e2d7656264b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6259,7 +6259,20 @@ "serverDetails": "サーバーの詳細", "advertiseThisApp": "このアプリをサーバーとして公開", "advertiseThisAppHelp": "ブラウザー、モバイルクライアント、または別の Orca クライアントがこの実行中のアプリへ接続できるように、アクセスリンクを作成します。", - "runtimeReachable": "{{value0}} に到達できます。" + "runtimeReachable": "{{value0}} に到達できます。", + "editNameRequired": "Server name is required.", + "editServerUpdatedConnection": "Updated {{value0}}. Reconnect if you changed the address.", + "editServerRenamed": "Renamed server to {{value0}}.", + "editServerFailed": "Failed to update server.", + "editServerAria": "Edit {{value0}}", + "editServerTitle": "Edit Server", + "editServerDescription": "Rename this server, or paste a new pairing code to change its connection address without removing workspaces.", + "currentEndpoint": "Current connection", + "newPairingCode": "New pairing code (optional)", + "editPairingCodeHelp": "Leave blank to keep the current address. To switch LAN ↔ Tailscale, generate a new pairing URL on the server with the desired --pairing-address.", + "editServerSave": "Save Changes", + "endpointTransportTailscale": "Tailscale", + "endpointTransportDirect": "Direct" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}}をコピー" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c74f362a770..e1efb81bcdb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6222,7 +6222,20 @@ "serverDetails": "서버 세부 정보", "advertiseThisApp": "이 앱을 서버로 알리기", "advertiseThisAppHelp": "브라우저, 모바일 클라이언트 또는 다른 Orca 클라이언트가 실행 중인 이 앱에 다시 연결할 수 있도록 액세스 링크를 만듭니다.", - "runtimeReachable": "{{value0}}에 연결할 수 있습니다." + "runtimeReachable": "{{value0}}에 연결할 수 있습니다.", + "editNameRequired": "Server name is required.", + "editServerUpdatedConnection": "Updated {{value0}}. Reconnect if you changed the address.", + "editServerRenamed": "Renamed server to {{value0}}.", + "editServerFailed": "Failed to update server.", + "editServerAria": "Edit {{value0}}", + "editServerTitle": "Edit Server", + "editServerDescription": "Rename this server, or paste a new pairing code to change its connection address without removing workspaces.", + "currentEndpoint": "Current connection", + "newPairingCode": "New pairing code (optional)", + "editPairingCodeHelp": "Leave blank to keep the current address. To switch LAN ↔ Tailscale, generate a new pairing URL on the server with the desired --pairing-address.", + "editServerSave": "Save Changes", + "endpointTransportTailscale": "Tailscale", + "endpointTransportDirect": "Direct" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}} 복사" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3796138b5fd..7bbb8cf76b8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6222,7 +6222,20 @@ "serverDetails": "服务器详细信息", "advertiseThisApp": "将此应用作为服务器公布", "advertiseThisAppHelp": "创建访问链接,让浏览器、移动客户端或另一个 Orca 客户端连接回这个正在运行的应用。", - "runtimeReachable": "可连接到 {{value0}}。" + "runtimeReachable": "可连接到 {{value0}}。", + "editNameRequired": "Server name is required.", + "editServerUpdatedConnection": "Updated {{value0}}. Reconnect if you changed the address.", + "editServerRenamed": "Renamed server to {{value0}}.", + "editServerFailed": "Failed to update server.", + "editServerAria": "Edit {{value0}}", + "editServerTitle": "Edit Server", + "editServerDescription": "Rename this server, or paste a new pairing code to change its connection address without removing workspaces.", + "currentEndpoint": "Current connection", + "newPairingCode": "New pairing code (optional)", + "editPairingCodeHelp": "Leave blank to keep the current address. To switch LAN ↔ Tailscale, generate a new pairing URL on the server with the desired --pairing-address.", + "editServerSave": "Save Changes", + "endpointTransportTailscale": "Tailscale", + "endpointTransportDirect": "Direct" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "复制 {{value0}}" From 71c1e474a743d3cb07f67baff28f3ed984eef405 Mon Sep 17 00:00:00 2001 From: bbingz Date: Mon, 13 Jul 2026 18:45:54 +0800 Subject: [PATCH 5/5] fix(runtime): reset server editor by environment --- .../settings/RuntimeEnvironmentsPane.tsx | 2 ++ .../settings/RuntimeServerEditDialog.tsx | 16 +++------------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index eebffd6dff7..c31d44ba1a3 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -1521,6 +1521,8 @@ export function RuntimeEnvironmentsPane({ { - if (!open || !environment) { - return - } - setName(environment.name) - setPairingCode('') - }, [open, environment]) + const transportLabel = getRuntimeEndpointTransportLabel(getRuntimeEndpointTransportKind(endpoint)) const trimmedName = name.trim() const trimmedPairingCode = pairingCode.trim()