feat(mobile): add per-device pairing connection mode

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-13 19:47:10 -04:00
co-authored by Orca
parent 36166ffcc8
commit b1b491cc36
32 changed files with 1165 additions and 476 deletions
@@ -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')
})
})
@@ -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'
}
+20
View File
@@ -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: () => ({
+10 -1
View File
@@ -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()}`
})
+25 -1
View File
@@ -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 = []
@@ -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')
})
})
@@ -135,6 +135,12 @@ export class DesktopRelayService {
params: PairingGetEndpointsParams
): Promise<PairingGetEndpointsResult> {
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<DeviceCredentialInstalled> {
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) {
+125
View File
@@ -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({
+23 -5
View File
@@ -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<ReturnType<OrcaRuntimeRpcServer['createPairingOffer']>> {
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)
+6 -1
View File
@@ -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
+2
View File
@@ -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 }
+1 -1
View File
@@ -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;
}
@@ -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.')}
</p>
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
// Why: keep the picker reachable when interface discovery is
// empty — "Add custom address…" is the only path to enter a
// manual Tailscale hostname / static IP.
disabled={false}
className="mp-network-select"
/>
<button
type="button"
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
<MobilePairingConnectionOptions
value={connectionMode}
onChange={onConnectionModeChange}
compact
/>
{connectionMode === 'local-only' ? (
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
// Why: when discovery is empty, a custom address is the
// only route to a Tailscale hostname or static IP.
disabled={false}
className="mp-network-select"
/>
<button
type="button"
className={cn(
'mp-network-refresh',
refreshingNetworkInterfaces && 'is-spinning'
)}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
) : null}
<div className="mp-inline-actions">
<span className="mp-action-divider">
@@ -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<void>
}
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
}) => (
<div>
<span data-testid="stage">{props.stage ?? 'loading'}</span>
<span data-testid="step">{props.stepIdx}</span>
<span data-testid="mode">{props.connectionMode}</span>
<span data-testid="pairing-url">{props.pairingUrl ?? 'none'}</span>
<button type="button" onClick={props.enterFlow}>
Enter flow
</button>
<button type="button" onClick={props.handleContinue}>
Continue
</button>
<button type="button" onClick={() => props.handleConnectionModeChange('automatic')}>
Anywhere
</button>
<button type="button" onClick={() => props.handleConnectionModeChange('local-only')}>
Local only
</button>
</div>
)
}))
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<void> {
const user = userEvent.setup()
render(<MobilePage />)
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')
})
})
@@ -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<FlowStage | null>(null)
@@ -30,6 +32,10 @@ export default function MobilePage(): React.JSX.Element {
const [pairQrDataUrl, setPairQrDataUrl] = useState<string | null>(null)
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
const [pairLoading, setPairLoading] = useState(false)
const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected')
const [connectionMode, setConnectionMode] = useState<MobilePairingConnectionMode>(() =>
signedIn ? 'automatic' : 'local-only'
)
const [networkInterfaces, setNetworkInterfaces] = useState<MobileNetworkInterface[]>([])
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(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<string[]>([])
const [deviceCountAtPairStart, setDeviceCountAtPairStart] = useState<number | null>(null)
const hasGeneratedRef = useRef(false)
const pairingRequestIdRef = useRef(0)
const wasSignedInRef = useRef(signedIn)
const mountedRef = useMountedRef()
const stageRef = useRef<FlowStage | null>(null)
const deviceCountAtPairStartRef = useRef<number | null>(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<void> => {
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}
@@ -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}
@@ -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
@@ -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<void>; 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<void> => {
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 }
}
@@ -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 <body> 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<React.ComponentProps<typeof MobileNetworkInterfaceSection>> = {}
) {
const onSelectedAddressChange = vi.fn()
const onRefreshNetworkInterfaces = vi.fn()
const onGenerateQr = vi.fn()
const props: React.ComponentProps<typeof MobileNetworkInterfaceSection> = {
networkInterfaces: [LAN, TAILNET],
selectedAddress: TAILNET.address,
onSelectedAddressChange,
refreshingNetworkInterfaces: false,
onRefreshNetworkInterfaces,
loading: false,
hasQrCode: false,
onGenerateQr,
...overrides
}
const user = userEvent.setup()
const utils = render(
<TooltipProvider>
<MobileNetworkInterfaceSection {...props} />
</TooltipProvider>
)
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)
})
})
@@ -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 (
<div className="rounded-lg border border-border/60 p-4">
<div className="mb-3 flex items-center gap-2">
<Wifi className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.406a35121c',
'Network Interface'
)}
</span>
</div>
<p className="text-muted-foreground mb-3 text-xs">
{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.'
)}
</p>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
className="min-w-[220px] justify-between font-normal"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d',
'Refresh network interfaces'
)}
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d',
'Refresh network interfaces'
)}
</TooltipContent>
</Tooltip>
</div>
<Button
onClick={onGenerateQr}
disabled={loading || !selectedAddress}
size="sm"
className="gap-1.5"
>
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : hasQrCode ? (
<RefreshCw className="size-3.5" />
) : (
<QrCode className="size-3.5" />
)}
{hasQrCode
? translate(
'auto.components.settings.MobileNetworkInterfaceSection.1e64659126',
'Regenerate'
)
: translate(
'auto.components.settings.MobileNetworkInterfaceSection.c541f67790',
'Generate QR Code'
)}
</Button>
</div>
<Accordion type="single" collapsible className="mt-4 border-t border-border/60 pt-2">
<AccordionItem value="remote-pairing-guide">
<AccordionTrigger className="py-2 text-xs">
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9',
'Connect outside your Wi-Fi with a tailnet'
)}
</AccordionTrigger>
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
<p>
{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.'
)}
</p>
<ol className="list-decimal space-y-1 pl-4">
<li>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb',
'Install'
)}{' '}
<button
type="button"
onClick={() => void window.api.shell.openUrl(TAILSCALE_DOWNLOAD_URL)}
className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.1dc87a7fbc',
'Tailscale'
)}
<ExternalLink className="size-3" />
</button>{' '}
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.668016be7a',
'on your computer and phone.'
)}
</li>
<li>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a',
'Sign in to the same tailnet on both devices.'
)}
</li>
<li>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5',
'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.'
)}
</li>
<li>
{translate(
'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e',
'Regenerate the QR code and scan it from the Orca mobile app.'
)}
</li>
</ol>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)
}
@@ -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(<MobileRelayStatusSection />)
render(<MobilePairingConnectionOptions value="local-only" onChange={vi.fn()} />)
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(<MobileRelayStatusSection />)
const onChange = vi.fn()
const user = userEvent.setup()
render(<MobilePairingConnectionOptions value="automatic" onChange={onChange} />)
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())
})
})
@@ -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<MobileRelayStatus>('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 (
<section className={cn('space-y-2', compact && 'max-w-md')}>
<h3 className="text-sm font-medium">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.title',
'How should the new phone connect?'
)}
</h3>
<div className="space-y-2" role="radiogroup">
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-border/60 px-3',
compact ? 'py-2' : 'py-3',
value === 'automatic' && signedIn && 'bg-accent'
)}
>
<label
className={cn('flex min-w-0 flex-1 items-start gap-3', signedIn && 'cursor-pointer')}
>
<input
type="radio"
name="mobile-pairing-connection-mode"
value="automatic"
checked={value === 'automatic'}
disabled={!signedIn}
onChange={() => onChange('automatic')}
className="mt-0.5 size-4 shrink-0 accent-primary"
/>
<span className="min-w-0 space-y-0.5">
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.anywhere',
'Connect from anywhere'
)}
<span className="text-xs font-normal text-muted-foreground">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.recommended',
'Recommended'
)}
</span>
</span>
<span className="block text-xs text-muted-foreground">
{signedIn
? translate(
'auto.components.settings.MobilePairingConnectionOptions.automaticDescription',
'Orca uses a direct connection when available and Relay otherwise.'
)
: translate(
'auto.components.settings.MobilePairingConnectionOptions.signInDescription',
'Sign in on this desktop to use Orca Relay.'
)}
</span>
</span>
</label>
{signedIn ? (
<Badge variant="outline" className="shrink-0">
{relayStatusLabel(relayStatus)}
</Badge>
) : configured ? (
<Button
type="button"
size="sm"
className="w-24 shrink-0"
disabled={connecting}
onClick={() => void connect()}
>
{connecting ? <Loader2 className="animate-spin" /> : null}
{translate(
'auto.components.settings.MobilePairingConnectionOptions.signIn',
'Sign in'
)}
</Button>
) : (
<Badge variant="outline" className="shrink-0">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.unavailable',
'Unavailable'
)}
</Badge>
)}
</div>
<label
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg border border-border/60 px-3',
compact ? 'py-2' : 'py-3',
value === 'local-only' && 'bg-accent'
)}
>
<input
type="radio"
name="mobile-pairing-connection-mode"
value="local-only"
checked={value === 'local-only'}
onChange={() => onChange('local-only')}
className="mt-0.5 size-4 shrink-0 accent-primary"
/>
<span className="min-w-0 space-y-0.5">
<span className="block text-sm font-medium">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.localOnly',
'Local network only'
)}
</span>
<span className="block text-xs text-muted-foreground">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.localDescription',
'Uses LAN or Tailscale without connecting this phone through Orca Relay.'
)}
</span>
</span>
</label>
</div>
</section>
)
}
@@ -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<React.ComponentProps<typeof MobilePairingSetupSection>> = {}
) {
const onSelectedAddressChange = vi.fn()
const onRefreshNetworkInterfaces = vi.fn()
const onGenerateQr = vi.fn()
const props: React.ComponentProps<typeof MobilePairingSetupSection> = {
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(
<TooltipProvider>
<MobilePairingSetupSection {...props} />
</TooltipProvider>
)
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()
})
})
@@ -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 (
<section className="rounded-lg border border-border/60 p-4">
<h3 className="text-sm font-medium">
{translate('auto.components.settings.MobilePairingSetupSection.title', 'Pair a phone')}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{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.'
)}
</p>
<Button
onClick={onGenerateQr}
disabled={loading || !selectedAddress}
size="sm"
className="mt-3 gap-1.5"
>
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : hasQrCode ? (
<RefreshCw className="size-3.5" />
) : (
<QrCode className="size-3.5" />
)}
{hasQrCode
? translate('auto.components.settings.MobilePairingSetupSection.regenerate', 'Regenerate')
: translate(
'auto.components.settings.MobilePairingSetupSection.generate',
'Generate QR Code'
)}
</Button>
<Accordion
key={connectionMode}
type="single"
collapsible
defaultValue={connectionMode === 'local-only' ? 'local-address' : undefined}
className="mt-4 border-t border-border/60 pt-2"
>
<AccordionItem value="local-address">
<AccordionTrigger className="py-2 text-xs">
{translate(
'auto.components.settings.MobilePairingSetupSection.localSettings',
'Local connection settings'
)}
</AccordionTrigger>
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
<p>
{translate(
'auto.components.settings.MobilePairingSetupSection.localAddressDescription',
'Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.'
)}
</p>
<div className="flex flex-wrap items-center gap-3">
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
className="min-w-[220px] justify-between font-normal"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.settings.MobilePairingSetupSection.refresh',
'Refresh network interfaces'
)}
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.settings.MobilePairingSetupSection.refresh',
'Refresh network interfaces'
)}
</TooltipContent>
</Tooltip>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="tailnet-guide">
<AccordionTrigger className="py-2 text-xs">
{translate(
'auto.components.settings.MobilePairingSetupSection.tailnet',
'Connect with your own tailnet'
)}
</AccordionTrigger>
<AccordionContent className="space-y-3 text-xs text-muted-foreground">
<p>
{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.'
)}
</p>
<button
type="button"
onClick={() => void window.api.shell.openUrl(TAILSCALE_DOWNLOAD_URL)}
className="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
>
{translate(
'auto.components.settings.MobilePairingSetupSection.getTailscale',
'Get Tailscale'
)}
<ExternalLink className="size-3" />
</button>
</AccordionContent>
</AccordionItem>
</Accordion>
</section>
)
}
@@ -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<number | null>(null)
const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected')
const [connectionMode, setConnectionMode] = useState<MobilePairingConnectionMode>(() =>
signedIn ? 'automatic' : 'local-only'
)
const [rotateNextQr, setRotateNextQr] = useState(false)
const devicesRef = useRef<PairedDevice[]>([])
const wasSignedInRef = useRef(signedIn)
const codeCopiedResetTimerRef = useRef<number | null>(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 (
<div className="space-y-6">
<MobileRelayStatusSection />
<MobilePairingConnectionOptions value={connectionMode} onChange={changeConnectionMode} />
<MobileNetworkInterfaceSection
<MobilePairingSetupSection
connectionMode={connectionMode}
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={setSelectedAddress}
@@ -1,118 +0,0 @@
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 === 'standby') {
return translate(
'auto.components.settings.MobileRelayStatusSection.standby',
'Standby — no relay devices'
)
}
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<MobileRelayStatus>('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 (
<section className="space-y-2">
<h3 className="text-sm font-medium">
{translate('auto.components.settings.MobileRelayStatusSection.title', 'Orca Relay')}
</h3>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 px-3 py-2.5">
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium">
{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'
)}
</p>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
{signedIn ? (
<Badge variant="outline" className="shrink-0">
{relayStatusLabel(relayStatus)}
</Badge>
) : configured ? (
<Button
type="button"
size="sm"
className="w-24 shrink-0"
disabled={connecting}
onClick={() => void connect()}
>
{connecting ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.settings.MobileRelayStatusSection.signIn', 'Sign in')}
</Button>
) : (
<Badge variant="outline" className="shrink-0">
{translate(
'auto.components.settings.MobileRelayStatusSection.unavailable',
'Unavailable'
)}
</Badge>
)}
</div>
</section>
)
}
+28
View File
@@ -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": {
+28
View File
@@ -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": {
+28
View File
@@ -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": {
+28
View File
@@ -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": {
+28
View File
@@ -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": {
@@ -0,0 +1 @@
export type MobilePairingConnectionMode = 'automatic' | 'local-only'