Demote address picker to optional disclosure on Orca Relay (#12796)

* refactor(mobile): demote address picker to optional disclosure on Relay

Relay provides remote access without requiring a specific local address,
so hide the picker behind a disclosure to keep the direct fast path
accessible without visual clutter. Reposition Sign in between the Relay
and LAN options to clarify it's Relay-specific. Keep custom addresses
always visible and force the disclosure open when settings search
targets the address picker.

* refactor(mobile): improve relay pairing guide and interface ranking

- Rank Docker/VirtualBox bridges below real LAN addresses so they're never auto-advertised as the default
- Clarify UI copy: 'Local network address (optional)' → 'Direct connection on this network'
- Better explain direct connection vs Relay roles and when each is used
- Fix Relay unavailability to be a build property, not dependent on current selection

* refactor(mobile): reframe local network address as optional in relay pai

Demote the address picker from primary action styling to an optional
disclosure with quieter visual treatment. Update messaging from "Direct
connection on this network" to "Also use a faster local path" to
clarify Relay is the default path and local addressing only applies
when nearby. Add explanatory hint text to set expectations that Relay
remains available when away.
This commit is contained in:
Jinjing 2026-08-06 00:34:42 -07:00 committed by GitHub
parent 1251e5530f
commit c3db2b89fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1032 additions and 335 deletions

View File

@ -126,6 +126,51 @@ describe('registerMobileHandlers', () => {
})
})
it('ranks container and VM bridges below every reachable address', () => {
// Why: a phone can never reach docker0, so advertising it makes the direct
// path lose the pairing race and silently relays every session.
networkInterfacesMock.mockReturnValue({
docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }],
'vEthernet (Default Switch)': [{ family: 'IPv4', internal: false, address: '172.28.80.1' }],
bridge0: [{ family: 'IPv4', internal: false, address: '169.254.60.1' }],
en0: [
{ family: 'IPv6', internal: false, address: '2605:340:cd51:2a01:0:2b13:f279:c096' },
{ family: 'IPv4', internal: false, address: '192.168.1.24' }
]
})
registerMobileHandlers({} as never)
// Real IPv6 outranks a bridge IPv4: the phone can reach one, never the other.
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
interfaces: [
{ name: 'en0', address: '192.168.1.24' },
{ name: 'en0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' },
{ name: 'docker0', address: '172.17.0.1' },
{ name: 'vEthernet (Default Switch)', address: '172.28.80.1' },
{ name: 'bridge0', address: '169.254.60.1' }
]
})
})
it('keeps a real LAN address that merely overlaps a container subnet', () => {
// Why: Docker's 172.16/12 pool overlaps genuine corporate LANs, so the
// bridge check keys on interface name — a subnet test would demote this.
networkInterfacesMock.mockReturnValue({
eth0: [{ family: 'IPv4', internal: false, address: '172.17.4.9' }],
docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }]
})
registerMobileHandlers({} as never)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
interfaces: [
{ name: 'eth0', address: '172.17.4.9' },
{ name: 'docker0', address: '172.17.0.1' }
]
})
})
it('returns an IPv6 interface on an IPv6-only host (regression: was empty, breaking mobile pairing)', () => {
networkInterfacesMock.mockReturnValue({
eth0: [

View File

@ -35,6 +35,19 @@ function isProxyFakeIpIPv4Address(address: string): boolean {
return /^198\.(?:18|19)\./.test(address)
}
// Why: container/VM bridges are host-local — a phone can never reach docker0 or
// vmnet8 — but they enumerate as ordinary non-internal IPv4, so advertising one
// makes the direct path silently lose the pairing race and every session relay.
// Keyed on interface name, not subnet: Docker's 172.16/12 pool overlaps real
// corporate LANs, so an address test would demote genuine addresses. These stay
// pickable in the UI; they are only ranked below a real LAN address.
const VIRTUAL_BRIDGE_INTERFACE_PATTERN =
/^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|^vEthernet |VMware Network Adapter|VirtualBox Host-Only/i
function isVirtualBridgeInterface(name: string): boolean {
return VIRTUAL_BRIDGE_INTERFACE_PATTERN.test(name)
}
// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't
// connectable from a mobile device. We enumerate all non-internal IPv4 and
// (non-link-local) IPv6 addresses so the user can choose which one to advertise
@ -64,15 +77,17 @@ function getNetworkInterfaces(): NetworkInterface[] {
}
}
// Why: prefer tailnet IPv4 first (most portable across networks), then other
// IPv4, then IPv6 as a fallback for IPv6-only environments.
return result.sort((a, b) => rankAddress(a.address) - rankAddress(b.address))
// IPv4, then IPv6 as a fallback for IPv6-only environments. Virtual bridges
// sort below both so they are never the auto-advertised default.
return result.sort((a, b) => rankInterface(a) - rankInterface(b))
}
function rankAddress(address: string): number {
function rankInterface({ name, address }: NetworkInterface): number {
if (isTailnetIPv4Address(address)) {
return 0
}
return address.includes(':') ? 2 : 1
const bridgePenalty = isVirtualBridgeInterface(name) ? 2 : 0
return (address.includes(':') ? 2 : 1) + bridgePenalty
}
function getDefaultPairingAddress(): string | null {

View File

@ -861,6 +861,38 @@
cursor: not-allowed;
}
/* Secondary collapsible; quieter than mp-text-link so it isnt read as a primary action. */
.mobile-page-root .mp-disclosure-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 0;
padding: 0;
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: color 140ms ease;
}
.mobile-page-root .mp-disclosure-trigger:hover {
color: var(--foreground);
}
.mobile-page-root .mp-disclosure-trigger svg {
width: 13px;
height: 13px;
}
.mobile-page-root .mp-disclosure-hint {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.45;
max-width: 36rem;
}
.mobile-page-root .mp-network-row {
display: flex;
align-items: center;

View File

@ -3,6 +3,7 @@
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'
vi.mock('@/i18n/i18n', () => ({
@ -313,4 +314,81 @@ describe('HeroFlow height', () => {
expect(refresh).toHaveFocus()
})
it('demotes the network address picker to a disclosure on Orca Relay', async () => {
const props: React.ComponentProps<typeof MobileHeroPairingStep> = {
pairQrDataUrl: null,
pairingUrl: null,
pairingQrError: false,
relayMintFailure: null,
onUseLan: vi.fn(),
onRetryRelay: vi.fn(),
onCopyRelayDiagnostics: vi.fn(),
pairLoading: false,
connectionMode: 'automatic',
onConnectionModeChange: vi.fn(),
onRegeneratePairing: vi.fn(),
canGeneratePairing: true,
onCopyPairingCode: vi.fn(),
networkInterfaces: [],
customAddresses: [],
selectedAddress: undefined,
selectedAddressIsCustom: false,
onSelectedAddressChange: vi.fn(),
onCustomAddressSelect: vi.fn(),
onCustomAddressRemove: vi.fn(),
beforeCustomAddressChange: vi.fn().mockResolvedValue(true),
onRefreshNetworkInterfaces: vi.fn(),
refreshingNetworkInterfaces: false
}
const user = userEvent.setup()
const { rerender } = render(<MobileHeroPairingStep {...props} />)
expect(screen.queryByText('Network')).toBeNull()
expect(screen.queryByRole('button', { name: 'Refresh network interfaces' })).toBeNull()
// Relay still advertises a LAN endpoint, so the picker must stay reachable.
await user.click(screen.getByRole('button', { name: /Also use a faster local path/i }))
expect(screen.getByText('Network')).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh network interfaces' })).toBeVisible()
expect(screen.getByText(/Optional\. Pick the WiFi or Tailscale address/i)).toBeVisible()
rerender(<MobileHeroPairingStep {...props} connectionMode="local-only" />)
expect(screen.getByText('Network')).toBeVisible()
expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull()
expect(screen.getByRole('button', { name: 'Refresh network interfaces' })).toBeVisible()
})
it('keeps a custom address visible on Orca Relay', () => {
const address = 'host.example:6768'
const props: React.ComponentProps<typeof MobileHeroPairingStep> = {
pairQrDataUrl: null,
pairingUrl: null,
pairingQrError: false,
relayMintFailure: null,
onUseLan: vi.fn(),
onRetryRelay: vi.fn(),
onCopyRelayDiagnostics: vi.fn(),
pairLoading: false,
connectionMode: 'automatic',
onConnectionModeChange: vi.fn(),
onRegeneratePairing: vi.fn(),
canGeneratePairing: true,
onCopyPairingCode: vi.fn(),
networkInterfaces: [],
customAddresses: [address],
selectedAddress: address,
selectedAddressIsCustom: true,
onSelectedAddressChange: vi.fn(),
onCustomAddressSelect: vi.fn(),
onCustomAddressRemove: vi.fn(),
beforeCustomAddressChange: vi.fn().mockResolvedValue(true),
onRefreshNetworkInterfaces: vi.fn(),
refreshingNetworkInterfaces: false
}
render(<MobileHeroPairingStep {...props} />)
expect(screen.getByText('Network')).toBeVisible()
// Why: a trigger here could not collapse the pinned-open row, so it would be
// a dead control advertising aria-expanded it does not own.
expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull()
})
})

View File

@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { CircleAlert, Copy, RefreshCw } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, CircleAlert, Copy, RefreshCw } from 'lucide-react'
import { cn } from '../../lib/utils'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
import { NetworkInterfacePicker } from './NetworkInterfacePicker'
import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions'
@ -106,6 +107,11 @@ export function MobileHeroPairingStep({
}): React.JSX.Element {
const copyPairingCodeRef = useRef<HTMLButtonElement | null>(null)
const pairingWasReadyRef = useRef(pairingUrl != null && !pairLoading)
const usingRelay = connectionMode === 'automatic'
const [networkDisclosureOpen, setNetworkDisclosureOpen] = useState(false)
// A custom address is a deliberate override: show the row outright rather than
// behind a trigger that could not collapse it anyway.
const networkDisclosurePinned = selectedAddressIsCustom
const emptyQrMessage =
!pairLoading && pairQrDataUrl == null
? emptyPairingQrMessage({
@ -126,6 +132,42 @@ export function MobileHeroPairingStep({
}
}, [pairLoading, pairingUrl])
const networkRow = (
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
customAddresses={customAddresses}
selectedAddress={selectedAddress}
selectedAddressIsCustom={selectedAddressIsCustom}
onSelectedAddressChange={onSelectedAddressChange}
onCustomAddressSelect={onCustomAddressSelect}
onCustomAddressRemove={onCustomAddressRemove}
beforeCustomAddressChange={beforeCustomAddressChange}
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>
)
return (
<div className={cn('mp-pairing-layout', relayMintFailure != null && 'has-failure')}>
<div className="mp-step2-copy mp-pairing-copy">
@ -220,39 +262,48 @@ export function MobileHeroPairingStep({
) : null}
</div>
<div className="mp-pairing-controls">
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
customAddresses={customAddresses}
selectedAddress={selectedAddress}
selectedAddressIsCustom={selectedAddressIsCustom}
onSelectedAddressChange={onSelectedAddressChange}
onCustomAddressSelect={onCustomAddressSelect}
onCustomAddressRemove={onCustomAddressRemove}
beforeCustomAddressChange={beforeCustomAddressChange}
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'
)}
{usingRelay && !networkDisclosurePinned ? (
// Why: Relay is the default path; this only configures the LAN/Tailscale
// endpoint the phone prefers when nearby. Noun phrasing + muted style so
// it reads as an alternative, not a mode switch next to "Copy pairing code".
<Collapsible
open={networkDisclosureOpen}
onOpenChange={setNetworkDisclosureOpen}
className="mb-[18px]"
>
<RefreshCw className="size-3.5" />
</button>
</div>
<CollapsibleTrigger asChild>
<button type="button" className="mp-disclosure-trigger">
{translate(
'auto.components.mobile.MobileHero.directAddressDisclosure',
'Also use a faster local path'
)}
<ChevronDown
className={cn(
'size-3.5 transition-transform',
networkDisclosureOpen && 'rotate-180'
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{/* The row's own 18px gap moves to the Collapsible so the spacing
below stays identical whether the disclosure is open. `!` is
required: mobile-page.css is unlayered and so outranks Tailwind's
utilities layer on specificity ties. */}
<div className="mt-2 space-y-2 [&>.mp-network-row]:mb-0!">
<p className="mp-disclosure-hint">
{translate(
'auto.components.mobile.MobileHero.directAddressHint',
'Optional. Pick the WiFi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when youre away.'
)}
</p>
{networkRow}
</div>
</CollapsibleContent>
</Collapsible>
) : (
networkRow
)}
<div className="mp-inline-actions">
<span className="mp-action-divider">
@ -272,6 +323,7 @@ export function MobileHeroPairingStep({
<WindowsFirewallNotice
pairingReady={pairQrDataUrl != null}
address={selectedAddress}
usingRelay={usingRelay}
className="mt-3"
/>
</div>

View File

@ -44,6 +44,27 @@ describe('WindowsFirewallNotice', () => {
expect(screen.getByText(/TCP port 6768/i)).toBeInTheDocument()
})
it('reassures on Relay that pairing works without the firewall rule', async () => {
const getWindowsFirewallStatus = vi.fn().mockResolvedValue({
supported: true,
port: 6768,
ruleAllowed: false,
blockingRuleDetected: false,
privateFirewallEnabled: true,
networkCategory: 'private',
inspectionAvailable: true
})
setMobileApi({ getWindowsFirewallStatus })
const { rerender } = render(
<WindowsFirewallNotice pairingReady address="192.168.0.108" usingRelay />
)
expect(await screen.findByText(/allow phone connections through/i)).toBeInTheDocument()
expect(screen.getByText(/still works over Orca Relay/i)).toBeInTheDocument()
rerender(<WindowsFirewallNotice pairingReady address="192.168.0.108" />)
expect(screen.queryByText(/still works over Orca Relay/i)).not.toBeInTheDocument()
})
it('repairs only after explicit user action and hides after success', async () => {
const repairWindowsFirewall = vi.fn().mockResolvedValue({ ok: true })
const getWindowsFirewallStatus = vi

View File

@ -10,12 +10,15 @@ import { cn } from '../../lib/utils'
type WindowsFirewallNoticeProps = {
pairingReady: boolean
address?: string
/** Relay offers still carry a LAN endpoint, but a blocked one is not fatal. */
usingRelay?: boolean
className?: string
}
export function WindowsFirewallNotice({
pairingReady,
address,
usingRelay = false,
className
}: WindowsFirewallNoticeProps): React.JSX.Element | null {
const [status, setStatus] = useState<WindowsMobileFirewallStatus | null>(null)
@ -173,6 +176,14 @@ export function WindowsFirewallNotice({
{ port: firewallStatus.port }
)}
</p>
{usingRelay ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.mobile.WindowsFirewallNotice.relay-note',
'Pairing still works over Orca Relay — allowing this only adds the faster local connection.'
)}
</p>
) : null}
</div>
{networkIsPublic ? (
<Button

View File

@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
type StoreState = {
settings: {
mobilePairingCustomAddress?: string | null
mobilePairingCustomAddresses?: string[]
}
updateSettings: (patch: Record<string, unknown>) => Promise<void>
}
const mocks = vi.hoisted(() => {
const holder: { state: StoreState } = { state: {} as StoreState }
return {
holder,
useAppStore: (selector: (state: StoreState) => unknown) => selector(holder.state)
}
})
vi.mock('@/store', () => ({ useAppStore: mocks.useAppStore }))
import { useMobilePairingAddressPreference } from './use-mobile-pairing-address-preference'
const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' }
const OTHER: MobileNetworkInterface = { name: 'en1', address: '10.0.0.5' }
function renderPreference() {
mocks.holder.state = {
settings: {},
updateSettings: vi.fn().mockResolvedValue(undefined)
}
const onSelectionInvalidated = vi.fn()
const { result } = renderHook(() =>
useMobilePairingAddressPreference({
networkInterfaces: [],
onSelectionInvalidated
})
)
return { result, onSelectionInvalidated }
}
afterEach(() => cleanup())
describe('useMobilePairingAddressPreference', () => {
it('does not invalidate the offer on the first address resolution', () => {
// Why: the renderer's first pick matches the default main already minted
// with, so invalidating there would drop a QR that is still correct.
const { result, onSelectionInvalidated } = renderPreference()
act(() => result.current.selectAddressAfterRefresh([LAN]))
expect(result.current.selectedAddress).toBe(LAN.address)
expect(onSelectionInvalidated).not.toHaveBeenCalled()
})
it('invalidates when a later refresh moves off the resolved address', () => {
const { result, onSelectionInvalidated } = renderPreference()
act(() => result.current.selectAddressAfterRefresh([LAN]))
act(() => result.current.selectAddressAfterRefresh([OTHER]))
expect(result.current.selectedAddress).toBe(OTHER.address)
expect(onSelectionInvalidated).toHaveBeenCalledExactlyOnceWith({
address: OTHER.address,
source: 'refresh'
})
})
it('invalidates when discovery stops reporting the resolved address', () => {
const { result, onSelectionInvalidated } = renderPreference()
act(() => result.current.selectAddressAfterRefresh([LAN]))
act(() => result.current.selectAddressAfterRefresh([]))
expect(result.current.selectedAddress).toBeUndefined()
expect(onSelectionInvalidated).toHaveBeenCalledExactlyOnceWith({
address: undefined,
source: 'refresh'
})
})
})

View File

@ -59,11 +59,16 @@ export function useMobilePairingAddressPreference(args: {
if (nextAddress === selectedAddressRef.current) {
return
}
// Why: the first resolution picks the same default main already minted
// with, so invalidating there would drop a QR that is still correct.
const hadSelection = selectedAddressRef.current !== undefined
selectedAddressRef.current = nextAddress
selectedAddressIsManualRef.current = false
setSelectedAddress(nextAddress)
setSelectedAddressIsCustom(false)
onSelectionInvalidated({ address: nextAddress, source: 'refresh' })
if (hadSelection) {
onSelectionInvalidated({ address: nextAddress, source: 'refresh' })
}
},
[onSelectionInvalidated]
)

View File

@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status'
@ -65,17 +65,33 @@ describe('MobilePairingConnectionOptions', () => {
afterEach(() => cleanup())
it('shows a compact Sign in row when Orca Relay is selected and signed out', async () => {
it('shows Sign in directly under Orca Relay, above LAN', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<MobilePairingConnectionOptions value="automatic" onChange={onChange} />)
expect(screen.getByTestId('anywhere-sign-in-panel')).toBeVisible()
expect(screen.getByText('Sign in to use Orca Mobile Relay.')).toBeVisible()
const relay = screen.getByRole('radio', { name: /Orca Relay/i })
const lan = screen.getByRole('radio', { name: /^LAN\b/i })
const signInPanel = screen.getByTestId('anywhere-sign-in-panel')
const signIn = screen.getByRole('button', { name: 'Sign in for Relay' })
expect(signInPanel).toBeVisible()
expect(screen.getByText('Relay only — LAN does not need an account.')).toBeVisible()
// Why: CTA must sit between Relay and LAN so it is not buried under LAN.
expect(
relay.compareDocumentPosition(signInPanel) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(signInPanel.compareDocumentPosition(lan) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// Why: `radiogroup` only permits `radio` children. The panel is layout-only,
// so it must stay role-less rather than declaring a `group` the group cannot
// own — and its label must not double-announce the button it wraps.
const group = screen.getByRole('radiogroup')
expect(within(group).queryAllByRole('group')).toHaveLength(0)
expect(within(group).getAllByRole('radio')).toHaveLength(2)
expect(signInPanel).not.toHaveAttribute('aria-label')
// Why: do not surface build-setup diagnostics in the pairing flow.
expect(screen.queryByText(/not configured for this build/i)).toBeNull()
await user.click(screen.getByRole('button', { name: 'Sign in' }))
await user.click(signIn)
expect(onChange).toHaveBeenCalledWith('automatic')
expect(connect).toHaveBeenCalledOnce()
})
@ -83,6 +99,7 @@ describe('MobilePairingConnectionOptions', () => {
it('hides Sign in when LAN is selected', () => {
render(<MobilePairingConnectionOptions value="local-only" onChange={vi.fn()} />)
expect(screen.queryByTestId('anywhere-sign-in-panel')).toBeNull()
expect(screen.queryByRole('button', { name: /Sign in/i })).toBeNull()
})
it('shows Unavailable instead of a dead Sign in on unconfigured builds', () => {
@ -100,8 +117,37 @@ describe('MobilePairingConnectionOptions', () => {
// No Relay endpoint to sign into — the Sign in CTA must not appear.
expect(screen.queryByTestId('anywhere-sign-in-panel')).toBeNull()
expect(screen.queryByRole('button', { name: /Sign in/i })).toBeNull()
expect(screen.getByTestId('anywhere-unavailable-panel')).toBeVisible()
expect(screen.getByText('Unavailable')).toBeVisible()
const relay = screen.getByRole('radio', { name: /Orca Relay/i })
expect(relay).toHaveTextContent('Unavailable')
expect(relay).toHaveTextContent(/isnt available in this build/i)
})
it('keeps Relay unavailable and unselectable while LAN is selected', async () => {
mocks.state = {
...mocks.state,
orcaProfileAuthStatus: {
activeProfileId: 'profile-1',
configured: false,
state: 'unconfigured',
persistence: 'none'
}
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<MobilePairingConnectionOptions value="local-only" onChange={onChange} />)
// Availability follows the build, not the selected path.
const relay = screen.getByRole('radio', { name: /Orca Relay/i })
expect(relay).toHaveTextContent('Unavailable')
expect(relay).toHaveTextContent(/isnt available in this build/i)
expect(relay).toHaveAttribute('aria-disabled', 'true')
await user.click(relay)
expect(onChange).not.toHaveBeenCalled()
screen.getByRole('radio', { name: /^LAN\b/i }).focus()
await user.keyboard('{ArrowUp}')
expect(onChange).not.toHaveBeenCalled()
})
it('moves selection with the arrow keys as a radiogroup', async () => {
@ -114,17 +160,27 @@ describe('MobilePairingConnectionOptions', () => {
expect(onChange).toHaveBeenCalledWith('local-only')
})
it('does not change path when arrow keys hit the Sign in control', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<MobilePairingConnectionOptions value="automatic" onChange={onChange} />)
screen.getByRole('button', { name: 'Sign in for Relay' }).focus()
await user.keyboard('{ArrowDown}')
expect(onChange).not.toHaveBeenCalled()
})
it('selects a path from the compact list', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<MobilePairingConnectionOptions value="local-only" onChange={onChange} />)
expect(
screen.getByText('Phone can be on cellular or any WiFi. Sign-in required.')
screen.getByText('Phone can be on cellular or any WiFi. Sign-in required for Relay only.')
).toBeVisible()
expect(
screen.getByText(
'Phone must be on this WiFi or connected through Tailscale. No sign-in required.'
'Phone must be on this WiFi or connected through Tailscale. No account needed.'
)
).toBeVisible()

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
@ -7,6 +7,7 @@ import { useAppStore } from '../../store'
import { cn } from '@/lib/utils'
import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
import { MobilePairingPathOption } from './MobilePairingPathOption'
function relayStatusLabel(status: MobileRelayStatus): string {
if (status === 'registered') {
@ -36,78 +37,6 @@ function relayStatusLabel(status: MobileRelayStatus): string {
)
}
type PathOptionProps = {
selected: boolean
onSelect: () => void
title: string
description: string
trailing?: ReactNode
tabIndex: number
disabled?: boolean
optionRef?: (el: HTMLDivElement | null) => void
}
// Why: this is a bespoke radio row rather than the canonical SettingsSegmentedControl
// because each option needs a two-line title + description plus a trailing status
// badge, which the single-line segmented pill cannot carry (STYLEGUIDE.md's
// "real difference in role" carve-out). Arrow-key nav and roving tabindex below
// keep it a conformant ARIA radiogroup.
function PathOption({
selected,
onSelect,
title,
description,
trailing,
tabIndex,
disabled = false,
optionRef
}: PathOptionProps): React.JSX.Element {
return (
<div
ref={optionRef}
role="radio"
tabIndex={tabIndex}
aria-checked={selected}
aria-disabled={disabled}
onClick={disabled ? undefined : onSelect}
onKeyDown={(event) => {
if (disabled) {
return
}
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
onSelect()
}
}}
className={cn(
'flex cursor-pointer items-start gap-3 px-3 py-2.5 outline-none transition-colors',
// Why: match SettingsFormControls focus ring so keyboard focus is visible
// even when the selected row already uses bg-accent/40.
'focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50',
disabled && 'cursor-not-allowed opacity-60',
selected ? 'bg-accent/40' : 'hover:bg-accent/20'
)}
>
<span
className={cn(
'mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full border',
selected ? 'border-foreground bg-foreground' : 'border-muted-foreground/40'
)}
aria-hidden
>
{selected ? <span className="size-1.5 rounded-full bg-background" /> : null}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium leading-none">{title}</span>
{trailing}
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
</div>
)
}
export function MobilePairingConnectionOptions({
value,
onChange,
@ -134,7 +63,9 @@ export function MobilePairingConnectionOptions({
// and only offer Sign in when the build can actually reach Relay.
const configured = authStatus?.configured !== false
const needsSignIn = value === 'automatic' && !signedIn && configured
const relayUnavailable = value === 'automatic' && !signedIn && !configured
// Availability is a property of the build, not of the current selection.
const relayUnavailable = !signedIn && !configured
const relayDisabled = relayMintRetrying || relayUnavailable
const optionRefs = useRef<Record<MobilePairingConnectionMode, HTMLDivElement | null>>({
automatic: null,
'local-only': null
@ -142,16 +73,22 @@ export function MobilePairingConnectionOptions({
// Why: ARIA radiogroups move selection with the arrow keys; wrap between the
// two options and move focus so keyboard users get standard behavior.
// Ignore arrows that originate on nested controls (Sign in) so they do not
// steal keys from the button or flip the path while focus is outside a radio.
const handleArrowKeys = (event: React.KeyboardEvent): void => {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
return
}
if (relayMintRetrying && value !== 'automatic') {
const target = event.target
if (!(target instanceof HTMLElement) || target.getAttribute('role') !== 'radio') {
return
}
if (relayDisabled && value !== 'automatic') {
return
}
event.preventDefault()
const next: MobilePairingConnectionMode =
relayMintRetrying || value === 'automatic' ? 'local-only' : 'automatic'
relayDisabled || value === 'automatic' ? 'local-only' : 'automatic'
onChange(next)
optionRefs.current[next]?.focus()
}
@ -196,10 +133,12 @@ export function MobilePairingConnectionOptions({
onKeyDown={handleArrowKeys}
className="overflow-hidden rounded-md border border-border"
>
<PathOption
<MobilePairingPathOption
selected={value === 'automatic'}
tabIndex={value === 'automatic' && !relayMintRetrying ? 0 : -1}
disabled={relayMintRetrying}
tabIndex={value === 'automatic' && !relayDisabled ? 0 : -1}
disabled={relayDisabled}
positionInSet={1}
setSize={2}
optionRef={(el) => {
optionRefs.current.automatic = el
}}
@ -208,12 +147,26 @@ export function MobilePairingConnectionOptions({
'auto.components.settings.MobilePairingConnectionOptions.anywhereTitle',
'Orca Relay'
)}
description={translate(
'auto.components.settings.MobilePairingConnectionOptions.anywhereDescription',
'Phone can be on cellular or any WiFi. Sign-in required.'
)}
description={
relayUnavailable
? translate(
'auto.components.settings.MobilePairingConnectionOptions.relayUnavailable',
'Orca Relay isnt available in this build. Use LAN.'
)
: translate(
'auto.components.settings.MobilePairingConnectionOptions.anywhereDescription',
'Phone can be on cellular or any WiFi. Sign-in required for Relay only.'
)
}
trailing={
signedIn && value === 'automatic' ? (
relayUnavailable ? (
<Badge variant="outline" className="text-[11px]">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.unavailable',
'Unavailable'
)}
</Badge>
) : signedIn && value === 'automatic' ? (
<Badge variant="outline" className="text-[11px]">
{relayMintRetrying
? translate(
@ -230,10 +183,59 @@ export function MobilePairingConnectionOptions({
) : null
}
/>
{needsSignIn ? (
<div
// Why: indent under the Relay radio so Sign in reads as a Relay
// sub-step, not a requirement for the whole connection section/LAN.
// Deliberately role-less: a `group` here would be an invalid owned
// element of the radiogroup, and its label would double-announce the
// button it wraps. The plain div contributes nothing to the a11y
// tree, leaving the CTA reachable by Tab as an ordinary button.
onKeyDown={(event) => {
// Why: nested controls live inside the radiogroup for layout; do not
// let arrow keys bubble and flip the selected path.
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
event.stopPropagation()
}
}}
className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-accent/40 py-2.5 pl-10 pr-3"
data-testid="anywhere-sign-in-panel"
>
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.signInRequired',
'Relay only — LAN does not need an account.'
)}
</p>
<Button
type="button"
size="sm"
className="shrink-0"
disabled={connecting}
onClick={() => {
onChange('automatic')
void connect()
}}
>
{connecting ? <Loader2 className="animate-spin" /> : null}
{reconnectRequired
? translate(
'auto.components.settings.MobilePairingConnectionOptions.signInAgain',
'Sign in again for Relay'
)
: translate(
'auto.components.settings.MobilePairingConnectionOptions.signIn',
'Sign in for Relay'
)}
</Button>
</div>
) : null}
<div className="border-t border-border" />
<PathOption
<MobilePairingPathOption
selected={value === 'local-only'}
tabIndex={value === 'local-only' || relayMintRetrying ? 0 : -1}
tabIndex={value === 'local-only' || relayDisabled ? 0 : -1}
positionInSet={2}
setSize={2}
optionRef={(el) => {
optionRefs.current['local-only'] = el
}}
@ -244,64 +246,10 @@ export function MobilePairingConnectionOptions({
)}
description={translate(
'auto.components.settings.MobilePairingConnectionOptions.localDescription',
'Phone must be on this WiFi or connected through Tailscale. No sign-in required.'
'Phone must be on this WiFi or connected through Tailscale. No account needed.'
)}
/>
</div>
{needsSignIn ? (
<div
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border px-3 py-2"
data-testid="anywhere-sign-in-panel"
>
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.signInRequired',
'Sign in to use Orca Mobile Relay.'
)}
</p>
<Button
type="button"
size="sm"
disabled={connecting}
onClick={() => {
onChange('automatic')
void connect()
}}
>
{connecting ? <Loader2 className="animate-spin" /> : null}
{reconnectRequired
? translate(
'auto.components.settings.MobilePairingConnectionOptions.signInAgain',
'Sign in again'
)
: translate(
'auto.components.settings.MobilePairingConnectionOptions.signIn',
'Sign in'
)}
</Button>
</div>
) : null}
{relayUnavailable ? (
<div
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border px-3 py-2"
data-testid="anywhere-unavailable-panel"
>
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.relayUnavailable',
'Orca Relay isnt available in this build. Use LAN.'
)}
</p>
<Badge variant="outline" className="shrink-0">
{translate(
'auto.components.settings.MobilePairingConnectionOptions.unavailable',
'Unavailable'
)}
</Badge>
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,75 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
// Why: bespoke radio row instead of SettingsSegmentedControl — needs two-line
// title + description + trailing badge (STYLEGUIDE "real difference in role").
export function MobilePairingPathOption({
selected,
onSelect,
title,
description,
trailing,
tabIndex,
disabled = false,
positionInSet,
setSize,
optionRef
}: {
selected: boolean
onSelect: () => void
title: string
description: string
trailing?: ReactNode
tabIndex: number
disabled?: boolean
/** Stated explicitly: the Sign in panel sits between the radios in the DOM. */
positionInSet: number
setSize: number
optionRef?: (el: HTMLDivElement | null) => void
}): React.JSX.Element {
return (
<div
ref={optionRef}
role="radio"
tabIndex={tabIndex}
aria-checked={selected}
aria-disabled={disabled}
aria-posinset={positionInSet}
aria-setsize={setSize}
onClick={disabled ? undefined : onSelect}
onKeyDown={(event) => {
if (disabled) {
return
}
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
onSelect()
}
}}
className={cn(
'flex cursor-pointer items-start gap-3 px-3 py-2.5 outline-none transition-colors',
// Why: match SettingsFormControls focus ring when selected uses bg-accent/40.
'focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50',
disabled && 'cursor-not-allowed opacity-60',
selected ? 'bg-accent/40' : 'hover:bg-accent/20'
)}
>
<span
className={cn(
'mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full border',
selected ? 'border-foreground bg-foreground' : 'border-muted-foreground/40'
)}
aria-hidden
>
{selected ? <span className="size-1.5 rounded-full bg-background" /> : null}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium leading-none">{title}</span>
{trailing}
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</div>
</div>
)
}

View File

@ -78,12 +78,48 @@ describe('MobilePairingSetupSection', () => {
expect(screen.getByText(/must be able to reach this address/i)).toBeVisible()
})
it('explains the direct address when Orca Relay is selected', () => {
renderSection({ connectionMode: 'automatic' })
expect(screen.getByText('This computers address')).toBeVisible()
expect(screen.getByRole('combobox')).toBeVisible()
expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible()
it('demotes this computers address to a disclosure when Orca Relay is selected', async () => {
const { user } = renderSection({
connectionMode: 'automatic',
selectedAddress: undefined
})
expect(screen.queryByText('This computers address')).toBeNull()
expect(screen.queryByRole('combobox')).toBeNull()
expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeEnabled()
// Relay still advertises a LAN endpoint, so the picker must stay reachable.
await user.click(screen.getByRole('button', { name: /Also use a faster local path/i }))
expect(screen.getByRole('combobox')).toBeVisible()
expect(screen.getByText(/faster than Relay/i)).toBeVisible()
})
it('opens the Relay address disclosure when a settings search targets it', () => {
renderSection({
connectionMode: 'automatic',
addressDisclosureForcedOpen: true
})
expect(screen.getByRole('combobox')).toBeVisible()
// Why: a trigger here could not collapse the pinned-open picker, so it would
// be a dead control advertising aria-expanded it does not own.
expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull()
})
it('never hides a custom address behind the Relay disclosure', () => {
const address = 'host.example:6768'
renderSection({
connectionMode: 'automatic',
customAddresses: [address],
selectedAddress: address,
selectedAddressIsCustom: true
})
expect(screen.getByRole('combobox')).toHaveTextContent(address)
expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull()
})
it('disables generate on LAN when no advertise address is selected', () => {
renderSection({ connectionMode: 'local-only', selectedAddress: undefined })
expect(screen.getByText('This computers address')).toBeVisible()
expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeDisabled()
})
it('can move retry recovery into the persistent failure notice', () => {

View File

@ -1,7 +1,9 @@
import type { ReactNode } from 'react'
import { Loader2, QrCode, RefreshCw } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { ChevronDown, Loader2, QrCode, RefreshCw } from 'lucide-react'
import { Button } from '../ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker'
import type { MobileNetworkInterface } from './mobile-network-interface-selection'
@ -11,6 +13,8 @@ type MobilePairingSetupSectionProps = {
connectionMode: MobilePairingConnectionMode
/** False when Anywhere is selected but Relay cannot be committed yet. */
canGenerate?: boolean
/** Reveals the Relay address disclosure when a settings search matches it. */
addressDisclosureForcedOpen?: boolean
connectionPathControl: ReactNode
networkInterfaces: MobileNetworkInterface[]
customAddresses: readonly string[]
@ -30,6 +34,7 @@ type MobilePairingSetupSectionProps = {
export function MobilePairingSetupSection({
connectionMode,
canGenerate = true,
addressDisclosureForcedOpen = false,
connectionPathControl,
networkInterfaces,
customAddresses,
@ -46,7 +51,72 @@ export function MobilePairingSetupSection({
onGenerateQr
}: MobilePairingSetupSectionProps): React.JSX.Element {
const usingRelay = connectionMode === 'automatic'
const generateDisabled = loading || !selectedAddress || !canGenerate
const [addressDisclosureOpen, setAddressDisclosureOpen] = useState(false)
// A search hit or a custom address pins the picker open: render it outright
// rather than behind a trigger that could not collapse it anyway.
const addressDisclosurePinned = addressDisclosureForcedOpen || selectedAddressIsCustom
// Relay offers still carry a LAN endpoint for the direct fast path, but main
// substitutes its own default when the renderer has not resolved one yet.
// LAN has no such fallback, so it needs an explicit reachable host.
const generateDisabled = loading || !canGenerate || (!usingRelay && !selectedAddress)
// "Also use…" frames this as additive under Relay, not a second connection mode.
// The phone races both paths and direct wins ties when nearby.
const relayAddressLabel = translate(
'auto.components.settings.MobilePairingSetupSection.step2RelayDisclosure',
'Also use a faster local path'
)
const addressControls = (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
customAddresses={customAddresses}
selectedAddress={selectedAddress}
selectedAddressIsCustom={selectedAddressIsCustom}
onSelectedAddressChange={onSelectedAddressChange}
onCustomAddressSelect={onCustomAddressSelect}
onCustomAddressRemove={onCustomAddressRemove}
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>
<p className="text-xs text-muted-foreground">
{usingRelay
? translate(
'auto.components.settings.MobilePairingSetupSection.step2RelayDescription',
'Optional. Pick the WiFi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when youre away.'
)
: translate(
'auto.components.settings.MobilePairingSetupSection.step2LocalDescription',
'The phone must be able to reach this address on Tailscale or WiFi.'
)}
</p>
</div>
)
return (
<section className="space-y-5">
@ -69,61 +139,50 @@ export function MobilePairingSetupSection({
{connectionPathControl}
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">
{translate(
'auto.components.settings.MobilePairingSetupSection.step2Title',
'This computers address'
)}
</p>
<div className="flex flex-wrap items-center gap-2">
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
customAddresses={customAddresses}
selectedAddress={selectedAddress}
selectedAddressIsCustom={selectedAddressIsCustom}
onSelectedAddressChange={onSelectedAddressChange}
onCustomAddressSelect={onCustomAddressSelect}
onCustomAddressRemove={onCustomAddressRemove}
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>
{usingRelay && addressDisclosurePinned ? (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">{relayAddressLabel}</p>
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-3">
{addressControls}
</div>
</div>
<p className="text-xs text-muted-foreground">
{usingRelay
? translate(
'auto.components.settings.MobilePairingSetupSection.step2RelayDescription',
'Used for a faster direct path when nearby. Relay covers remote access.'
)
: translate(
'auto.components.settings.MobilePairingSetupSection.step2LocalDescription',
'The phone must be able to reach this address on Tailscale or WiFi.'
)}
</p>
</div>
) : usingRelay ? (
// Why: Relay makes the address optional, not irrelevant — demote it to a
// disclosure so the direct fast path stays reachable without clutter.
<Collapsible open={addressDisclosureOpen} onOpenChange={setAddressDisclosureOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="-ml-2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
{relayAddressLabel}
<ChevronDown
className={cn(
'size-3.5 transition-transform',
addressDisclosureOpen && 'rotate-180'
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-md border border-border/60 bg-muted/20 px-3 py-3">
{addressControls}
</div>
</CollapsibleContent>
</Collapsible>
) : (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">
{translate(
'auto.components.settings.MobilePairingSetupSection.step2Title',
'This computers address'
)}
</p>
{addressControls}
</div>
)}
{showGenerateAction ? (
<div className="space-y-2">

View File

@ -23,6 +23,7 @@ type PairedDevicesProps = {
type StoreState = {
orcaProfileAuthStatus: { state: 'connected' | 'local' }
settingsSearchQuery: string
settings: {
mobileAutoRestoreFitMs: number | null
mobilePairingConnectionMode?: MobilePairingConnectionMode
@ -155,7 +156,11 @@ vi.mock('./MobilePairedDevicesSection', () => ({
}
}))
vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () => <div /> }))
vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () => <div /> }))
vi.mock('../mobile/WindowsFirewallNotice', () => ({
WindowsFirewallNotice: (props: { usingRelay?: boolean }) => (
<div data-testid="firewall-notice">{String(props.usingRelay)}</div>
)
}))
import { MobilePane } from './MobilePane'
@ -180,6 +185,7 @@ describe('MobilePane pairing connection mode', () => {
updateSettings.mockReset().mockResolvedValue(undefined)
mocks.holder.state = {
orcaProfileAuthStatus: { state: 'connected' },
settingsSearchQuery: '',
settings: { mobileAutoRestoreFitMs: null },
updateSettings,
recordFeatureInteraction: vi.fn()
@ -475,6 +481,17 @@ describe('MobilePane pairing connection mode', () => {
expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')
})
it('keeps the firewall notice on Relay, which still uses the direct LAN path', async () => {
const user = userEvent.setup()
render(<MobilePane />)
expect(screen.getByTestId('mode')).toHaveTextContent('automatic')
expect(screen.getByTestId('firewall-notice')).toHaveTextContent('true')
await user.click(screen.getByRole('button', { name: 'choose-local' }))
expect(screen.getByTestId('firewall-notice')).toHaveTextContent('false')
})
it('keeps the current pairing code when only custom address intent changes', async () => {
const address = '100.126.117.25:6768'
mocks.holder.state.settings = {
@ -777,6 +794,7 @@ describe('MobilePane', () => {
mocks.updateSettings.mockReset().mockResolvedValue(undefined)
mocks.holder.state = {
orcaProfileAuthStatus: { state: 'connected' },
settingsSearchQuery: '',
settings: { mobileAutoRestoreFitMs: null },
updateSettings: mocks.updateSettings,
recordFeatureInteraction: vi.fn()

View File

@ -24,6 +24,7 @@ import {
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode'
import { useMobilePairingAddressPreference } from '../mobile/use-mobile-pairing-address-preference'
import { shouldOpenMobilePairingAddress } from './mobile-pane-search'
export { getMobilePaneSearchEntries } from './mobile-pane-search'
export function MobilePane(): React.JSX.Element {
@ -41,6 +42,7 @@ export function MobilePane(): React.JSX.Element {
const [codeCopied, setCodeCopied] = useState(false)
const [deviceCountAtQr, setDeviceCountAtQr] = useState<number | null>(null)
const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected')
const settingsSearchQuery = useAppStore((state) => state.settingsSearchQuery)
const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode()
const [rotateNextQr, setRotateNextQr] = useState(false)
const codeCopiedResetTimerRef = useRef<number | null>(null)
@ -386,6 +388,7 @@ export function MobilePane(): React.JSX.Element {
<MobilePairingSetupSection
connectionMode={connectionMode}
canGenerate={canMintMobilePairingOffer({ connectionMode, signedIn })}
addressDisclosureForcedOpen={shouldOpenMobilePairingAddress(settingsSearchQuery)}
connectionPathControl={
<MobilePairingConnectionOptions
value={connectionMode}
@ -439,7 +442,11 @@ export function MobilePane(): React.JSX.Element {
onClearCodeCopiedTimer={clearCodeCopiedResetTimer}
/>
<WindowsFirewallNotice pairingReady={pairingUrl != null} address={selectedAddress} />
<WindowsFirewallNotice
pairingReady={pairingUrl != null}
address={selectedAddress}
usingRelay={connectionMode === 'automatic'}
/>
<MobilePairedDevicesSection
devices={devices}

View File

@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type StoreState = {
orcaProfileAuthStatus: { state: 'connected' }
settingsSearchQuery: string
settings: { mobileAutoRestoreFitMs: number | null }
updateSettings: () => Promise<void>
recordFeatureInteraction: () => void
}
const mocks = vi.hoisted(() => {
const holder: { state: StoreState } = { state: {} as StoreState }
const useAppStore = Object.assign(
(selector: (state: StoreState) => unknown) => selector(holder.state),
{ getState: () => holder.state }
)
return { holder, useAppStore }
})
vi.mock('@/store', () => ({ useAppStore: mocks.useAppStore }))
vi.mock('../../store', () => ({ useAppStore: mocks.useAppStore }))
// `i18n` is read by the localized search catalogs the pane consults to decide
// whether a query targets the address picker.
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback,
i18n: { language: 'en' }
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
vi.mock('./mobile-pairing-device-polling', () => ({ useMobilePairingDevicePolling: vi.fn() }))
vi.mock('./MobilePairingSetupSection', () => ({
MobilePairingSetupSection: (props: { addressDisclosureForcedOpen?: boolean }) => (
<div data-testid="address-forced-open">{String(props.addressDisclosureForcedOpen)}</div>
)
}))
vi.mock('./MobilePairingConnectionOptions', () => ({
MobilePairingConnectionOptions: () => <div />
}))
vi.mock('./MobilePairingQrSection', () => ({ MobilePairingQrSection: () => <div /> }))
vi.mock('./MobilePairedDevicesSection', () => ({ MobilePairedDevicesSection: () => <div /> }))
vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () => <div /> }))
vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () => <div /> }))
import { MobilePane } from './MobilePane'
describe('MobilePane address disclosure search wiring', () => {
beforeEach(() => {
mocks.holder.state = {
orcaProfileAuthStatus: { state: 'connected' },
settingsSearchQuery: '',
settings: { mobileAutoRestoreFitMs: null },
updateSettings: vi.fn().mockResolvedValue(undefined),
recordFeatureInteraction: vi.fn()
}
Object.defineProperty(window, 'api', {
configurable: true,
value: {
mobile: {
getPairingQR: vi.fn(),
listDevices: vi.fn().mockResolvedValue({ devices: [] }),
listNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }),
revokeDevice: vi.fn()
}
}
})
})
afterEach(() => cleanup())
// Why: an empty query matches every catalog entry, so a missing blank guard
// would spring the disclosure open the moment Settings mounts.
it.each([
['', 'false'],
['revoke', 'false'],
['tailscale', 'true'],
['Network Interface', 'true']
])('forces the disclosure open for %j: %s', (query, forced) => {
mocks.holder.state.settingsSearchQuery = query
render(<MobilePane />)
expect(screen.getByTestId('address-forced-open')).toHaveTextContent(forced)
})
})

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { getMobilePaneSearchEntries, shouldOpenMobilePairingAddress } from './mobile-pane-search'
describe('getMobilePaneSearchEntries', () => {
// Why: the network entries were split into their own catalog and spliced back
// in. Search ranking breaks ties by index, so a reorder silently reranks rows.
it('keeps Network Interface in its original catalog position', () => {
expect(getMobilePaneSearchEntries().map((entry) => entry.title)).toEqual([
'Mobile Pairing',
'Connected Devices',
'Network Interface',
'When you leave the mobile app'
])
})
})
describe('shouldOpenMobilePairingAddress', () => {
it('stays closed for an empty or blank query', () => {
// Why: an empty query scores as a match for every entry, so the disclosure
// would spring open the moment Settings mounts without a search.
expect(shouldOpenMobilePairingAddress('')).toBe(false)
expect(shouldOpenMobilePairingAddress(' ')).toBe(false)
})
it('opens for queries that target the address picker', () => {
expect(shouldOpenMobilePairingAddress('Network Interface')).toBe(true)
expect(shouldOpenMobilePairingAddress('tailscale')).toBe(true)
expect(shouldOpenMobilePairingAddress(' LAN ')).toBe(true)
})
it('stays closed for queries aimed at other rows on the pane', () => {
expect(shouldOpenMobilePairingAddress('qr')).toBe(false)
expect(shouldOpenMobilePairingAddress('revoke')).toBe(false)
})
})

View File

@ -1,43 +1,9 @@
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.mobile.pane.search.d49925710a', 'Mobile Pairing'),
description: translate(
'auto.components.settings.mobile.pane.search.7fb728fb2b',
'Pair a mobile device by scanning a QR code.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.3c1807a81a', 'qr'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.4a0c826f3d', 'code'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.e518cbd61c', 'pair'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.ad08035c5f', 'phone'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.2128a21096', 'scan')
]
},
{
title: translate('auto.components.settings.mobile.pane.search.9d3a9397ba', 'Connected Devices'),
description: translate(
'auto.components.settings.mobile.pane.search.13419718b3',
'Manage paired mobile devices.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'),
...translateSearchKeyword(
'auto.components.settings.mobile.pane.search.82783d9b71',
'devices'
),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.905c65a308', 'revoke'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.5e8fda4d7f', 'paired'),
...translateSearchKeyword(
'auto.components.settings.mobile.pane.search.7d01f93ec0',
'connected'
)
]
},
const getNetworkInterfaceSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.mobile.pane.search.d96c315227', 'Network Interface'),
description: translate(
@ -75,7 +41,54 @@ export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [
...translateSearchKeyword('auto.components.settings.mobile.pane.search.70f505f3c3', 'lan'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.126afc5dbd', 'remote')
]
}
])
/** Reveal the Relay address disclosure when a search targets the picker. */
export function shouldOpenMobilePairingAddress(searchQuery: string): boolean {
return (
normalizeSettingsSearchQuery(searchQuery) !== '' &&
matchesSettingsSearch(searchQuery, getNetworkInterfaceSearchEntries())
)
}
export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.mobile.pane.search.d49925710a', 'Mobile Pairing'),
description: translate(
'auto.components.settings.mobile.pane.search.7fb728fb2b',
'Pair a mobile device by scanning a QR code.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.3c1807a81a', 'qr'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.4a0c826f3d', 'code'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.e518cbd61c', 'pair'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.ad08035c5f', 'phone'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.2128a21096', 'scan')
]
},
{
title: translate('auto.components.settings.mobile.pane.search.9d3a9397ba', 'Connected Devices'),
description: translate(
'auto.components.settings.mobile.pane.search.13419718b3',
'Manage paired mobile devices.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'),
...translateSearchKeyword(
'auto.components.settings.mobile.pane.search.82783d9b71',
'devices'
),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.905c65a308', 'revoke'),
...translateSearchKeyword('auto.components.settings.mobile.pane.search.5e8fda4d7f', 'paired'),
...translateSearchKeyword(
'auto.components.settings.mobile.pane.search.7d01f93ec0',
'connected'
)
]
},
...getNetworkInterfaceSearchEntries(),
{
title: translate(
'auto.components.settings.mobile.pane.search.1e711aca11',

View File

@ -9791,13 +9791,13 @@
"unavailable": "Unavailable",
"pathGroup": "How the phone reaches this computer",
"anywhereTitle": "Orca Relay",
"anywhereDescription": "Phone can be on cellular or any WiFi. Sign-in required.",
"signInRequired": "Sign in to use Orca Mobile Relay.",
"anywhereDescription": "Phone can be on cellular or any WiFi. Sign-in required for Relay only.",
"signInRequired": "Relay only — LAN does not need an account.",
"relayUnavailable": "Orca Relay isnt available in this build. Use LAN.",
"signIn": "Sign in",
"signInAgain": "Sign in again",
"signIn": "Sign in for Relay",
"signInAgain": "Sign in again for Relay",
"localTitle": "LAN",
"localDescription": "Phone must be on this WiFi or connected through Tailscale. No sign-in required.",
"localDescription": "Phone must be on this WiFi or connected through Tailscale. No account needed.",
"retrying": "Retrying"
},
"MobilePairingSetupSection": {
@ -9805,11 +9805,12 @@
"overview": "Generate a QR code, then scan it in Orca Mobile under Pair Desktop.",
"step1Title": "Connection",
"step2Title": "This computers address",
"step2RelayDescription": "Used for a faster direct path when nearby. Relay covers remote access.",
"step2LocalDescription": "The phone must be able to reach this address on WiFi or Tailscale.",
"step2LocalDescription": "The phone must be able to reach this address on Tailscale or WiFi.",
"regenerate": "Regenerate QR code",
"generate": "Generate QR code",
"refresh": "Refresh network interfaces"
"refresh": "Refresh network interfaces",
"step2RelayDescription": "Optional. Pick the WiFi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when youre away.",
"step2RelayDisclosure": "Also use a faster local path"
},
"MobileRelayBetaAvailability": {
"about": "About the Orca Relay beta",
@ -12030,7 +12031,9 @@
"pairingCodeReady": "Pairing code ready",
"pairThisMac": "Pair this Mac.",
"pairThisPc": "Pair this PC.",
"pairThisComputer": "Pair this computer."
"pairThisComputer": "Pair this computer.",
"directAddressDisclosure": "Also use a faster local path",
"directAddressHint": "Optional. Pick the WiFi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when youre away."
},
"MobilePage": {
"e17393c6a3": "Phone preview",
@ -12177,7 +12180,8 @@
"repair-unverified": "Windows Firewall access could not be verified",
"blocked-title": "Windows may be blocking Orca Mobile",
"blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.",
"repair": "Repair firewall access"
"repair": "Repair firewall access",
"relay-note": "Pairing still works over Orca Relay — allowing this only adds the faster local connection."
},
"MobileRelayMintFailureNotice": {
"retryingTitle": "Retrying Orca Relay…",

View File

@ -9577,14 +9577,14 @@
"available": "Disponible",
"reconnecting": "Reconectando",
"unavailable": "No disponible",
"signIn": "Iniciar sesión",
"localDescription": "El teléfono debe estar en esta WiFi o en tu Tailscale. No requiere inicio de sesión.",
"signIn": "Iniciar sesión para Relay",
"localDescription": "El teléfono debe estar en esta WiFi o conectado por Tailscale. No se necesita cuenta.",
"pathGroup": "Cómo llega el teléfono a este ordenador",
"anywhereTitle": "Orca Relay",
"anywhereDescription": "El teléfono puede estar en datos móviles o cualquier WiFi. Se requiere inicio de sesión.",
"signInRequired": "Inicia sesión para usar Orca Mobile Relay.",
"anywhereDescription": "El teléfono puede estar en datos móviles o cualquier WiFi. El inicio de sesión solo es necesario para Relay.",
"signInRequired": "Solo Relay — LAN no necesita una cuenta.",
"relayUnavailable": "Orca Relay no está disponible en esta compilación. Usa LAN.",
"signInAgain": "Iniciar sesión de nuevo",
"signInAgain": "Volver a iniciar sesión para Relay",
"localTitle": "LAN"
},
"MobilePairingSetupSection": {
@ -9595,8 +9595,9 @@
"overview": "Genera un código QR, luego escanéalo en Orca Mobile en Pair Desktop.",
"step1Title": "Método de conexión",
"step2Title": "Dirección de este ordenador",
"step2RelayDescription": "Se usa para una ruta directa más rápida cuando está cerca. Relay cubre el acceso remoto.",
"step2LocalDescription": "El teléfono debe poder alcanzar esta dirección por WiFi o Tailscale."
"step2LocalDescription": "El teléfono debe poder alcanzar esta dirección por Tailscale o WiFi.",
"step2RelayDescription": "Opcional. Elige la dirección WiFi o Tailscale que el teléfono usará cuando esté cerca — suele ser más rápido que Relay. Relay sigue funcionando cuando estás fuera.",
"step2RelayDisclosure": "También usar una ruta local más rápida"
},
"MobileRelayBetaAvailability": {
"about": "Acerca de la beta de Orca Relay",
@ -11756,7 +11757,9 @@
"stable": "Estable"
},
"relayDegradedNotice": "No se pudo contactar con Relay: este código solo funciona en tu LAN o Tailscale.",
"pairingQrError": "Este código de emparejamiento no se pudo representar como código QR. Cópialo en Orca Mobile."
"pairingQrError": "Este código de emparejamiento no se pudo representar como código QR. Cópialo en Orca Mobile.",
"directAddressDisclosure": "También usar una ruta local más rápida",
"directAddressHint": "Opcional. Elige la dirección WiFi o Tailscale que el teléfono usará cuando esté cerca — suele ser más rápido que Relay. Relay sigue funcionando cuando estás fuera."
},
"MobilePage": {
"e17393c6a3": "Vista previa del teléfono",
@ -11899,7 +11902,8 @@
"repair-unverified": "Windows Firewall access could not be verified",
"blocked-title": "Windows may be blocking Orca Mobile",
"blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.",
"repair": "Repair firewall access"
"repair": "Repair firewall access",
"relay-note": "El emparejamiento sigue funcionando por Orca Relay: permitirlo solo añade la conexión local más rápida."
}
},
"gitlab": {

View File

@ -9577,14 +9577,14 @@
"available": "利用可能",
"reconnecting": "再接続中",
"unavailable": "利用不可",
"signIn": "サインイン",
"localDescription": "スマートフォンがこの WiFi または Tailscale に接続されている必要があります。サインインは不要です。",
"signIn": "Relay にサインイン",
"localDescription": "スマートフォンがこの WiFi または Tailscale に接続されている必要があります。アカウントは不要です。",
"pathGroup": "スマートフォンがこのコンピューターに到達する方法",
"anywhereTitle": "Orca Relay",
"anywhereDescription": "スマートフォンはモバイル回線または WiFi から接続できます。サインインが必要です。",
"signInRequired": "Orca Mobile Relay を使用するにはサインインしてください。",
"anywhereDescription": "スマートフォンはモバイル回線または任意の WiFi から接続できます。サインインが必要なのは Relay のみです。",
"signInRequired": "Relay のみ — LAN にはアカウントは不要です。",
"relayUnavailable": "このビルドでは Orca Relay を利用できません。LAN をご利用ください。",
"signInAgain": "再サインイン",
"signInAgain": "Relay に再サインイン",
"localTitle": "LAN"
},
"MobilePairingSetupSection": {
@ -9595,8 +9595,9 @@
"overview": "QR コードを生成し、Orca Mobile の Pair Desktop でスキャンしてください。",
"step1Title": "接続方法",
"step2Title": "このコンピューターのアドレス",
"step2RelayDescription": "近くでは高速な直接接続に使用されます。Relay はリモートアクセスに対応します。",
"step2LocalDescription": "スマートフォンが WiFi または Tailscale でこのアドレスに到達できる必要があります。"
"step2LocalDescription": "スマートフォンが Tailscale または WiFi でこのアドレスに到達できる必要があります。",
"step2RelayDescription": "任意。近く(同じ WiFi または Tailscaleにいるときにスマホが使うアドレスを選びます。通常は Relay より高速です。外出中は引き続き Relay が使われます。",
"step2RelayDisclosure": "より速いローカル経路も使う"
},
"MobileRelayBetaAvailability": {
"about": "Orca Relay ベータについて",
@ -11756,7 +11757,9 @@
"stable": "安定版"
},
"relayDegradedNotice": "Relay に接続できませんでした — このコードは LAN または Tailscale でのみ動作します。",
"pairingQrError": "このペアリングコードを QR コードとして表示できませんでした。代わりに Orca Mobile にコピーしてください。"
"pairingQrError": "このペアリングコードを QR コードとして表示できませんでした。代わりに Orca Mobile にコピーしてください。",
"directAddressDisclosure": "より速いローカル経路も使う",
"directAddressHint": "任意。近く(同じ WiFi または Tailscaleにいるときにスマホが使うアドレスを選びます。通常は Relay より高速です。外出中は引き続き Relay が使われます。"
},
"MobilePage": {
"e17393c6a3": "スマートフォンプレビュー",
@ -11899,7 +11902,8 @@
"repair-unverified": "Windows Firewall access could not be verified",
"blocked-title": "WindowsがOrcaモバイルをブロックしている可能性があります",
"blocked-description": "既存のインバウンドブロックルールがペアリング例外を上書きする可能性があります。修復はこのOrcaアプリの競合するTCPルールを削除し、プライベートネットワーク上のポート{{port}}を許可します。",
"repair": "ファイアウォールアクセスを修復"
"repair": "ファイアウォールアクセスを修復",
"relay-note": "ペアリングは Orca Relay 経由で引き続き機能します。許可すると、より高速なローカル接続が追加されるだけです。"
}
},
"gitlab": {

View File

@ -9577,14 +9577,14 @@
"available": "사용 가능",
"reconnecting": "다시 연결 중",
"unavailable": "사용할 수 없음",
"signIn": "로그인",
"localDescription": "휴대폰이 이 WiFi 또는 Tailscale에 연결되어 있어야 합니다. 로그인이 필요하지 않습니다.",
"signIn": "Relay용 로그인",
"localDescription": "휴대폰이 이 WiFi에 있거나 Tailscale로 연결되어 있어야 합니다. 계정이 필요하지 않습니다.",
"pathGroup": "휴대폰이 이 컴퓨터에 연결되는 방식",
"anywhereTitle": "Orca Relay",
"anywhereDescription": "휴대폰이 모바일 네트워크 또는 모든 WiFi에서 연결 가능합니다. 로그인이 필요합니다.",
"signInRequired": "Orca Mobile Relay를 사용하려면 로그인하세요.",
"anywhereDescription": "휴대폰이 모바일 네트워크 또는 모든 WiFi에서 연결할 수 있습니다. 로그인은 Relay에만 필요합니다.",
"signInRequired": "Relay만 해당 — LAN에는 계정이 필요하지 않습니다.",
"relayUnavailable": "이 빌드에서는 Orca Relay를 사용할 수 없습니다. LAN을 사용하세요.",
"signInAgain": "다시 로그인",
"signInAgain": "Relay용 다시 로그인",
"localTitle": "LAN"
},
"MobilePairingSetupSection": {
@ -9595,8 +9595,9 @@
"overview": "QR 코드를 생성한 후 Orca Mobile의 Pair Desktop에서 스캔하세요.",
"step1Title": "연결 방식",
"step2Title": "이 컴퓨터의 주소",
"step2RelayDescription": "가까운 곳에서는 빠른 직접 연결에 사용됩니다. 먼 거리에서는 Relay가 원격 액세스를 제공합니다.",
"step2LocalDescription": "휴대폰이 WiFi 또는 Tailscale을 통해 이 주소에 접근할 수 있어야 합니다."
"step2LocalDescription": "휴대폰이 Tailscale 또는 WiFi를 통해 이 주소에 접근할 수 있어야 합니다.",
"step2RelayDescription": "선택 사항. 근처에 있을 때(같은 WiFi 또는 Tailscale) 휴대폰이 사용할 주소를 고르세요. 보통 Relay보다 빠릅니다. 외부에 있을 때는 여전히 Relay를 사용합니다.",
"step2RelayDisclosure": "더 빠른 로컬 경로도 사용"
},
"MobileRelayBetaAvailability": {
"about": "Orca Relay 베타 정보",
@ -11756,7 +11757,9 @@
"stable": "안정 버전"
},
"relayDegradedNotice": "Relay에 연결할 수 없습니다 — 이 코드는 LAN 또는 Tailscale에서만 작동합니다.",
"pairingQrError": "이 페어링 코드를 QR 코드로 표시할 수 없습니다. 대신 Orca Mobile에 복사하세요."
"pairingQrError": "이 페어링 코드를 QR 코드로 표시할 수 없습니다. 대신 Orca Mobile에 복사하세요.",
"directAddressDisclosure": "더 빠른 로컬 경로도 사용",
"directAddressHint": "선택 사항. 근처에 있을 때(같은 WiFi 또는 Tailscale) 휴대폰이 사용할 주소를 고르세요. 보통 Relay보다 빠릅니다. 외부에 있을 때는 여전히 Relay를 사용합니다."
},
"MobilePage": {
"e17393c6a3": "휴대폰 미리보기",
@ -11899,7 +11902,8 @@
"repair-unverified": "Windows 방화벽 액세스를 확인할 수 없습니다",
"blocked-title": "Windows가 Orca Mobile을 차단하고 있을 수 있습니다",
"blocked-description": "기존 인바운드 차단 규칙이 페어링 예외보다 우선할 수 있습니다. 복구를 실행하면 이 Orca 앱의 충돌하는 TCP 규칙을 제거한 다음 개인 네트워크에서 포트 {{port}}을(를) 허용합니다.",
"repair": "방화벽 액세스 복구"
"repair": "방화벽 액세스 복구",
"relay-note": "페어링은 Orca Relay를 통해 계속 작동합니다. 허용하면 더 빠른 로컬 연결이 추가될 뿐입니다."
}
},
"gitlab": {

View File

@ -9589,14 +9589,14 @@
"available": "可用",
"reconnecting": "正在重新连接",
"unavailable": "不可用",
"signIn": "登录",
"localDescription": "手机必须连接此 WiFi 或您的 Tailscale。无需登录。",
"signIn": "登录以使用 Relay",
"localDescription": "手机必须连接此 WiFi 或通过 Tailscale。无需账号。",
"pathGroup": "手机访问此电脑的方式",
"anywhereTitle": "Orca Relay",
"anywhereDescription": "手机可通过蜂窝网络或任意 WiFi 连接。需要登录。",
"signInRequired": "请登录以使用 Orca Mobile Relay。",
"anywhereDescription": "手机可通过蜂窝网络或任意 WiFi 连接。仅 Relay 需要登录。",
"signInRequired": "仅 Relay 需要 — 局域网无需账号。",
"relayUnavailable": "此版本不支持 Orca Relay。请使用局域网。",
"signInAgain": "重新登录",
"signInAgain": "重新登录以使用 Relay",
"localTitle": "局域网"
},
"MobilePairingSetupSection": {
@ -9607,8 +9607,9 @@
"overview": "生成二维码,然后在 Orca Mobile 的 Pair Desktop 中扫描。",
"step1Title": "连接方式",
"step2Title": "此电脑的地址",
"step2RelayDescription": "距离较近时用于快速直接连接。Relay 覆盖远程访问。",
"step2LocalDescription": "手机必须能通过 WiFi 或 Tailscale 访问此地址。"
"step2LocalDescription": "手机必须能通过 Tailscale 或 WiFi 访问此地址。",
"step2RelayDescription": "可选。选择手机在附近(同一 WiFi 或 Tailscale时使用的地址通常比 Relay 更快。外出时仍走 Relay。",
"step2RelayDisclosure": "也可使用更快的本地连接"
},
"MobileRelayBetaAvailability": {
"about": "关于 Orca Relay 测试版",
@ -11776,7 +11777,9 @@
"stable": "稳定版"
},
"relayDegradedNotice": "无法连接 Relay — 此二维码仅在局域网或 Tailscale 内可用。",
"pairingQrError": "无法将此配对码呈现为二维码。请改为将其复制到 Orca Mobile。"
"pairingQrError": "无法将此配对码呈现为二维码。请改为将其复制到 Orca Mobile。",
"directAddressDisclosure": "也可使用更快的本地连接",
"directAddressHint": "可选。选择手机在附近(同一 WiFi 或 Tailscale时使用的地址通常比 Relay 更快。外出时仍走 Relay。"
},
"MobilePage": {
"e17393c6a3": "手机预览",
@ -11919,7 +11922,8 @@
"repair-unverified": "无法验证 Windows 防火墙访问权限",
"blocked-title": "Windows 可能正在阻止 Orca Mobile",
"blocked-description": "现有的入站阻止规则可能会覆盖配对例外。修复将移除此 Orca 应用的冲突 TCP 规则,然后在专用网络上允许端口 {{port}}。",
"repair": "修复防火墙访问权限"
"repair": "修复防火墙访问权限",
"relay-note": "配对仍可通过 Orca Relay 进行 — 允许后只是额外获得更快的本地连接。"
}
},
"gitlab": {