From d452385bf7d7401926beea96530e568be755e9f4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:30:22 -0700 Subject: [PATCH] fix(mobile): offer scoped Windows Firewall repair (#8439) --- src/main/ipc/mobile.test.ts | 55 ++++- src/main/ipc/mobile.ts | 53 ++++- .../runtime/windows-mobile-firewall.test.ts | 116 +++++++++ src/main/runtime/windows-mobile-firewall.ts | 225 ++++++++++++++++++ src/preload/api-types.ts | 15 ++ src/preload/index.ts | 7 + .../src/components/mobile/MobileHero.tsx | 6 + .../mobile/WindowsFirewallNotice.test.tsx | 111 +++++++++ .../mobile/WindowsFirewallNotice.tsx | 168 +++++++++++++ .../src/components/settings/MobilePane.tsx | 3 + src/renderer/src/i18n/locales/en.json | 11 + src/renderer/src/i18n/locales/es.json | 11 + src/renderer/src/i18n/locales/ja.json | 11 + src/renderer/src/i18n/locales/ko.json | 11 + src/renderer/src/i18n/locales/zh.json | 11 + src/renderer/src/web/web-preload-api.ts | 3 + src/shared/windows-mobile-firewall.ts | 16 ++ 17 files changed, 830 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/windows-mobile-firewall.test.ts create mode 100644 src/main/runtime/windows-mobile-firewall.ts create mode 100644 src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx create mode 100644 src/renderer/src/components/mobile/WindowsFirewallNotice.tsx create mode 100644 src/shared/windows-mobile-firewall.ts diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index ea4b0602282..4091a298446 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -6,7 +6,9 @@ const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ - ipcMain: { handle: handleMock } + app: { isPackaged: false }, + ipcMain: { handle: handleMock }, + shell: { openExternal: vi.fn() } })) vi.mock('qrcode', () => ({ @@ -226,4 +228,55 @@ describe('registerMobileHandlers', () => { }) expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1') }) + + it('inspects and repairs the current packaged Windows websocket port', async () => { + const runPowerShell = vi + .fn() + .mockResolvedValueOnce( + JSON.stringify({ + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + .mockResolvedValueOnce('{"launched":true,"exitCode":0}') + const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' } + registerMobileHandlers(rpcServer as never, { + firewallEnvironment: { + platform: 'win32', + isPackaged: true, + executablePath: 'C:\\Program Files\\Orca\\Orca.exe', + runPowerShell + } + }) + + await expect( + handlers.get('mobile:getWindowsFirewallStatus')?.(null, { address: '192.168.0.108' }) + ).resolves.toMatchObject({ supported: true, port: 6768, ruleAllowed: false }) + await expect( + handlers.get('mobile:repairWindowsFirewall')?.({ + sender: { isDestroyed: () => false, getType: () => 'window' } + }) + ).resolves.toEqual({ ok: true }) + }) + + it('rejects firewall mutation from a non-window renderer', async () => { + const runPowerShell = vi.fn() + const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' } + registerMobileHandlers(rpcServer as never, { + firewallEnvironment: { + platform: 'win32', + isPackaged: true, + executablePath: 'C:\\Program Files\\Orca\\Orca.exe', + runPowerShell + } + }) + + expect( + handlers.get('mobile:repairWindowsFirewall')?.({ + sender: { isDestroyed: () => false, getType: () => 'webview' } + }) + ).toEqual({ ok: false, reason: 'unsupported' }) + expect(runPowerShell).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 11da72988d4..548835801ce 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -1,10 +1,16 @@ -import { ipcMain } from 'electron' +import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import QRCode from 'qrcode' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { + getWebSocketPort, + inspectWindowsMobileFirewall, + repairWindowsMobileFirewall, + type WindowsMobileFirewallEnvironment +} from '../runtime/windows-mobile-firewall' export type NetworkInterface = { name: string @@ -51,7 +57,21 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { // device management, and WebSocket readiness status. They depend on the // OrcaRuntimeRpcServer because it owns the device registry and TLS state. -export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { +export type MobileHandlerDependencies = { + firewallEnvironment?: WindowsMobileFirewallEnvironment + openWindowsNetworkSettings?: () => Promise +} + +export function registerMobileHandlers( + rpcServer: OrcaRuntimeRpcServer, + dependencies: MobileHandlerDependencies = {} +): void { + const firewallEnvironment = dependencies.firewallEnvironment ?? { + platform: process.platform, + isPackaged: app.isPackaged, + executablePath: process.execPath, + systemRoot: process.env.SystemRoot + } ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({ interfaces: getNetworkInterfaces() })) @@ -189,4 +209,33 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { endpoint: rpcServer.getWebSocketEndpoint() } }) + + ipcMain.handle('mobile:getWindowsFirewallStatus', (_event, args?: { address?: string }) => { + const port = getWebSocketPort(rpcServer.getWebSocketEndpoint()) + return inspectWindowsMobileFirewall(port, args?.address, firewallEnvironment) + }) + + ipcMain.handle('mobile:repairWindowsFirewall', (event: IpcMainInvokeEvent) => { + if (!isWindowRenderer(event)) { + return { ok: false as const, reason: 'unsupported' as const } + } + // Why: elevated inputs come from the running runtime, never the renderer. + const port = getWebSocketPort(rpcServer.getWebSocketEndpoint()) + return repairWindowsMobileFirewall(port, firewallEnvironment) + }) + + ipcMain.handle('mobile:openWindowsNetworkSettings', async (event: IpcMainInvokeEvent) => { + if (!isWindowRenderer(event) || firewallEnvironment.platform !== 'win32') { + return false + } + const openSettings = + dependencies.openWindowsNetworkSettings ?? + (() => shell.openExternal('ms-settings:network-status')) + await openSettings() + return true + }) +} + +function isWindowRenderer(event: IpcMainInvokeEvent): boolean { + return !event.sender.isDestroyed() && event.sender.getType() === 'window' } diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts new file mode 100644 index 00000000000..4a7cc8a1219 --- /dev/null +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getWebSocketPort, + inspectWindowsMobileFirewall, + repairWindowsMobileFirewall, + type WindowsMobileFirewallEnvironment +} from './windows-mobile-firewall' + +function environment( + runPowerShell: WindowsMobileFirewallEnvironment['runPowerShell'], + overrides: Partial = {} +): WindowsMobileFirewallEnvironment { + return { + platform: 'win32', + isPackaged: true, + executablePath: "C:\\Users\\O'Brien\\Orca\\Orca.exe", + systemRoot: 'C:\\Windows', + runPowerShell, + ...overrides + } +} + +describe('windows mobile firewall', () => { + it('inspects the exact executable, port, and selected interface profile', async () => { + const runPowerShell = vi.fn().mockResolvedValue( + JSON.stringify({ + ruleAllowed: true, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + + await expect( + inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell)) + ).resolves.toEqual({ + supported: true, + port: 6768, + ruleAllowed: true, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + + const script = runPowerShell.mock.calls[0]![0] as string + expect(script).toContain("LocalPort | Where-Object { [string]$_ -eq 'Any'") + expect(script).toContain("[string]$_ -eq '6768'") + expect(script).toContain("C:\\Users\\O''Brien\\Orca\\Orca.exe") + expect(script).toContain("$profile -match 'Private'") + expect(script).toContain("Get-NetIPAddress -IPAddress '192.168.0.108'") + }) + + it('does not support non-Windows or unpackaged development builds', async () => { + const runPowerShell = vi.fn() + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(runPowerShell, { platform: 'darwin' }) + ) + ).resolves.toEqual({ supported: false }) + await expect( + repairWindowsMobileFirewall(6768, environment(runPowerShell, { isPackaged: false })) + ).resolves.toEqual({ ok: false, reason: 'unsupported' }) + expect(runPowerShell).not.toHaveBeenCalled() + }) + + it('returns an actionable status when inspection is unavailable', async () => { + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(vi.fn().mockRejectedValue(new Error('managed policy'))) + ) + ).resolves.toEqual({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'unknown', + inspectionAvailable: false + }) + }) + + it('repairs only Orca mobile pairing on private networks after elevation', async () => { + const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}') + await expect(repairWindowsMobileFirewall(6769, environment(runPowerShell))).resolves.toEqual({ + ok: true + }) + + const outerScript = runPowerShell.mock.calls[0]![0] as string + const encoded = outerScript.match(/'-EncodedCommand', '([^']+)'/)?.[1] + expect(encoded).toBeTruthy() + const repairScript = Buffer.from(encoded!, 'base64').toString('utf16le') + expect(repairScript).toContain("-Name 'Orca.MobilePairing'") + expect(repairScript).toContain('-Profile Private') + expect(repairScript).toContain('-Protocol TCP') + expect(repairScript).toContain('-LocalPort 6769') + expect(repairScript).toContain("-Program 'C:\\Users\\O''Brien\\Orca\\Orca.exe'") + expect(repairScript).toContain('-EdgeTraversalPolicy Block') + }) + + it('distinguishes a cancelled UAC prompt from repair failure', async () => { + await expect( + repairWindowsMobileFirewall( + 6768, + environment(vi.fn().mockResolvedValue('{"launched":false,"nativeErrorCode":1223}')) + ) + ).resolves.toEqual({ ok: false, reason: 'cancelled' }) + }) + + it('extracts only explicit valid websocket ports', () => { + expect(getWebSocketPort('ws://0.0.0.0:6768')).toBe(6768) + expect(getWebSocketPort('ws://0.0.0.0')).toBeNull() + expect(getWebSocketPort('not a url')).toBeNull() + }) +}) diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts new file mode 100644 index 00000000000..b6419da9f5d --- /dev/null +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -0,0 +1,225 @@ +import { execFile } from 'node:child_process' +import { win32 } from 'node:path' +import type { + WindowsMobileFirewallRepairResult, + WindowsMobileFirewallStatus, + WindowsNetworkCategory +} from '../../shared/windows-mobile-firewall' + +const FIREWALL_RULE_NAME = 'Orca.MobilePairing' +const FIREWALL_RULE_DISPLAY_NAME = 'Orca Mobile Pairing' +const POWERSHELL_TIMEOUT_MS = 10_000 +const ELEVATION_TIMEOUT_MS = 5 * 60_000 + +type PowerShellRunner = (script: string, timeoutMs: number) => Promise + +export type WindowsMobileFirewallEnvironment = { + platform: NodeJS.Platform + isPackaged: boolean + executablePath: string + systemRoot?: string + runPowerShell?: PowerShellRunner +} + +type FirewallInspection = { + ruleAllowed: boolean + privateFirewallEnabled: boolean + networkCategory: string +} + +type ElevationResult = { + launched: boolean + exitCode?: number + nativeErrorCode?: number +} + +export async function inspectWindowsMobileFirewall( + port: number | null, + address: string | undefined, + environment: WindowsMobileFirewallEnvironment = defaultEnvironment() +): Promise { + if (!isSupported(environment, port)) { + return { supported: false } + } + + try { + const stdout = await getRunner(environment)( + buildInspectionScript(port, environment.executablePath, address), + POWERSHELL_TIMEOUT_MS + ) + const result = JSON.parse(stdout.trim()) as FirewallInspection + return { + supported: true, + port, + ruleAllowed: result.ruleAllowed === true, + privateFirewallEnabled: result.privateFirewallEnabled !== false, + networkCategory: parseNetworkCategory(result.networkCategory), + inspectionAvailable: true + } + } catch { + // Why: firewall inspection is advisory; unavailable PowerShell or managed + // policy must not block pairing or remove the explicit repair option. + return unavailableStatus(port) + } +} + +export async function repairWindowsMobileFirewall( + port: number | null, + environment: WindowsMobileFirewallEnvironment = defaultEnvironment() +): Promise { + if (!isSupported(environment, port)) { + return { ok: false, reason: 'unsupported' } + } + + const powershellPath = getWindowsPowerShellPath(environment.systemRoot) + const elevatedScript = buildRepairScript(port, environment.executablePath) + const outerScript = buildElevationScript(powershellPath, encodePowerShell(elevatedScript)) + try { + const stdout = await getRunner(environment)(outerScript, ELEVATION_TIMEOUT_MS) + const result = JSON.parse(stdout.trim()) as ElevationResult + if (!result.launched && result.nativeErrorCode === 1223) { + return { ok: false, reason: 'cancelled' } + } + return result.launched && result.exitCode === 0 ? { ok: true } : { ok: false, reason: 'failed' } + } catch { + return { ok: false, reason: 'failed' } + } +} + +export function getWebSocketPort(endpoint: string | null): number | null { + if (!endpoint) { + return null + } + try { + const parsed = new URL(endpoint) + const port = Number(parsed.port) + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null + } catch { + return null + } +} + +function defaultEnvironment(): WindowsMobileFirewallEnvironment { + return { + platform: process.platform, + // Why: direct helper calls must fail closed; the IPC integration opts in + // only after Electron confirms this is a packaged Windows build. + isPackaged: false, + executablePath: process.execPath, + systemRoot: process.env.SystemRoot + } +} + +function isSupported( + environment: WindowsMobileFirewallEnvironment, + port: number | null +): port is number { + // Why: development Electron paths are transient and must never be persisted + // into an elevated firewall rule that outlives the checkout. + return environment.platform === 'win32' && environment.isPackaged && port !== null +} + +function unavailableStatus(port: number): WindowsMobileFirewallStatus { + return { + supported: true, + port, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'unknown', + inspectionAvailable: false + } +} + +function parseNetworkCategory(value: string): WindowsNetworkCategory { + if (value === 'Private') { + return 'private' + } + if (value === 'Public') { + return 'public' + } + if (value === 'DomainAuthenticated') { + return 'domain' + } + return 'unknown' +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'` +} + +function buildInspectionScript(port: number, executablePath: string, address?: string): string { + const addressLookup = address + ? ` +try { + $ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1 + $profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1 + if ($profile) { $networkCategory = [string]$profile.NetworkCategory } +} catch {}` + : '' + return `$ErrorActionPreference = 'Stop' +$ruleAllowed = $false +$rules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' }) +foreach ($rule in $rules) { + $portFilter = $rule | Get-NetFirewallPortFilter + $protocol = [string]$portFilter.Protocol + $profile = [string]$rule.Profile + $portMatches = @($portFilter.LocalPort | Where-Object { [string]$_ -eq 'Any' -or [string]$_ -eq '${port}' }).Count -gt 0 + if (($protocol -eq 'Any' -or $protocol -eq 'TCP' -or $protocol -eq '6') -and ($profile -eq 'Any' -or $profile -match 'Private') -and $portMatches) { + $ruleAllowed = $true + } +} +$privateFirewallEnabled = [bool](Get-NetFirewallProfile -Name Private).Enabled +$networkCategory = 'Unknown'${addressLookup} +[pscustomobject]@{ + ruleAllowed = $ruleAllowed + privateFirewallEnabled = $privateFirewallEnabled + networkCategory = $networkCategory +} | ConvertTo-Json -Compress` +} + +function buildRepairScript(port: number, executablePath: string): string { + return `$ErrorActionPreference = 'Stop' +Get-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule +New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShell(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShell(executablePath)} -EdgeTraversalPolicy Block | Out-Null` +} + +function buildElevationScript(powershellPath: string, encodedRepairScript: string): string { + return `$ErrorActionPreference = 'Stop' +try { + $process = Start-Process -FilePath ${quotePowerShell(powershellPath)} -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', '${encodedRepairScript}') -Verb RunAs -Wait -PassThru + [pscustomobject]@{ launched = $true; exitCode = $process.ExitCode } | ConvertTo-Json -Compress +} catch { + [pscustomobject]@{ launched = $false; nativeErrorCode = $_.Exception.NativeErrorCode } | ConvertTo-Json -Compress +}` +} + +function encodePowerShell(script: string): string { + return Buffer.from(script, 'utf16le').toString('base64') +} + +function getWindowsPowerShellPath(systemRoot = 'C:\\Windows'): string { + return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') +} + +function getRunner(environment: WindowsMobileFirewallEnvironment): PowerShellRunner { + return environment.runPowerShell ?? createPowerShellRunner(environment.systemRoot) +} + +function createPowerShellRunner(systemRoot?: string): PowerShellRunner { + const powershellPath = getWindowsPowerShellPath(systemRoot) + return (script, timeoutMs) => + new Promise((resolve, reject) => { + execFile( + powershellPath, + ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShell(script)], + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c25d57723f5..29ddafafb85 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3133,6 +3133,21 @@ export type PreloadApi = { deviceId: string } > + getWindowsFirewallStatus: (args?: { address?: string }) => Promise< + | { supported: false } + | { + supported: true + port: number + ruleAllowed: boolean + privateFirewallEnabled: boolean + networkCategory: 'private' | 'public' | 'domain' | 'unknown' + inspectionAvailable: boolean + } + > + repairWindowsFirewall: () => Promise< + { ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' } + > + openWindowsNetworkSettings: () => Promise getRuntimePairingUrl: (args?: { address?: string; rotate?: boolean }) => Promise< | { available: false } | { diff --git a/src/preload/index.ts b/src/preload/index.ts index ddc85ada4f4..35224416e4b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4242,6 +4242,13 @@ const api = { } > => ipcRenderer.invoke('mobile:getPairingQR', args), + getWindowsFirewallStatus: (args?: { address?: string }) => + ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args), + + repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'), + + openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'), + getRuntimePairingUrl: (args?: { address?: string rotate?: boolean diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index af22a1dff6c..64e0ca7344f 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -4,6 +4,7 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' import { NetworkInterfacePicker } from './NetworkInterfacePicker' import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy' +import { WindowsFirewallNotice } from './WindowsFirewallNotice' export { HeroIntro } from './MobileHeroIntro' export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices' import { translate } from '@/i18n/i18n' @@ -239,6 +240,11 @@ export function HeroFlow({ {translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')} +
= {}): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getWindowsFirewallStatus: vi.fn().mockResolvedValue({ supported: false }), + repairWindowsFirewall: vi.fn().mockResolvedValue({ ok: true }), + openWindowsNetworkSettings: vi.fn().mockResolvedValue(true), + ...overrides + } + } + }) +} + +describe('WindowsFirewallNotice', () => { + it('stays hidden until a pairing code exists and Windows reports a missing rule', async () => { + const getWindowsFirewallStatus = vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + setMobileApi({ getWindowsFirewallStatus }) + const { rerender } = render( + + ) + expect(screen.queryByText(/allow phone connections through/i)).not.toBeInTheDocument() + + rerender() + expect(await screen.findByText(/allow phone connections through/i)).toBeInTheDocument() + expect(screen.getByText(/TCP port 6768/i)).toBeInTheDocument() + }) + + it('repairs only after explicit user action and hides after success', async () => { + const repairWindowsFirewall = vi.fn().mockResolvedValue({ ok: true }) + setMobileApi({ + getWindowsFirewallStatus: vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }), + repairWindowsFirewall + }) + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: /allow phone connections/i })) + expect(repairWindowsFirewall).toHaveBeenCalledTimes(1) + await waitFor(() => + expect(screen.queryByText(/allow phone connections through/i)).not.toBeInTheDocument() + ) + }) + + it('does not offer a firewall rule while the selected network is public', async () => { + const openWindowsNetworkSettings = vi.fn().mockResolvedValue(true) + setMobileApi({ + getWindowsFirewallStatus: vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: true, + privateFirewallEnabled: false, + networkCategory: 'public', + inspectionAvailable: true + }), + openWindowsNetworkSettings + }) + const user = userEvent.setup() + render() + + expect(await screen.findByText(/marks this network as public/i)).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /allow phone connections/i }) + ).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /open network settings/i })) + expect(openWindowsNetworkSettings).toHaveBeenCalledTimes(1) + }) + + it('does not offer a Private-profile rule on a managed domain network', async () => { + const getWindowsFirewallStatus = vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + privateFirewallEnabled: true, + networkCategory: 'domain', + inspectionAvailable: true + }) + setMobileApi({ + getWindowsFirewallStatus + }) + + render() + + await waitFor(() => expect(getWindowsFirewallStatus).toHaveBeenCalledTimes(1)) + expect(screen.queryByRole('button', { name: /allow phone connections/i })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx new file mode 100644 index 00000000000..9fd06428246 --- /dev/null +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useState } from 'react' +import { CircleAlert, Loader2, ShieldCheck } from 'lucide-react' +import { toast } from 'sonner' +import type { WindowsMobileFirewallStatus } from '../../../../shared/windows-mobile-firewall' +import { useMountedRef } from '../../hooks/useMountedRef' +import { translate } from '../../i18n/i18n' +import { Button } from '../ui/button' +import { cn } from '../../lib/utils' + +type WindowsFirewallNoticeProps = { + pairingReady: boolean + address?: string + className?: string +} + +export function WindowsFirewallNotice({ + pairingReady, + address, + className +}: WindowsFirewallNoticeProps): React.JSX.Element | null { + const [status, setStatus] = useState(null) + const [repairing, setRepairing] = useState(false) + const mountedRef = useMountedRef() + + const inspect = useCallback(async () => { + if (!pairingReady) { + setStatus(null) + return + } + try { + const next = await window.api.mobile.getWindowsFirewallStatus( + address ? { address } : undefined + ) + if (mountedRef.current) { + setStatus(next) + } + } catch { + if (mountedRef.current) { + setStatus(null) + } + } + }, [address, mountedRef, pairingReady]) + + useEffect(() => { + void inspect() + window.addEventListener('focus', inspect) + return () => window.removeEventListener('focus', inspect) + }, [inspect]) + + if (!status?.supported) { + return null + } + const firewallStatus = status + const networkIsPublic = firewallStatus.networkCategory === 'public' + // Why: a Private-profile allow rule cannot help on managed domain networks. + if (!pairingReady || firewallStatus.networkCategory === 'domain') { + return null + } + if (!networkIsPublic && (firewallStatus.ruleAllowed || !firewallStatus.privateFirewallEnabled)) { + return null + } + + async function repair(): Promise { + setRepairing(true) + try { + const result = await window.api.mobile.repairWindowsFirewall() + if (!mountedRef.current) { + return + } + if (result.ok) { + setStatus({ ...firewallStatus, ruleAllowed: true }) + toast.success( + translate( + 'auto.components.mobile.WindowsFirewallNotice.repair-success', + 'Windows Firewall now allows Orca Mobile on private networks' + ) + ) + return + } + if (result.reason !== 'cancelled') { + toast.error( + translate( + 'auto.components.mobile.WindowsFirewallNotice.repair-failed', + 'Could not add the Windows Firewall rule' + ) + ) + } + } catch { + if (mountedRef.current) { + toast.error( + translate( + 'auto.components.mobile.WindowsFirewallNotice.repair-failed', + 'Could not add the Windows Firewall rule' + ) + ) + } + } finally { + if (mountedRef.current) { + setRepairing(false) + } + } + } + + return ( +
+
+ +
+
+

+ {networkIsPublic + ? translate( + 'auto.components.mobile.WindowsFirewallNotice.public-title', + 'Windows marks this network as public' + ) + : translate( + 'auto.components.mobile.WindowsFirewallNotice.missing-title', + 'Allow phone connections through Windows Firewall' + )} +

+

+ {networkIsPublic + ? translate( + 'auto.components.mobile.WindowsFirewallNotice.public-description', + 'Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.' + ) + : translate( + 'auto.components.mobile.WindowsFirewallNotice.missing-description', + 'Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.', + { port: firewallStatus.port } + )} +

+
+ {networkIsPublic ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 62f93109850..650d42c29ad 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -11,6 +11,7 @@ import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' import { MobilePairingQrSection } from './MobilePairingQrSection' import { MobilePairedDevicesSection, type PairedDevice } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' +import { WindowsFirewallNotice } from '../mobile/WindowsFirewallNotice' import { translate } from '@/i18n/i18n' export { getMobilePaneSearchEntries } from './mobile-pane-search' @@ -182,6 +183,8 @@ export function MobilePane(): React.JSX.Element { onClearCodeCopiedTimer={clearCodeCopiedResetTimer} /> + + { mobile: { listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), getPairingQR: () => Promise.resolve({ available: false }), + getWindowsFirewallStatus: () => Promise.resolve({ supported: false }), + repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }), + openWindowsNetworkSettings: () => Promise.resolve(false), getRuntimePairingUrl: () => Promise.resolve({ available: false }), listDevices: () => Promise.resolve({ devices: [] }), revokeDevice: () => Promise.resolve({ revoked: false }), diff --git a/src/shared/windows-mobile-firewall.ts b/src/shared/windows-mobile-firewall.ts new file mode 100644 index 00000000000..f6570031723 --- /dev/null +++ b/src/shared/windows-mobile-firewall.ts @@ -0,0 +1,16 @@ +export type WindowsNetworkCategory = 'private' | 'public' | 'domain' | 'unknown' + +export type WindowsMobileFirewallStatus = + | { supported: false } + | { + supported: true + port: number + ruleAllowed: boolean + privateFirewallEnabled: boolean + networkCategory: WindowsNetworkCategory + inspectionAvailable: boolean + } + +export type WindowsMobileFirewallRepairResult = + | { ok: true } + | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' }