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": {