diff --git a/src/main/index.ts b/src/main/index.ts index 45e598ffc27..a127db089cc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2202,6 +2202,7 @@ app.whenReady().then(async () => { runtimeRpc, onStatus: (status) => { desktopRelayStatus = status + mainWindow?.webContents.send('mobile:relayStatusChanged', status) } }) desktopRelayService = relayService diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index bbf5f2e85d5..dce3d39bb5f 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -293,4 +293,10 @@ describe('registerMobileHandlers', () => { ).resolves.toEqual({ revoked: true }) expect(revokeMobileDevice).toHaveBeenCalledWith('mobile-1') }) + + it('reports the current relay broker status without exposing a toggle', () => { + registerMobileHandlers({} as never, { getRelayStatus: () => 'registered' }) + + expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' }) + }) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5784d5f77c7..9136230972f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -17,6 +17,7 @@ import type { } from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' +import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -3167,6 +3168,8 @@ export type PreloadApi = { listRuntimeAccessGrants: () => Promise<{ grants: RuntimeAccessGrant[] }> revokeRuntimeAccess: (args: { deviceId: string }) => Promise<{ revoked: boolean }> isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> + getRelayStatus: () => Promise<{ status: MobileRelayStatus }> + onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void) => () => void } speech: { getCatalog: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 83466e7b962..535e9953484 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,7 @@ import type { TerminalPaneSplitSource } from '../shared/feature-education-teleme import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -4295,7 +4296,17 @@ const api = { ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => - ipcRenderer.invoke('mobile:isWebSocketReady') + ipcRenderer.invoke('mobile:isWebSocketReady'), + + getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => + ipcRenderer.invoke('mobile:getRelayStatus'), + + onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => + callback(status) + ipcRenderer.on('mobile:relayStatusChanged', listener) + return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) + } }, agentStatus: { diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 650d42c29ad..4f56728a07c 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -12,6 +12,7 @@ import { MobilePairingQrSection } from './MobilePairingQrSection' import { MobilePairedDevicesSection, type PairedDevice } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' import { WindowsFirewallNotice } from '../mobile/WindowsFirewallNotice' +import { MobileRelayStatusSection } from './MobileRelayStatusSection' import { translate } from '@/i18n/i18n' export { getMobilePaneSearchEntries } from './mobile-pane-search' @@ -161,6 +162,8 @@ export function MobilePane(): React.JSX.Element { return (
+ + Promise +} + +const mocks = vi.hoisted(() => ({ + state: {} as MobileRelayStoreState +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: MobileRelayStoreState) => unknown) => selector(mocks.state) +})) + +describe('MobileRelayStatusSection', () => { + let statusListener: ((status: MobileRelayStatus) => void) | null + const connect = vi.fn().mockResolvedValue(null) + + beforeEach(() => { + statusListener = null + connect.mockClear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getRelayStatus: vi.fn().mockResolvedValue({ status: 'registered' }), + onRelayStatusChanged: vi.fn((listener: (status: MobileRelayStatus) => void) => { + statusListener = listener + return vi.fn() + }) + } + } + }) + mocks.state = { + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'local', + persistence: 'none' + }, + orcaProfileConnecting: false, + connectCurrentOrcaProfile: connect + } + }) + + afterEach(() => cleanup()) + + it('keeps direct pairing available while offering desktop sign-in', async () => { + const user = userEvent.setup() + render() + + expect( + screen.getByText('LAN and Tailscale pairing still work without an account.') + ).toBeVisible() + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(connect).toHaveBeenCalledOnce() + }) + + it('shows live automatic relay status for a signed-in desktop', async () => { + mocks.state = { + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'connected', + persistence: 'encrypted' + }, + orcaProfileConnecting: false, + connectCurrentOrcaProfile: connect + } + render() + + await waitFor(() => expect(screen.getByText('Registered')).toBeVisible()) + statusListener?.('offline') + await waitFor(() => expect(screen.getByText('Offline')).toBeVisible()) + expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/MobileRelayStatusSection.tsx b/src/renderer/src/components/settings/MobileRelayStatusSection.tsx new file mode 100644 index 00000000000..79312e97738 --- /dev/null +++ b/src/renderer/src/components/settings/MobileRelayStatusSection.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status' + +function relayStatusLabel(status: MobileRelayStatus): string { + if (status === 'registered') { + return translate('auto.components.settings.MobileRelayStatusSection.registered', 'Registered') + } + if (status === 'connecting') { + return translate('auto.components.settings.MobileRelayStatusSection.connecting', 'Connecting') + } + if (status === 'draining') { + return translate( + 'auto.components.settings.MobileRelayStatusSection.reconnecting', + 'Reconnecting' + ) + } + return translate('auto.components.settings.MobileRelayStatusSection.offline', 'Offline') +} + +export function MobileRelayStatusSection(): React.JSX.Element { + const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) + const connecting = useAppStore((state) => state.orcaProfileConnecting) + const connect = useAppStore((state) => state.connectCurrentOrcaProfile) + const [relayStatus, setRelayStatus] = useState('offline') + const signedIn = authStatus?.state === 'connected' + const configured = authStatus?.configured !== false + + useEffect(() => { + let receivedEvent = false + let active = true + const unsubscribe = window.api.mobile.onRelayStatusChanged((status) => { + receivedEvent = true + if (active) { + setRelayStatus(status) + } + }) + void window.api.mobile + .getRelayStatus() + .then(({ status }) => { + if (active && !receivedEvent) { + setRelayStatus(status) + } + }) + .catch(() => {}) + return () => { + active = false + unsubscribe() + } + }, []) + + return ( +
+

+ {translate('auto.components.settings.MobileRelayStatusSection.title', 'Orca Relay')} +

+
+
+

+ {signedIn + ? translate( + 'auto.components.settings.MobileRelayStatusSection.automatic', + 'Connect from anywhere automatically' + ) + : translate( + 'auto.components.settings.MobileRelayStatusSection.signInPrompt', + 'Sign in on this desktop to connect from anywhere' + )} +

+

+ {signedIn + ? translate( + 'auto.components.settings.MobileRelayStatusSection.directStillAvailable', + 'LAN and Tailscale connections remain available.' + ) + : translate( + 'auto.components.settings.MobileRelayStatusSection.directNeedsNoAccount', + 'LAN and Tailscale pairing still work without an account.' + )} +

+
+ {signedIn ? ( + + {relayStatusLabel(relayStatus)} + + ) : configured ? ( + + ) : ( + + {translate( + 'auto.components.settings.MobileRelayStatusSection.unavailable', + 'Unavailable' + )} + + )} +
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 40492be7b71..bf7813ca16c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9032,6 +9032,19 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere automatically", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f909b7687b9..e50e25cc57e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9032,6 +9032,19 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere automatically", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 33bc07fbfd1..c012ab4f537 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9032,6 +9032,19 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere automatically", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 67187a4b21c..afe6827de0d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9032,6 +9032,19 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere automatically", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dbf29920404..37d61d1af98 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9032,6 +9032,19 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "MobileRelayStatusSection": { + "registered": "Registered", + "connecting": "Connecting", + "reconnecting": "Reconnecting", + "offline": "Offline", + "title": "Orca Relay", + "automatic": "Connect from anywhere automatically", + "signInPrompt": "Sign in on this desktop to connect from anywhere", + "directStillAvailable": "LAN and Tailscale connections remain available.", + "directNeedsNoAccount": "LAN and Tailscale pairing still work without an account.", + "signIn": "Sign in", + "unavailable": "Unavailable" } }, "right": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 0d104751b17..0c451ef44d0 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -758,7 +758,10 @@ function createWebPreloadApi(): Partial { revokeDevice: () => Promise.resolve({ revoked: false }), listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), - isWebSocketReady: () => Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }) + isWebSocketReady: () => + Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }), + getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), + onRelayStatusChanged: () => noopUnsubscribe }, telemetryTrack: () => Promise.resolve(), telemetrySetOptIn: () => Promise.resolve(),