From c3db2b89fc027e0973ae82b3989951a41cfdced0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:34:42 -0700 Subject: [PATCH] Demote address picker to optional disclosure on Orca Relay (#12796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/main/ipc/mobile.test.ts | 45 ++++ src/main/ipc/mobile.ts | 23 +- src/renderer/src/assets/mobile-page.css | 32 +++ .../src/components/mobile/MobileHero.test.tsx | 78 ++++++ .../mobile/MobileHeroPairingStep.tsx | 120 ++++++--- .../mobile/WindowsFirewallNotice.test.tsx | 21 ++ .../mobile/WindowsFirewallNotice.tsx | 11 + ...mobile-pairing-address-preference.test.tsx | 84 +++++++ .../use-mobile-pairing-address-preference.ts | 7 +- .../MobilePairingConnectionOptions.test.tsx | 74 +++++- .../MobilePairingConnectionOptions.tsx | 230 +++++++----------- .../settings/MobilePairingPathOption.tsx | 75 ++++++ .../MobilePairingSetupSection.test.tsx | 46 +++- .../settings/MobilePairingSetupSection.tsx | 173 ++++++++----- .../components/settings/MobilePane.test.tsx | 20 +- .../src/components/settings/MobilePane.tsx | 9 +- .../settings/MobilePaneAddressSearch.test.tsx | 86 +++++++ .../settings/mobile-pane-search.test.ts | 36 +++ .../components/settings/mobile-pane-search.ts | 85 ++++--- src/renderer/src/i18n/locales/en.json | 24 +- src/renderer/src/i18n/locales/es.json | 22 +- src/renderer/src/i18n/locales/ja.json | 22 +- src/renderer/src/i18n/locales/ko.json | 22 +- src/renderer/src/i18n/locales/zh.json | 22 +- 24 files changed, 1032 insertions(+), 335 deletions(-) create mode 100644 src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx create mode 100644 src/renderer/src/components/settings/MobilePairingPathOption.tsx create mode 100644 src/renderer/src/components/settings/MobilePaneAddressSearch.test.tsx create mode 100644 src/renderer/src/components/settings/mobile-pane-search.test.ts diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 9f81c3a65..bc90f0b2b 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -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: [ diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 35584f951..0b391acc7 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -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 { diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 2303b6a9d..55d07eff5 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -861,6 +861,38 @@ cursor: not-allowed; } +/* Secondary collapsible; quieter than mp-text-link so it isn’t 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; diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index 6be96fff5..bbde9d2cf 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -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 = { + 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() + 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 Wi‑Fi or Tailscale address/i)).toBeVisible() + + rerender() + 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 = { + 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() + 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() + }) }) diff --git a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx index 2be436a80..be041e25e 100644 --- a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx +++ b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx @@ -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(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 = ( +
+ + {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} + + + +
+ ) + return (
@@ -220,39 +262,48 @@ export function MobileHeroPairingStep({ ) : null}
-
- - {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} - - - -
+ + + + + {/* 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. */} +
+

+ {translate( + 'auto.components.mobile.MobileHero.directAddressHint', + 'Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.' + )} +

+ {networkRow} +
+
+ + ) : ( + networkRow + )}
@@ -272,6 +323,7 @@ export function MobileHeroPairingStep({
diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx index e19b527fc..3196f0e34 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx @@ -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( + + ) + expect(await screen.findByText(/allow phone connections through/i)).toBeInTheDocument() + expect(screen.getByText(/still works over Orca Relay/i)).toBeInTheDocument() + + rerender() + 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 diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx index fb9d18276..5ac7f5caa 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx @@ -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(null) @@ -173,6 +176,14 @@ export function WindowsFirewallNotice({ { port: firewallStatus.port } )}

+ {usingRelay ? ( +

+ {translate( + 'auto.components.mobile.WindowsFirewallNotice.relay-note', + 'Pairing still works over Orca Relay — allowing this only adds the faster local connection.' + )} +

+ ) : null}
{networkIsPublic ? ( +
+ ) : null}
- { optionRefs.current['local-only'] = el }} @@ -244,64 +246,10 @@ export function MobilePairingConnectionOptions({ )} description={translate( 'auto.components.settings.MobilePairingConnectionOptions.localDescription', - 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' + 'Phone must be on this Wi‑Fi or connected through Tailscale. No account needed.' )} />
- - {needsSignIn ? ( -
-

- {translate( - 'auto.components.settings.MobilePairingConnectionOptions.signInRequired', - 'Sign in to use Orca Mobile Relay.' - )} -

- -
- ) : null} - - {relayUnavailable ? ( -
-

- {translate( - 'auto.components.settings.MobilePairingConnectionOptions.relayUnavailable', - 'Orca Relay isn’t available in this build. Use LAN.' - )} -

- - {translate( - 'auto.components.settings.MobilePairingConnectionOptions.unavailable', - 'Unavailable' - )} - -
- ) : null} ) } diff --git a/src/renderer/src/components/settings/MobilePairingPathOption.tsx b/src/renderer/src/components/settings/MobilePairingPathOption.tsx new file mode 100644 index 000000000..191b584d2 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingPathOption.tsx @@ -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 ( +
{ + 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' + )} + > + + {selected ? : null} + +
+
+ {title} + {trailing} +
+

{description}

+
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx index d66a9144c..ca7e13f5b 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -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 computer’s address')).toBeVisible() - expect(screen.getByRole('combobox')).toBeVisible() - expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible() + it('demotes this computer’s address to a disclosure when Orca Relay is selected', async () => { + const { user } = renderSection({ + connectionMode: 'automatic', + selectedAddress: undefined + }) + expect(screen.queryByText('This computer’s 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 computer’s address')).toBeVisible() + expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeDisabled() }) it('can move retry recovery into the persistent failure notice', () => { diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index 8367b49fd..c398e199d 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -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 = ( +
+
+ + + + + + + {translate( + 'auto.components.settings.MobilePairingSetupSection.refresh', + 'Refresh network interfaces' + )} + + +
+

+ {usingRelay + ? translate( + 'auto.components.settings.MobilePairingSetupSection.step2RelayDescription', + 'Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.' + ) + : translate( + 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', + 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' + )} +

+
+ ) return (
@@ -69,61 +139,50 @@ export function MobilePairingSetupSection({ {connectionPathControl} -
-

- {translate( - 'auto.components.settings.MobilePairingSetupSection.step2Title', - 'This computer’s address' - )} -

-
- - - - - - - {translate( - 'auto.components.settings.MobilePairingSetupSection.refresh', - 'Refresh network interfaces' - )} - - + {usingRelay && addressDisclosurePinned ? ( +
+

{relayAddressLabel}

+
+ {addressControls} +
-

- {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 Wi‑Fi.' - )} -

-
+ ) : usingRelay ? ( + // Why: Relay makes the address optional, not irrelevant — demote it to a + // disclosure so the direct fast path stays reachable without clutter. + + + + + +
+ {addressControls} +
+
+
+ ) : ( +
+

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.step2Title', + 'This computer’s address' + )} +

+ {addressControls} +
+ )} {showGenerateAction ? (
diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 948cc9661..1695263a7 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -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: () =>
})) -vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ + WindowsFirewallNotice: (props: { usingRelay?: boolean }) => ( +
{String(props.usingRelay)}
+ ) +})) 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() + + 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() diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 00fb7f343..8dafdf21c 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -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(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(null) @@ -386,6 +388,7 @@ export function MobilePane(): React.JSX.Element { - + Promise + 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 }) => ( +
{String(props.addressDisclosureForcedOpen)}
+ ) +})) +vi.mock('./MobilePairingConnectionOptions', () => ({ + MobilePairingConnectionOptions: () =>
+})) +vi.mock('./MobilePairingQrSection', () => ({ MobilePairingQrSection: () =>
})) +vi.mock('./MobilePairedDevicesSection', () => ({ MobilePairedDevicesSection: () =>
})) +vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) + +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() + expect(screen.getByTestId('address-forced-open')).toHaveTextContent(forced) + }) +}) diff --git a/src/renderer/src/components/settings/mobile-pane-search.test.ts b/src/renderer/src/components/settings/mobile-pane-search.test.ts new file mode 100644 index 000000000..fb909663e --- /dev/null +++ b/src/renderer/src/components/settings/mobile-pane-search.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/settings/mobile-pane-search.ts b/src/renderer/src/components/settings/mobile-pane-search.ts index 669a22a5c..576b0f86f 100644 --- a/src/renderer/src/components/settings/mobile-pane-search.ts +++ b/src/renderer/src/components/settings/mobile-pane-search.ts @@ -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', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9a4dc1498..a0c23b478 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9791,13 +9791,13 @@ "unavailable": "Unavailable", "pathGroup": "How the phone reaches this computer", "anywhereTitle": "Orca Relay", - "anywhereDescription": "Phone can be on cellular or any Wi‑Fi. Sign-in required.", - "signInRequired": "Sign in to use Orca Mobile Relay.", + "anywhereDescription": "Phone can be on cellular or any Wi‑Fi. Sign-in required for Relay only.", + "signInRequired": "Relay only — LAN does not need an account.", "relayUnavailable": "Orca Relay isn’t 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 Wi‑Fi or connected through Tailscale. No sign-in required.", + "localDescription": "Phone must be on this Wi‑Fi 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 computer’s 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 Wi‑Fi or Tailscale.", + "step2LocalDescription": "The phone must be able to reach this address on Tailscale or Wi‑Fi.", "regenerate": "Regenerate QR code", "generate": "Generate QR code", - "refresh": "Refresh network interfaces" + "refresh": "Refresh network interfaces", + "step2RelayDescription": "Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re 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 Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re 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…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f72d53c43..60c1f6a7a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9577,14 +9577,14 @@ "available": "Disponible", "reconnecting": "Reconectando", "unavailable": "No disponible", - "signIn": "Iniciar sesión", - "localDescription": "El teléfono debe estar en esta Wi‑Fi o en tu Tailscale. No requiere inicio de sesión.", + "signIn": "Iniciar sesión para Relay", + "localDescription": "El teléfono debe estar en esta Wi‑Fi 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 Wi‑Fi. 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 Wi‑Fi. 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 Wi‑Fi o Tailscale." + "step2LocalDescription": "El teléfono debe poder alcanzar esta dirección por Tailscale o Wi‑Fi.", + "step2RelayDescription": "Opcional. Elige la dirección Wi‑Fi 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 Wi‑Fi 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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e33148261..e9099533a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9577,14 +9577,14 @@ "available": "利用可能", "reconnecting": "再接続中", "unavailable": "利用不可", - "signIn": "サインイン", - "localDescription": "スマートフォンがこの Wi‑Fi または Tailscale に接続されている必要があります。サインインは不要です。", + "signIn": "Relay にサインイン", + "localDescription": "スマートフォンがこの Wi‑Fi または Tailscale に接続されている必要があります。アカウントは不要です。", "pathGroup": "スマートフォンがこのコンピューターに到達する方法", "anywhereTitle": "Orca Relay", - "anywhereDescription": "スマートフォンはモバイル回線または Wi‑Fi から接続できます。サインインが必要です。", - "signInRequired": "Orca Mobile Relay を使用するにはサインインしてください。", + "anywhereDescription": "スマートフォンはモバイル回線または任意の Wi‑Fi から接続できます。サインインが必要なのは 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": "スマートフォンが Wi‑Fi または Tailscale でこのアドレスに到達できる必要があります。" + "step2LocalDescription": "スマートフォンが Tailscale または Wi‑Fi でこのアドレスに到達できる必要があります。", + "step2RelayDescription": "任意。近く(同じ Wi‑Fi または 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": "任意。近く(同じ Wi‑Fi または 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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 82852ed46..5c3ab64d7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9577,14 +9577,14 @@ "available": "사용 가능", "reconnecting": "다시 연결 중", "unavailable": "사용할 수 없음", - "signIn": "로그인", - "localDescription": "휴대폰이 이 Wi‑Fi 또는 Tailscale에 연결되어 있어야 합니다. 로그인이 필요하지 않습니다.", + "signIn": "Relay용 로그인", + "localDescription": "휴대폰이 이 Wi‑Fi에 있거나 Tailscale로 연결되어 있어야 합니다. 계정이 필요하지 않습니다.", "pathGroup": "휴대폰이 이 컴퓨터에 연결되는 방식", "anywhereTitle": "Orca Relay", - "anywhereDescription": "휴대폰이 모바일 네트워크 또는 모든 Wi‑Fi에서 연결 가능합니다. 로그인이 필요합니다.", - "signInRequired": "Orca Mobile Relay를 사용하려면 로그인하세요.", + "anywhereDescription": "휴대폰이 모바일 네트워크 또는 모든 Wi‑Fi에서 연결할 수 있습니다. 로그인은 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": "휴대폰이 Wi‑Fi 또는 Tailscale을 통해 이 주소에 접근할 수 있어야 합니다." + "step2LocalDescription": "휴대폰이 Tailscale 또는 Wi‑Fi를 통해 이 주소에 접근할 수 있어야 합니다.", + "step2RelayDescription": "선택 사항. 근처에 있을 때(같은 Wi‑Fi 또는 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": "선택 사항. 근처에 있을 때(같은 Wi‑Fi 또는 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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1dacaaaee..6671acc8f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9589,14 +9589,14 @@ "available": "可用", "reconnecting": "正在重新连接", "unavailable": "不可用", - "signIn": "登录", - "localDescription": "手机必须连接此 Wi‑Fi 或您的 Tailscale。无需登录。", + "signIn": "登录以使用 Relay", + "localDescription": "手机必须连接此 Wi‑Fi 或通过 Tailscale。无需账号。", "pathGroup": "手机访问此电脑的方式", "anywhereTitle": "Orca Relay", - "anywhereDescription": "手机可通过蜂窝网络或任意 Wi‑Fi 连接。需要登录。", - "signInRequired": "请登录以使用 Orca Mobile Relay。", + "anywhereDescription": "手机可通过蜂窝网络或任意 Wi‑Fi 连接。仅 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": "手机必须能通过 Wi‑Fi 或 Tailscale 访问此地址。" + "step2LocalDescription": "手机必须能通过 Tailscale 或 Wi‑Fi 访问此地址。", + "step2RelayDescription": "可选。选择手机在附近(同一 Wi‑Fi 或 Tailscale)时使用的地址,通常比 Relay 更快。外出时仍走 Relay。", + "step2RelayDisclosure": "也可使用更快的本地连接" }, "MobileRelayBetaAvailability": { "about": "关于 Orca Relay 测试版", @@ -11776,7 +11777,9 @@ "stable": "稳定版" }, "relayDegradedNotice": "无法连接 Relay — 此二维码仅在局域网或 Tailscale 内可用。", - "pairingQrError": "无法将此配对码呈现为二维码。请改为将其复制到 Orca Mobile。" + "pairingQrError": "无法将此配对码呈现为二维码。请改为将其复制到 Orca Mobile。", + "directAddressDisclosure": "也可使用更快的本地连接", + "directAddressHint": "可选。选择手机在附近(同一 Wi‑Fi 或 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": {