From b1b491cc363625f2bb0a6adfd7c2469da0e073f6 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 13 Jul 2026 19:47:10 -0400 Subject: [PATCH] feat(mobile): add per-device pairing connection mode Co-authored-by: Orca --- .../mobile-connection-path-label.test.ts | 2 +- .../transport/mobile-connection-path-label.ts | 2 +- src/main/ipc/mobile.test.ts | 20 ++ src/main/ipc/mobile.ts | 11 +- src/main/runtime/device-registry.ts | 26 ++- .../relay/desktop-relay-service.test.ts | 26 ++- .../runtime/relay/desktop-relay-service.ts | 12 ++ src/main/runtime/runtime-rpc.test.ts | 125 ++++++++++++ src/main/runtime/runtime-rpc.ts | 28 ++- src/preload/api-types.ts | 7 +- src/preload/index.ts | 2 + src/renderer/src/assets/mobile-page.css | 2 +- .../src/components/mobile/MobileHero.tsx | 78 ++++--- .../src/components/mobile/MobilePage.test.tsx | 135 ++++++++++++ .../src/components/mobile/MobilePage.tsx | 87 +++++--- .../components/mobile/MobilePageContent.tsx | 7 + .../mobile/NetworkInterfacePicker.tsx | 2 +- .../mobile/use-mobile-install-actions.ts | 37 ++++ .../MobileNetworkInterfaceSection.test.tsx | 98 --------- .../MobileNetworkInterfaceSection.tsx | 168 --------------- ...> MobilePairingConnectionOptions.test.tsx} | 28 +-- .../MobilePairingConnectionOptions.tsx | 193 ++++++++++++++++++ .../MobilePairingSetupSection.test.tsx | 74 +++++++ .../settings/MobilePairingSetupSection.tsx | 157 ++++++++++++++ .../src/components/settings/MobilePane.tsx | 55 ++++- .../settings/MobileRelayStatusSection.tsx | 118 ----------- src/renderer/src/i18n/locales/en.json | 28 +++ src/renderer/src/i18n/locales/es.json | 28 +++ src/renderer/src/i18n/locales/ja.json | 28 +++ src/renderer/src/i18n/locales/ko.json | 28 +++ src/renderer/src/i18n/locales/zh.json | 28 +++ src/shared/mobile-pairing-connection-mode.ts | 1 + 32 files changed, 1165 insertions(+), 476 deletions(-) create mode 100644 src/renderer/src/components/mobile/MobilePage.test.tsx create mode 100644 src/renderer/src/components/mobile/use-mobile-install-actions.ts delete mode 100644 src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx delete mode 100644 src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx rename src/renderer/src/components/settings/{MobileRelayStatusSection.test.tsx => MobilePairingConnectionOptions.test.tsx} (69%) create mode 100644 src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx create mode 100644 src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx create mode 100644 src/renderer/src/components/settings/MobilePairingSetupSection.tsx delete mode 100644 src/renderer/src/components/settings/MobileRelayStatusSection.tsx create mode 100644 src/shared/mobile-pairing-connection-mode.ts diff --git a/mobile/src/transport/mobile-connection-path-label.test.ts b/mobile/src/transport/mobile-connection-path-label.test.ts index 1bc16a450a4..e65842a8869 100644 --- a/mobile/src/transport/mobile-connection-path-label.test.ts +++ b/mobile/src/transport/mobile-connection-path-label.test.ts @@ -5,6 +5,6 @@ describe('mobile connection path label', () => { it('distinguishes LAN, Tailscale, and the relay without exposing transport errors', () => { expect(mobileConnectionPathLabel('lan')).toBe('Direct · LAN') expect(mobileConnectionPathLabel('tailscale')).toBe('Direct · Tailscale') - expect(mobileConnectionPathLabel('relay')).toBe('Via Orca Relay') + expect(mobileConnectionPathLabel('relay')).toBe('Orca Relay') }) }) diff --git a/mobile/src/transport/mobile-connection-path-label.ts b/mobile/src/transport/mobile-connection-path-label.ts index 30efd6dc7d0..0df114e85a7 100644 --- a/mobile/src/transport/mobile-connection-path-label.ts +++ b/mobile/src/transport/mobile-connection-path-label.ts @@ -2,7 +2,7 @@ import type { MobileConnectionPath } from './stable-logical-rpc-client' export function mobileConnectionPathLabel(path: MobileConnectionPath): string { if (path === 'relay') { - return 'Via Orca Relay' + return 'Orca Relay' } return path === 'tailscale' ? 'Direct · Tailscale' : 'Direct · LAN' } diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index cca405aa41b..49a39686a28 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -83,11 +83,31 @@ describe('registerMobileHandlers', () => { expect(createMobilePairingOffer).toHaveBeenCalledWith({ address: '100.102.47.57', + connectionMode: undefined, rotate: undefined, name: expect.stringMatching(/^Mobile /) }) }) + it('forwards an explicit local-only pairing choice', async () => { + networkInterfacesMock.mockReturnValue({ + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] + }) + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#local', + endpoint: 'ws://192.168.1.24:6768', + deviceId: 'mobile-local' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + await handlers.get('mobile:getPairingQR')?.(null, { connectionMode: 'local-only' }) + + expect(createMobilePairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ connectionMode: 'local-only' }) + ) + }) + it('lists only paired mobile-scoped devices', () => { const rpcServer = { getDeviceRegistry: () => ({ diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 4c00aeea1c8..4cc09bc0b51 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,6 +2,7 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import QRCode from 'qrcode' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' @@ -80,7 +81,14 @@ export function registerMobileHandlers( ipcMain.handle( 'mobile:getPairingQR', - async (_event, args?: { address?: string; rotate?: boolean }) => { + async ( + _event, + args?: { + address?: string + connectionMode?: MobilePairingConnectionMode + rotate?: boolean + } + ) => { // Why: allow the caller to specify which network interface address to // embed in the QR code. This supports overlay networks (Tailscale, // ZeroTier) where the default LAN IP isn't reachable from the phone. @@ -98,6 +106,7 @@ export function registerMobileHandlers( // one so the new QR carries a different credential. const offer = await rpcServer.createMobilePairingOffer({ address: ip, + connectionMode: args?.connectionMode, rotate: args?.rotate, name: `Mobile ${new Date().toLocaleDateString()}` }) diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 7d74a799a7f..df4d58274ea 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -9,6 +9,7 @@ import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secu import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' export type { DeviceScope } @@ -20,6 +21,7 @@ export type DeviceEntry = { pairedAt: number lastSeenAt: number relayBinding?: RelayDeviceBinding + mobilePairingConnectionMode?: MobilePairingConnectionMode } function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { @@ -117,6 +119,26 @@ export class DeviceRegistry { return true } + setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean { + const device = this.devices.find((candidate) => candidate.deviceId === deviceId) + if (!device || device.scope !== 'mobile') { + return false + } + device.mobilePairingConnectionMode = mode + this.save() + return true + } + + getMobilePairingConnectionMode(deviceId: string): MobilePairingConnectionMode | null { + const device = this.devices.find((candidate) => candidate.deviceId === deviceId) + if (!device || device.scope !== 'mobile') { + return null + } + // Why: pairings created before this preference existed used automatic + // direct-first Relay fallback, so missing state must preserve that behavior. + return device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic' + } + listDevices(): readonly DeviceEntry[] { return this.devices } @@ -146,7 +168,9 @@ export class DeviceRegistry { // Why: older registries only existed for phone pairing. Treat missing // scope as mobile so legacy device tokens do not gain new CLI powers. scope: device.scope === 'runtime' ? 'runtime' : 'mobile', - relayBinding: validRelayBinding(device.relayBinding, device.deviceId) + relayBinding: validRelayBinding(device.relayBinding, device.deviceId), + mobilePairingConnectionMode: + device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic' })) } catch { this.devices = [] diff --git a/src/main/runtime/relay/desktop-relay-service.test.ts b/src/main/runtime/relay/desktop-relay-service.test.ts index c5c9ac4c5e2..a69c5a74fa6 100644 --- a/src/main/runtime/relay/desktop-relay-service.test.ts +++ b/src/main/runtime/relay/desktop-relay-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { MobilePairingConnectionContext } from '../runtime-rpc' -import { pairingAuthorizationForContext } from './desktop-relay-service' +import { DesktopRelayService, pairingAuthorizationForContext } from './desktop-relay-service' const relayHostId = 'AbCdEf0123_-xyZ9' @@ -60,3 +60,27 @@ describe('pairingAuthorizationForContext', () => { ).toThrow('stale_relay_connection') }) }) + +describe('local-only mobile pairing', () => { + it('refuses endpoint discovery and provisioning without opening Relay demand', async () => { + const registry = { + getDevice: () => ({ deviceId: 'device-1', scope: 'mobile' }), + getMobilePairingConnectionMode: () => 'local-only' + } + const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService + Object.defineProperty(service, 'runtimeRpc', { + value: { getDeviceRegistry: () => registry } + }) + + await expect(service.getEndpoints(context({ transport: 'direct' }), {})).resolves.toEqual({ + v: 1, + relay: null + }) + await expect( + service.provisionRelay(context({ transport: 'direct' }), { + reqId: 'install-1', + newResumeTokenHash: 'A'.repeat(43) + }) + ).rejects.toThrow('relay_disabled_for_device') + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 76e176a9d98..c52d80a8fe4 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -135,6 +135,12 @@ export class DesktopRelayService { params: PairingGetEndpointsParams ): Promise { this.requireMobileDevice(context.deviceId) + if ( + this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) === + 'local-only' + ) { + return { v: 1, relay: null } + } return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => { const broker = await this.activeBrokerForDemand() if (!broker?.endpoint) { @@ -169,6 +175,12 @@ export class DesktopRelayService { params: PairingProvisionRelayParams ): Promise { this.requireMobileDevice(context.deviceId) + if ( + this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) === + 'local-only' + ) { + throw new Error('relay_disabled_for_device') + } return await this.withTransientDemand(`provision:${context.deviceId}`, async () => { const broker = await this.requireActiveBroker() if (!broker.endpoint) { diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2a86c175168..03c8225b44d 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -590,6 +590,131 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('persists local-only pairing and never mints or later binds Relay', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const createPairingRelay = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay, + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay') + expect(createPairingRelay).not.toHaveBeenCalled() + expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe( + 'local-only' + ) + expect( + server.setMobileRelayBinding(offer.deviceId, { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId: offer.deviceId, + ownerIdentityKey: 'user\0profile\0org' + }) + ).toBe(false) + } finally { + await server.stop() + } + }) + + it('normalizes untrusted pairing modes to automatic at the runtime boundary', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ + connectionMode: 'renderer-controlled-value' as never + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe( + 'automatic' + ) + } finally { + await server.stop() + } + }) + + it('revokes and rotates a pending Relay code when switching it to local-only', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const anywhere = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(anywhere.available).toBe(true) + if (!anywhere.available) { + throw new Error('WebSocket pairing unavailable') + } + const local = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(local.available).toBe(true) + if (!local.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(local.deviceId).not.toBe(anywhere.deviceId) + expect(server.getDeviceRegistry()?.getDevice(anywhere.deviceId)).toBeNull() + expect(onDeviceRevokeQueued).toHaveBeenCalledOnce() + expect(parsePairingCode(local.pairingUrl)).not.toHaveProperty('relay') + } finally { + await server.stop() + } + }) + it('records cloud cleanup before rotating or deleting the local mobile credential', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index a04a35b2278..b9c55bb0376 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -26,6 +26,7 @@ import { type MobileSocketTransportMetadata } from './rpc/mobile-socket-wiring' import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import { RelayRevokeOutbox, type RelayDeviceBinding, @@ -515,7 +516,10 @@ export class OrcaRuntimeRpcServer { setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { const current = this.deviceRegistry?.getDevice(deviceId) - if (current?.scope !== 'mobile') { + if ( + current?.scope !== 'mobile' || + this.deviceRegistry?.getMobilePairingConnectionMode(deviceId) === 'local-only' + ) { return false } if ( @@ -613,19 +617,33 @@ export class OrcaRuntimeRpcServer { async createMobilePairingOffer(args: { address?: string | null + connectionMode?: MobilePairingConnectionMode name?: string rotate?: boolean }): Promise> { - if (args.rotate) { - const pending = this.deviceRegistry?.getPendingDevice('mobile') + // Why: the renderer is outside the trust boundary; only the explicit + // local-only value may suppress Relay provisioning. + const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic' + const pending = this.deviceRegistry?.getPendingDevice('mobile') + const switchingPendingToLocal = + connectionMode === 'local-only' && pending?.relayBinding !== undefined + if (args.rotate || switchingPendingToLocal) { if (pending?.relayBinding) { // Why: the durable cloud revoke is recorded before rotating the local // token, so a previously displayed relay invite cannot outlive the QR. this.queueRelayDeviceRevoke(pending.relayBinding) } } - const direct = this.createPairingOffer({ ...args, scope: 'mobile' }) - if (!direct.available || !this.mobileRelayPairingProvider) { + const direct = this.createPairingOffer({ + ...args, + rotate: args.rotate || switchingPendingToLocal, + scope: 'mobile' + }) + if (!direct.available) { + return direct + } + this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) + if (connectionMode === 'local-only' || !this.mobileRelayPairingProvider) { return direct } const device = this.deviceRegistry?.getDevice(direct.deviceId) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9136230972f..6df54b1f3a4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -18,6 +18,7 @@ import type { import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { MobileRelayStatus } from '../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -3126,7 +3127,11 @@ export type PreloadApi = { listNetworkInterfaces: () => Promise<{ interfaces: { name: string; address: string }[] }> - getPairingQR: (args?: { address?: string; rotate?: boolean }) => Promise< + getPairingQR: (args?: { + address?: string + connectionMode?: MobilePairingConnectionMode + rotate?: boolean + }) => Promise< | { available: false } | { available: true diff --git a/src/preload/index.ts b/src/preload/index.ts index 535e9953484..e90493a502c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,7 @@ import type { ProjectExecutionRuntimeResolution } from '../shared/project-execut 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 { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -4250,6 +4251,7 @@ const api = { getPairingQR: (args?: { address?: string + connectionMode?: MobilePairingConnectionMode rotate?: boolean }): Promise< | { available: false } diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 4f7c2a92ba9..883bd3dc53f 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -756,7 +756,7 @@ display: flex; align-items: center; gap: 10px; - margin: 0 0 18px; + margin: 14px 0 18px; flex-wrap: wrap; font-size: 13px; } diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 64e0ca7344f..f0688ee404c 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -3,8 +3,10 @@ import { cn } from '../../lib/utils' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' import { NetworkInterfacePicker } from './NetworkInterfacePicker' +import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions' import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy' import { WindowsFirewallNotice } from './WindowsFirewallNotice' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' export { HeroIntro } from './MobileHeroIntro' export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' import { translate } from '@/i18n/i18n' @@ -37,6 +39,8 @@ type HeroFlowProps = { pairQrDataUrl: string | null pairingUrl: string | null pairLoading: boolean + connectionMode: MobilePairingConnectionMode + onConnectionModeChange: (mode: MobilePairingConnectionMode) => void onRegeneratePairing: () => void onCopyPairingCode: () => void networkInterfaces: readonly MobileNetworkInterface[] @@ -62,6 +66,8 @@ export function HeroFlow({ pairQrDataUrl, pairingUrl, pairLoading, + connectionMode, + onConnectionModeChange, onRegeneratePairing, onCopyPairingCode, networkInterfaces, @@ -194,37 +200,47 @@ export function HeroFlow({ {translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}

-
- - {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} - - - -
+ + + {connectionMode === 'local-only' ? ( +
+ + {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} + + + +
+ ) : null}
diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx new file mode 100644 index 00000000000..8bd0b52f713 --- /dev/null +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' + +type StoreState = { + closeMobilePage: () => void + orcaProfileAuthStatus: { state: 'connected' | 'local' } + settings: { showMobileButton: boolean } + updateSettings: () => Promise +} + +const mocks = vi.hoisted(() => ({ + storeState: {} as StoreState +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: StoreState) => unknown) => selector(mocks.storeState) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), message: vi.fn(), success: vi.fn() } +})) + +vi.mock('./use-mobile-install-qr', () => ({ useMobileInstallQr: () => null })) +vi.mock('./use-mobile-page-escape', () => ({ useMobilePageEscape: vi.fn() })) +vi.mock('../settings/mobile-pairing-device-polling', () => ({ + useMobilePairingDevicePolling: vi.fn() +})) + +vi.mock('./MobilePageContent', () => ({ + MobilePageContent: (props: { + connectionMode: MobilePairingConnectionMode + enterFlow: () => void + handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void + handleContinue: () => void + pairingUrl: string | null + stage: string | null + stepIdx: number + }) => ( +
+ {props.stage ?? 'loading'} + {props.stepIdx} + {props.connectionMode} + {props.pairingUrl ?? 'none'} + + + + +
+ ) +})) + +import MobilePage from './MobilePage' + +describe('MobilePage pairing connection mode', () => { + const getPairingQR = vi.fn() + + beforeEach(() => { + getPairingQR.mockReset().mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,qr', + pairingUrl: 'orca://pair#automatic' + }) + mocks.storeState = { + closeMobilePage: vi.fn(), + orcaProfileAuthStatus: { state: 'connected' }, + settings: { showMobileButton: true }, + updateSettings: vi.fn().mockResolvedValue(undefined) + } + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getPairingQR, + listDevices: vi.fn().mockResolvedValue({ devices: [] }), + listNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }) + }, + shell: { openUrl: vi.fn() }, + ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } + } + }) + }) + + afterEach(cleanup) + + async function openPairingStep(): Promise { + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByTestId('stage')).toHaveTextContent('intro')) + await user.click(screen.getByRole('button', { name: 'Enter flow' })) + await user.click(screen.getByRole('button', { name: 'Continue' })) + } + + it('defaults signed-in pairing to direct-first Relay fallback and rotates on local-only change', async () => { + const user = userEvent.setup() + await openPairingStep() + + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + + await user.click(screen.getByRole('button', { name: 'Local only' })) + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + connectionMode: 'local-only', + rotate: true + }) + ) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) + + it('defaults signed-out pairing to local-only', async () => { + mocks.storeState.orcaProfileAuthStatus = { state: 'local' } + await openPairingStep() + + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'local-only' })) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) +}) diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index c5e52200bb6..537f2afea59 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import type { PairedDevice, Platform, StepIndex } from './MobileHero' -import { getInstallCopy, type IosChannel } from './mobile-platform-copy' +import type { IosChannel } from './mobile-platform-copy' import { selectRefreshedNetworkAddress, type MobileNetworkInterface @@ -17,6 +17,8 @@ import { translate } from '@/i18n/i18n' import { useMobilePageEscape } from './use-mobile-page-escape' import { MobilePageContent } from './MobilePageContent' import { useMobileInstallQr } from './use-mobile-install-qr' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import { useMobileInstallActions } from './use-mobile-install-actions' export default function MobilePage(): React.JSX.Element { const [stage, setStage] = useState(null) @@ -30,6 +32,10 @@ export default function MobilePage(): React.JSX.Element { const [pairQrDataUrl, setPairQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) const [pairLoading, setPairLoading] = useState(false) + const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') + const [connectionMode, setConnectionMode] = useState(() => + signedIn ? 'automatic' : 'local-only' + ) const [networkInterfaces, setNetworkInterfaces] = useState([]) const [selectedAddress, setSelectedAddress] = useState(undefined) // Why: tracks whether `selectedAddress` came from the user typing a @@ -41,6 +47,8 @@ export default function MobilePage(): React.JSX.Element { const [revokingDeviceIds, setRevokingDeviceIds] = useState([]) const [deviceCountAtPairStart, setDeviceCountAtPairStart] = useState(null) const hasGeneratedRef = useRef(false) + const pairingRequestIdRef = useRef(0) + const wasSignedInRef = useRef(signedIn) const mountedRef = useMountedRef() const stageRef = useRef(null) const deviceCountAtPairStartRef = useRef(null) @@ -48,6 +56,7 @@ export default function MobilePage(): React.JSX.Element { const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) const updateSettings = useAppStore((s) => s.updateSettings) const installQrUrl = useMobileInstallQr(stage, platform, iosChannel) + const { copyInstallUrl, openInstallUrl } = useMobileInstallActions(platform, iosChannel) const setPairingDeviceBaseline = useCallback( (count: number | null): void => { @@ -163,23 +172,36 @@ export default function MobilePage(): React.JSX.Element { ) const generatePairing = useCallback( - async (rotate: boolean, addressOverride?: string) => { + async ( + rotate: boolean, + addressOverride?: string, + connectionModeOverride?: MobilePairingConnectionMode + ) => { + const requestId = ++pairingRequestIdRef.current + // Mark the request synchronously so state changes cannot make the + // Step 2 auto-generate effect start a second offer in parallel. + hasGeneratedRef.current = true if (mountedRef.current) { setPairLoading(true) } try { const address = addressOverride ?? selectedAddress + const nextConnectionMode = connectionModeOverride ?? connectionMode const result = await window.api.mobile.getPairingQR({ ...(address ? { address } : {}), + connectionMode: nextConnectionMode, ...(rotate ? { rotate: true } : {}) }) + if (requestId !== pairingRequestIdRef.current) { + return + } if (result.available) { if (mountedRef.current) { setPairQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) } - hasGeneratedRef.current = true } else { + hasGeneratedRef.current = false if (mountedRef.current) { toast.error( translate( @@ -190,7 +212,8 @@ export default function MobilePage(): React.JSX.Element { } } } catch { - if (mountedRef.current) { + if (mountedRef.current && requestId === pairingRequestIdRef.current) { + hasGeneratedRef.current = false toast.error( translate( 'auto.components.mobile.MobilePage.4c8bd11c1a', @@ -199,14 +222,42 @@ export default function MobilePage(): React.JSX.Element { ) } } finally { - if (mountedRef.current) { + if (mountedRef.current && requestId === pairingRequestIdRef.current) { setPairLoading(false) } } }, - [mountedRef, selectedAddress] + [connectionMode, mountedRef, selectedAddress] ) + const handleConnectionModeChange = useCallback( + (nextMode: MobilePairingConnectionMode): void => { + if (nextMode === connectionMode) { + return + } + setConnectionMode(nextMode) + // Why: an offer encodes its connection policy. Invalidate the prior + // request before rotating so a late response cannot restore a stale QR. + pairingRequestIdRef.current += 1 + const shouldRegenerate = hasGeneratedRef.current || pairLoading + hasGeneratedRef.current = false + setPairQrDataUrl(null) + setPairingUrl(null) + if (shouldRegenerate) { + void generatePairing(true, undefined, nextMode) + } + }, + [connectionMode, generatePairing, pairLoading] + ) + + useEffect(() => { + const wasSignedIn = wasSignedInRef.current + wasSignedInRef.current = signedIn + if (signedIn !== wasSignedIn) { + handleConnectionModeChange(signedIn ? 'automatic' : 'local-only') + } + }, [handleConnectionModeChange, signedIn]) + const loadNetworkInterfaces = useCallback(async () => { if (mountedRef.current) { setRefreshingNetworkInterfaces(true) @@ -354,28 +405,6 @@ export default function MobilePage(): React.JSX.Element { } } - const openInstallUrl = (): void => { - void window.api.shell.openUrl(getInstallCopy(platform, iosChannel).url) - } - - const copyInstallUrl = async (): Promise => { - try { - await window.api.ui.writeClipboardText(getInstallCopy(platform, iosChannel).url) - if (mountedRef.current) { - toast.success( - translate('auto.components.mobile.MobilePage.fad833de8d', 'Install link copied') - ) - } - } catch (err) { - console.error('writeClipboardText failed', err) - if (mountedRef.current) { - toast.error( - translate('auto.components.mobile.MobilePage.baea63c445', 'Failed to copy link') - ) - } - } - } - const toggleMobileSidebarButton = useCallback(() => { const nextShowMobileButton = !showMobileButton void updateSettings({ showMobileButton: nextShowMobileButton }) @@ -410,6 +439,8 @@ export default function MobilePage(): React.JSX.Element { openInstallUrl={openInstallUrl} pairAnotherDevice={pairAnotherDevice} pairLoading={pairLoading} + connectionMode={connectionMode} + handleConnectionModeChange={handleConnectionModeChange} pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} platform={platform} diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index 00ebf7fe86e..570d781f91d 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -6,6 +6,7 @@ import { getInstallCopy, type IosChannel } from './mobile-platform-copy' import type { MobilePageStage } from './mobile-page-stage' import { MobilePageToolbar } from './MobilePageToolbar' import { PhoneCarousel } from './PhoneCarousel' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' type MobilePageContentProps = { closeMobilePage: () => void @@ -25,6 +26,8 @@ type MobilePageContentProps = { openInstallUrl: () => void pairAnotherDevice: () => void pairLoading: boolean + connectionMode: MobilePairingConnectionMode + handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void pairQrDataUrl: string | null pairingUrl: string | null platform: Platform @@ -58,6 +61,8 @@ export function MobilePageContent({ openInstallUrl, pairAnotherDevice, pairLoading, + connectionMode, + handleConnectionModeChange, pairQrDataUrl, pairingUrl, platform, @@ -104,6 +109,8 @@ export function MobilePageContent({ pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} pairLoading={pairLoading} + connectionMode={connectionMode} + onConnectionModeChange={handleConnectionModeChange} onRegeneratePairing={() => generatePairing(true)} onCopyPairingCode={copyPairingCode} networkInterfaces={networkInterfaces} diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index af45e066a3c..18aa57faeaf 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -4,7 +4,7 @@ import { AddressPicker, type AddressOption } from '../network/AddressPicker' import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' -// Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection +// Why: MobileHero (mobile pairing screen) and MobilePairingSetupSection // (Settings → Mobile) both need the same network selector. This wraps the // generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 // hostname — including Tailscale *.ts.net and DDNS domains — optionally diff --git a/src/renderer/src/components/mobile/use-mobile-install-actions.ts b/src/renderer/src/components/mobile/use-mobile-install-actions.ts new file mode 100644 index 00000000000..8735d2d3a39 --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-install-actions.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import type { Platform } from './MobileHero' +import { getInstallCopy, type IosChannel } from './mobile-platform-copy' + +export function useMobileInstallActions( + platform: Platform, + iosChannel: IosChannel +): { copyInstallUrl: () => Promise; openInstallUrl: () => void } { + const mountedRef = useMountedRef() + + const openInstallUrl = useCallback((): void => { + void window.api.shell.openUrl(getInstallCopy(platform, iosChannel).url) + }, [iosChannel, platform]) + + const copyInstallUrl = useCallback(async (): Promise => { + try { + await window.api.ui.writeClipboardText(getInstallCopy(platform, iosChannel).url) + if (mountedRef.current) { + toast.success( + translate('auto.components.mobile.MobilePage.fad833de8d', 'Install link copied') + ) + } + } catch (error) { + console.error('writeClipboardText failed', error) + if (mountedRef.current) { + toast.error( + translate('auto.components.mobile.MobilePage.baea63c445', 'Failed to copy link') + ) + } + } + }, [iosChannel, mountedRef, platform]) + + return { copyInstallUrl, openInstallUrl } +} diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx deleted file mode 100644 index 47d02ae59ec..00000000000 --- a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' - -import React from 'react' -import { afterEach, describe, it, expect, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' -import type { MobileNetworkInterface } from './mobile-network-interface-selection' -import { TooltipProvider } from '../ui/tooltip' - -// Why: Radix Select/Dialog portal their content to and don't always -// unmount synchronously when the test container tears down, so content can -// leak between tests. Forcing cleanup restores the empty DOM before each render. -afterEach(() => { - cleanup() -}) - -const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } -const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } - -function renderSection( - overrides: Partial> = {} -) { - const onSelectedAddressChange = vi.fn() - const onRefreshNetworkInterfaces = vi.fn() - const onGenerateQr = vi.fn() - const props: React.ComponentProps = { - networkInterfaces: [LAN, TAILNET], - selectedAddress: TAILNET.address, - onSelectedAddressChange, - refreshingNetworkInterfaces: false, - onRefreshNetworkInterfaces, - loading: false, - hasQrCode: false, - onGenerateQr, - ...overrides - } - const user = userEvent.setup() - const utils = render( - - - - ) - return { ...utils, user, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerateQr } -} - -describe('MobileNetworkInterfaceSection', () => { - it('renders the trigger with the currently selected address', () => { - renderSection() - expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') - }) - - it('renders the (custom) label on the trigger when the selection is a manual address', () => { - renderSection({ selectedAddress: 'my-mac.tail-abcd.ts.net' }) - expect(screen.getByRole('combobox')).toHaveTextContent('my-mac.tail-abcd.ts.net (custom)') - }) - - it('commits an OS interface picked from the list', async () => { - const { user, onSelectedAddressChange } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: '192.168.1.24 (en0)' })) - expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24') - }) - - it('opens the custom-address dialog from the Add custom address row', async () => { - const { user } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: /add custom address/i })) - expect(screen.getByRole('dialog')).toBeInTheDocument() - expect(screen.getByLabelText('Address')).toBeInTheDocument() - }) - - it('confirms a valid custom address typed into the dialog', async () => { - const { user, onSelectedAddressChange } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: /add custom address/i })) - await user.type(screen.getByLabelText('Address'), 'my-mac.tail-abcd.ts.net') - await user.click(screen.getByRole('button', { name: /use address/i })) - expect(onSelectedAddressChange).toHaveBeenCalledWith('my-mac.tail-abcd.ts.net') - }) - - it('disables the confirm button while the typed address is invalid', async () => { - const { user, onSelectedAddressChange } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.click(screen.getByRole('option', { name: /add custom address/i })) - await user.type(screen.getByLabelText('Address'), 'not an address') - expect(screen.getByRole('button', { name: /use address/i })).toBeDisabled() - await user.click(screen.getByRole('button', { name: /use address/i })) - expect(onSelectedAddressChange).not.toHaveBeenCalled() - }) - - it('shows No interfaces found when the list is empty', () => { - renderSection({ networkInterfaces: [], selectedAddress: undefined }) - expect(screen.getByRole('combobox')).toHaveTextContent(/no interfaces found/i) - }) -}) diff --git a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx b/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx deleted file mode 100644 index 4f17ffb248c..00000000000 --- a/src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react' -import { ExternalLink, Loader2, QrCode, RefreshCw, Wifi } from 'lucide-react' -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' -import { Button } from '../ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' -import { translate } from '@/i18n/i18n' -import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker' -import type { MobileNetworkInterface } from './mobile-network-interface-selection' - -const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' - -type MobileNetworkInterfaceSectionProps = { - networkInterfaces: MobileNetworkInterface[] - selectedAddress: string | undefined - onSelectedAddressChange: (address: string) => void - refreshingNetworkInterfaces: boolean - onRefreshNetworkInterfaces: () => void - loading: boolean - hasQrCode: boolean - onGenerateQr: () => void -} - -export function MobileNetworkInterfaceSection({ - networkInterfaces, - selectedAddress, - onSelectedAddressChange, - refreshingNetworkInterfaces, - onRefreshNetworkInterfaces, - loading, - hasQrCode, - onGenerateQr -}: MobileNetworkInterfaceSectionProps): React.JSX.Element { - return ( -
-
- - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.406a35121c', - 'Network Interface' - )} - -
-

- {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.d536b5e20d', - 'Choose which network address to advertise in the QR code. Use your LAN address for same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network access.' - )} -

-
-
- - - - - - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d', - 'Refresh network interfaces' - )} - - -
- -
- - - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9', - 'Connect outside your Wi-Fi with a tailnet' - )} - - -

- {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.9fc5d203ff', - 'Orca Mobile connects directly to this computer. To use it away from the same local network, put your computer and phone on the same private overlay network, then generate the QR code with that network address selected.' - )} -

-
    -
  1. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb', - 'Install' - )}{' '} - {' '} - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.668016be7a', - 'on your computer and phone.' - )} -
  2. -
  3. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a', - 'Sign in to the same tailnet on both devices.' - )} -
  4. -
  5. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5', - 'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.' - )} -
  6. -
  7. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e', - 'Regenerate the QR code and scan it from the Orca mobile app.' - )} -
  8. -
-
-
-
-
- ) -} diff --git a/src/renderer/src/components/settings/MobileRelayStatusSection.test.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx similarity index 69% rename from src/renderer/src/components/settings/MobileRelayStatusSection.test.tsx rename to src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx index 990bbc9f3ba..db3f28958de 100644 --- a/src/renderer/src/components/settings/MobileRelayStatusSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status' import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' -import { MobileRelayStatusSection } from './MobileRelayStatusSection' +import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions' type MobileRelayStoreState = { orcaProfileAuthStatus: OrcaProfileAuthStatus | null @@ -27,7 +27,7 @@ vi.mock('../../i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -describe('MobileRelayStatusSection', () => { +describe('MobilePairingConnectionOptions', () => { let statusListener: ((status: MobileRelayStatus) => void) | null const connect = vi.fn().mockResolvedValue(null) @@ -60,18 +60,17 @@ describe('MobileRelayStatusSection', () => { afterEach(() => cleanup()) - it('keeps direct pairing available while offering desktop sign-in', async () => { + it('offers local-only pairing while Relay requires sign-in', async () => { const user = userEvent.setup() - render() + render() - expect( - screen.getByText('LAN and Tailscale pairing still work without an account.') - ).toBeVisible() + expect(screen.getByRole('radio', { name: /connect from anywhere/i })).toBeDisabled() + expect(screen.getByRole('radio', { name: /local network only/i })).toBeChecked() await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(connect).toHaveBeenCalledOnce() }) - it('shows live automatic relay status for a signed-in desktop', async () => { + it('selects either automatic fallback or local-only pairing when signed in', async () => { mocks.state = { orcaProfileAuthStatus: { activeProfileId: 'profile-1', @@ -82,13 +81,14 @@ describe('MobileRelayStatusSection', () => { orcaProfileConnecting: false, connectCurrentOrcaProfile: connect } - render() + const onChange = vi.fn() + const user = userEvent.setup() + render() - await waitFor(() => expect(screen.getByText('Registered')).toBeVisible()) - statusListener?.('offline') - await waitFor(() => expect(screen.getByText('Offline')).toBeVisible()) + await waitFor(() => expect(screen.getByText('Ready')).toBeVisible()) + await user.click(screen.getByRole('radio', { name: /local network only/i })) + expect(onChange).toHaveBeenCalledWith('local-only') statusListener?.('standby') - await waitFor(() => expect(screen.getByText('Standby — no relay devices')).toBeVisible()) - expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument() + await waitFor(() => expect(screen.getByText('Available')).toBeVisible()) }) }) diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx new file mode 100644 index 00000000000..f28abcf2491 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { cn } from '../../lib/utils' +import { translate } from '../../i18n/i18n' +import { useAppStore } from '../../store' +import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' + +function relayStatusLabel(status: MobileRelayStatus): string { + if (status === 'registered') { + return translate('auto.components.settings.MobilePairingConnectionOptions.ready', 'Ready') + } + if (status === 'connecting') { + return translate( + 'auto.components.settings.MobilePairingConnectionOptions.connecting', + 'Connecting' + ) + } + if (status === 'standby') { + return translate( + 'auto.components.settings.MobilePairingConnectionOptions.available', + 'Available' + ) + } + if (status === 'draining') { + return translate( + 'auto.components.settings.MobilePairingConnectionOptions.reconnecting', + 'Reconnecting' + ) + } + return translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + ) +} + +export function MobilePairingConnectionOptions({ + value, + onChange, + compact = false +}: { + value: MobilePairingConnectionMode + onChange: (value: MobilePairingConnectionMode) => void + compact?: boolean +}): 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.MobilePairingConnectionOptions.title', + 'How should the new phone connect?' + )} +

+
+
+ + {signedIn ? ( + + {relayStatusLabel(relayStatus)} + + ) : configured ? ( + + ) : ( + + {translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + )} + + )} +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx new file mode 100644 index 00000000000..25703b9fc99 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MobilePairingSetupSection } from './MobilePairingSetupSection' +import type { MobileNetworkInterface } from './mobile-network-interface-selection' +import { TooltipProvider } from '../ui/tooltip' + +afterEach(() => cleanup()) + +const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } +const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } + +function renderSection( + overrides: Partial> = {} +) { + const onSelectedAddressChange = vi.fn() + const onRefreshNetworkInterfaces = vi.fn() + const onGenerateQr = vi.fn() + const props: React.ComponentProps = { + connectionMode: 'local-only', + networkInterfaces: [LAN, TAILNET], + selectedAddress: TAILNET.address, + onSelectedAddressChange, + refreshingNetworkInterfaces: false, + onRefreshNetworkInterfaces, + loading: false, + hasQrCode: false, + onGenerateQr, + ...overrides + } + const user = userEvent.setup() + const rendered = render( + + + + ) + return { ...rendered, user, onSelectedAddressChange, onGenerateQr } +} + +describe('MobilePairingSetupSection', () => { + it('opens local settings by default for local-only pairing', () => { + renderSection() + expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') + expect(screen.getByText(/connects only through the local network address/i)).toBeVisible() + }) + + it('keeps local settings advanced for automatic Relay fallback', async () => { + const { user } = renderSection({ connectionMode: 'automatic' }) + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect( + screen.getByText(/includes direct access and encrypted Orca Relay fallback/i) + ).toBeVisible() + await user.click(screen.getByRole('button', { name: 'Local connection settings' })) + expect(screen.getByRole('combobox')).toBeVisible() + }) + + it('commits an OS interface picked from the list', async () => { + const { user, onSelectedAddressChange } = renderSection() + await user.click(screen.getByRole('combobox')) + await user.click(screen.getByRole('option', { name: '192.168.1.24 (en0)' })) + expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24') + }) + + it('generates a pairing code with the selected mode', async () => { + const { user, onGenerateQr } = renderSection() + await user.click(screen.getByRole('button', { name: 'Generate QR Code' })) + expect(onGenerateQr).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx new file mode 100644 index 00000000000..80c2bfdf924 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -0,0 +1,157 @@ +import { ExternalLink, Loader2, QrCode, RefreshCw } from 'lucide-react' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { translate } from '@/i18n/i18n' +import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker' +import type { MobileNetworkInterface } from './mobile-network-interface-selection' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' + +const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' + +type MobilePairingSetupSectionProps = { + connectionMode: MobilePairingConnectionMode + networkInterfaces: MobileNetworkInterface[] + selectedAddress: string | undefined + onSelectedAddressChange: (address: string) => void + refreshingNetworkInterfaces: boolean + onRefreshNetworkInterfaces: () => void + loading: boolean + hasQrCode: boolean + onGenerateQr: () => void +} + +export function MobilePairingSetupSection({ + connectionMode, + networkInterfaces, + selectedAddress, + onSelectedAddressChange, + refreshingNetworkInterfaces, + onRefreshNetworkInterfaces, + loading, + hasQrCode, + onGenerateQr +}: MobilePairingSetupSectionProps): React.JSX.Element { + return ( +
+

+ {translate('auto.components.settings.MobilePairingSetupSection.title', 'Pair a phone')} +

+

+ {connectionMode === 'automatic' + ? translate( + 'auto.components.settings.MobilePairingSetupSection.automaticDescription', + 'The pairing code includes direct access and encrypted Orca Relay fallback.' + ) + : translate( + 'auto.components.settings.MobilePairingSetupSection.localDescription', + 'The pairing code connects only through the local network address below.' + )} +

+ + + + + + {translate( + 'auto.components.settings.MobilePairingSetupSection.localSettings', + 'Local connection settings' + )} + + +

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.localAddressDescription', + 'Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.' + )} +

+
+ + + + + + + {translate( + 'auto.components.settings.MobilePairingSetupSection.refresh', + 'Refresh network interfaces' + )} + + +
+
+
+ + + {translate( + 'auto.components.settings.MobilePairingSetupSection.tailnet', + 'Connect with your own tailnet' + )} + + +

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.tailnetDescription', + 'Install Tailscale on this computer and your phone, sign in to the same tailnet, then select its 100.x.y.z address above.' + )} +

+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index fbff19584d0..e8785d97325 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -7,13 +7,14 @@ import { selectRefreshedNetworkAddress, type MobileNetworkInterface } from './mobile-network-interface-selection' -import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' import { MobilePairingQrSection } from './MobilePairingQrSection' import { MobilePairedDevicesSection, type PairedDevice } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' -import { MobileRelayStatusSection } from './MobileRelayStatusSection' +import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions' +import { MobilePairingSetupSection } from './MobilePairingSetupSection' import { WindowsFirewallNotice } from '../mobile/WindowsFirewallNotice' import { translate } from '@/i18n/i18n' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' export { getMobilePaneSearchEntries } from './mobile-pane-search' export function MobilePane(): React.JSX.Element { @@ -30,7 +31,13 @@ export function MobilePane(): React.JSX.Element { const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const [codeCopied, setCodeCopied] = useState(false) const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) + const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') + const [connectionMode, setConnectionMode] = useState(() => + signedIn ? 'automatic' : 'local-only' + ) + const [rotateNextQr, setRotateNextQr] = useState(false) const devicesRef = useRef([]) + const wasSignedInRef = useRef(signedIn) const codeCopiedResetTimerRef = useRef(null) const mountedRef = useMountedRef() @@ -88,7 +95,8 @@ export function MobilePane(): React.JSX.Element { try { const result = await window.api.mobile.getPairingQR({ ...(selectedAddress ? { address: selectedAddress } : {}), - ...(opts.rotate ? { rotate: true } : {}) + connectionMode, + ...(opts.rotate || rotateNextQr ? { rotate: true } : {}) }) if (result.available) { useAppStore.getState().recordFeatureInteraction('mobile-pairing') @@ -99,6 +107,7 @@ export function MobilePane(): React.JSX.Element { setDeviceCountAtQr(devicesRef.current.length) clearCodeCopiedResetTimer() setCodeCopied(false) + setRotateNextQr(false) void loadDevices() } } else { @@ -126,7 +135,32 @@ export function MobilePane(): React.JSX.Element { } } }, - [clearCodeCopiedResetTimer, loadDevices, mountedRef, selectedAddress] + [ + clearCodeCopiedResetTimer, + connectionMode, + loadDevices, + mountedRef, + rotateNextQr, + selectedAddress + ] + ) + + const changeConnectionMode = useCallback( + (nextMode: MobilePairingConnectionMode) => { + if (nextMode === connectionMode) { + return + } + setConnectionMode(nextMode) + if (qrDataUrl) { + // Why: a displayed code encodes the old connection policy. Hide it and + // rotate its pending credential before showing a code for the new mode. + setQrDataUrl(null) + setPairingUrl(null) + setEndpoint(null) + setRotateNextQr(true) + } + }, + [connectionMode, qrDataUrl] ) useEffect(() => { @@ -134,6 +168,14 @@ export function MobilePane(): React.JSX.Element { void loadNetworkInterfaces() }, [loadDevices, loadNetworkInterfaces]) + useEffect(() => { + const wasSignedIn = wasSignedInRef.current + wasSignedInRef.current = signedIn + if (signedIn !== wasSignedIn) { + changeConnectionMode(signedIn ? 'automatic' : 'local-only') + } + }, [changeConnectionMode, signedIn]) + useMobilePairingDevicePolling({ deviceCountAtQr, currentDeviceCount: devices.length, @@ -162,9 +204,10 @@ export function MobilePane(): React.JSX.Element { return (
- + - 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 when a phone is paired' - ) - : 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 9ccad3441a0..58e95f495a7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9046,6 +9046,34 @@ "signIn": "Sign in", "unavailable": "Unavailable", "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "Ready", + "connecting": "Connecting", + "available": "Available", + "reconnecting": "Reconnecting", + "unavailable": "Unavailable", + "title": "How should the new phone connect?", + "anywhere": "Connect from anywhere", + "recommended": "Recommended", + "automaticDescription": "Orca uses a direct connection when available and Relay otherwise.", + "signInDescription": "Sign in on this desktop to use Orca Relay.", + "signIn": "Sign in", + "localOnly": "Local network only", + "localDescription": "Uses LAN or Tailscale without connecting this phone through Orca Relay." + }, + "MobilePairingSetupSection": { + "title": "Pair a phone", + "automaticDescription": "The pairing code includes direct access and encrypted Orca Relay fallback.", + "localDescription": "The pairing code connects only through the local network address below.", + "regenerate": "Regenerate", + "generate": "Generate QR Code", + "localSettings": "Local connection settings", + "localAddressDescription": "Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.", + "refresh": "Refresh network interfaces", + "tailnet": "Connect with your own tailnet", + "tailnetDescription": "Install Tailscale on this computer and your phone, sign in to the same tailnet, then select its 100.x.y.z address above.", + "getTailscale": "Get Tailscale" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ae78d1de6c4..235cfd3d9af 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9046,6 +9046,34 @@ "signIn": "Sign in", "unavailable": "Unavailable", "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "Listo", + "connecting": "Conectando", + "available": "Disponible", + "reconnecting": "Reconectando", + "unavailable": "No disponible", + "title": "¿Cómo debe conectarse el teléfono nuevo?", + "anywhere": "Conectarse desde cualquier lugar", + "recommended": "Recomendado", + "automaticDescription": "Orca usa una conexión directa cuando está disponible y Relay en caso contrario.", + "signInDescription": "Inicia sesión en este ordenador para usar Orca Relay.", + "signIn": "Iniciar sesión", + "localOnly": "Solo red local", + "localDescription": "Usa LAN o Tailscale sin conectar este teléfono mediante Orca Relay." + }, + "MobilePairingSetupSection": { + "title": "Vincular un teléfono", + "automaticDescription": "El código de vinculación incluye acceso directo y Orca Relay cifrado como respaldo.", + "localDescription": "El código de vinculación solo se conecta mediante la dirección de red local indicada abajo.", + "regenerate": "Regenerar", + "generate": "Generar código QR", + "localSettings": "Configuración de conexión local", + "localAddressDescription": "Elige la dirección LAN o de red privada que Orca Mobile puede usar para acceder directamente a este ordenador.", + "refresh": "Actualizar interfaces de red", + "tailnet": "Conectarse con tu propia tailnet", + "tailnetDescription": "Instala Tailscale en este ordenador y en tu teléfono, inicia sesión en la misma tailnet y selecciona arriba su dirección 100.x.y.z.", + "getTailscale": "Obtener Tailscale" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f7ee4d26e96..3bc59695719 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9046,6 +9046,34 @@ "signIn": "Sign in", "unavailable": "Unavailable", "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "準備完了", + "connecting": "接続中", + "available": "利用可能", + "reconnecting": "再接続中", + "unavailable": "利用不可", + "title": "新しいスマートフォンをどのように接続しますか?", + "anywhere": "どこからでも接続", + "recommended": "推奨", + "automaticDescription": "利用可能な場合は直接接続し、それ以外ではOrca Relayを使用します。", + "signInDescription": "Orca Relayを使用するには、このデスクトップでサインインしてください。", + "signIn": "サインイン", + "localOnly": "ローカルネットワークのみ", + "localDescription": "このスマートフォンをOrca Relayに接続せず、LANまたはTailscaleを使用します。" + }, + "MobilePairingSetupSection": { + "title": "スマートフォンをペアリング", + "automaticDescription": "ペアリングコードには、直接接続と暗号化されたOrca Relayのフォールバックが含まれます。", + "localDescription": "ペアリングコードは、下のローカルネットワークアドレスだけを使用します。", + "regenerate": "再生成", + "generate": "QRコードを生成", + "localSettings": "ローカル接続設定", + "localAddressDescription": "Orca Mobileからこのコンピューターへ直接接続できるLANまたはプライベートネットワークのアドレスを選択します。", + "refresh": "ネットワークインターフェースを更新", + "tailnet": "自分のtailnetで接続", + "tailnetDescription": "このコンピューターとスマートフォンにTailscaleをインストールし、同じtailnetにサインインして、上で100.x.y.zアドレスを選択します。", + "getTailscale": "Tailscaleを入手" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 49b8ffef71e..de0b98a6424 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9046,6 +9046,34 @@ "signIn": "Sign in", "unavailable": "Unavailable", "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "준비됨", + "connecting": "연결 중", + "available": "사용 가능", + "reconnecting": "다시 연결 중", + "unavailable": "사용할 수 없음", + "title": "새 휴대폰을 어떻게 연결할까요?", + "anywhere": "어디서나 연결", + "recommended": "권장", + "automaticDescription": "가능하면 직접 연결하고, 그렇지 않으면 Orca Relay를 사용합니다.", + "signInDescription": "Orca Relay를 사용하려면 이 데스크톱에서 로그인하세요.", + "signIn": "로그인", + "localOnly": "로컬 네트워크만", + "localDescription": "이 휴대폰을 Orca Relay에 연결하지 않고 LAN 또는 Tailscale을 사용합니다." + }, + "MobilePairingSetupSection": { + "title": "휴대폰 페어링", + "automaticDescription": "페어링 코드에는 직접 연결과 암호화된 Orca Relay 대체 연결이 포함됩니다.", + "localDescription": "페어링 코드는 아래의 로컬 네트워크 주소로만 연결합니다.", + "regenerate": "다시 생성", + "generate": "QR 코드 생성", + "localSettings": "로컬 연결 설정", + "localAddressDescription": "Orca Mobile에서 이 컴퓨터에 직접 연결할 수 있는 LAN 또는 사설 네트워크 주소를 선택하세요.", + "refresh": "네트워크 인터페이스 새로 고침", + "tailnet": "내 tailnet으로 연결", + "tailnetDescription": "이 컴퓨터와 휴대폰에 Tailscale을 설치하고 같은 tailnet에 로그인한 다음 위에서 100.x.y.z 주소를 선택하세요.", + "getTailscale": "Tailscale 받기" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c5dc100ba4c..c7106449dcc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9046,6 +9046,34 @@ "signIn": "Sign in", "unavailable": "Unavailable", "standby": "Standby — no relay devices" + }, + "MobilePairingConnectionOptions": { + "ready": "已就绪", + "connecting": "正在连接", + "available": "可用", + "reconnecting": "正在重新连接", + "unavailable": "不可用", + "title": "新手机应如何连接?", + "anywhere": "随时随地连接", + "recommended": "推荐", + "automaticDescription": "Orca 会优先使用直接连接,否则使用 Orca Relay。", + "signInDescription": "请在此桌面端登录以使用 Orca Relay。", + "signIn": "登录", + "localOnly": "仅限本地网络", + "localDescription": "使用局域网或 Tailscale,不通过 Orca Relay 连接这部手机。" + }, + "MobilePairingSetupSection": { + "title": "配对手机", + "automaticDescription": "配对码包含直接访问方式和加密的 Orca Relay 备用连接。", + "localDescription": "配对码仅通过下方的本地网络地址连接。", + "regenerate": "重新生成", + "generate": "生成二维码", + "localSettings": "本地连接设置", + "localAddressDescription": "选择 Orca Mobile 可用于直接访问此电脑的局域网或专用网络地址。", + "refresh": "刷新网络接口", + "tailnet": "使用自己的 tailnet 连接", + "tailnetDescription": "在此电脑和手机上安装 Tailscale,登录同一个 tailnet,然后在上方选择其 100.x.y.z 地址。", + "getTailscale": "获取 Tailscale" } }, "right": { diff --git a/src/shared/mobile-pairing-connection-mode.ts b/src/shared/mobile-pairing-connection-mode.ts new file mode 100644 index 00000000000..cde61a6fde5 --- /dev/null +++ b/src/shared/mobile-pairing-connection-mode.ts @@ -0,0 +1 @@ +export type MobilePairingConnectionMode = 'automatic' | 'local-only'