Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen

This commit is contained in:
Brennan Benson
2026-07-12 17:35:31 -07:00
17 changed files with 830 additions and 3 deletions
+54 -1
View File
@@ -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()
})
})
+51 -2
View File
@@ -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<void>
}
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'
}
@@ -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> = {}
): 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()
})
})
+225
View File
@@ -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<string>
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<WindowsMobileFirewallStatus> {
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<WindowsMobileFirewallRepairResult> {
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)
}
)
})
}
+15
View File
@@ -3137,6 +3137,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<boolean>
getRuntimePairingUrl: (args?: { address?: string; rotate?: boolean }) => Promise<
| { available: false }
| {
+7
View File
@@ -4244,6 +4244,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
@@ -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')}
</button>
</div>
<WindowsFirewallNotice
pairingReady={pairQrDataUrl != null}
address={selectedAddress}
className="mt-3"
/>
</div>
<div className="mp-qr-stack">
<div
@@ -0,0 +1,111 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WindowsFirewallNotice } from './WindowsFirewallNotice'
afterEach(cleanup)
function setMobileApi(overrides: Record<string, unknown> = {}): 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(
<WindowsFirewallNotice pairingReady={false} address="192.168.0.108" />
)
expect(screen.queryByText(/allow phone connections through/i)).not.toBeInTheDocument()
rerender(<WindowsFirewallNotice pairingReady address="192.168.0.108" />)
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(<WindowsFirewallNotice pairingReady address="192.168.0.108" />)
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(<WindowsFirewallNotice pairingReady address="192.168.0.108" />)
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(<WindowsFirewallNotice pairingReady address="10.0.0.4" />)
await waitFor(() => expect(getWindowsFirewallStatus).toHaveBeenCalledTimes(1))
expect(screen.queryByRole('button', { name: /allow phone connections/i })).toBeNull()
})
})
@@ -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<WindowsMobileFirewallStatus | null>(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<void> {
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 (
<div className={cn('rounded-lg border border-border bg-muted/40 p-3', className)}>
<div className="flex items-start gap-2.5">
<CircleAlert className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<p className="text-sm font-medium">
{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'
)}
</p>
<p className="text-xs text-muted-foreground">
{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 }
)}
</p>
</div>
{networkIsPublic ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void window.api.mobile.openWindowsNetworkSettings()}
>
{translate(
'auto.components.mobile.WindowsFirewallNotice.open-settings',
'Open network settings'
)}
</Button>
) : (
<Button type="button" size="sm" onClick={() => void repair()} disabled={repairing}>
{repairing ? (
<Loader2 className="animate-spin" />
) : (
<ShieldCheck aria-hidden="true" />
)}
{repairing
? translate(
'auto.components.mobile.WindowsFirewallNotice.waiting',
'Waiting for Windows…'
)
: translate(
'auto.components.mobile.WindowsFirewallNotice.allow',
'Allow phone connections'
)}
</Button>
)}
</div>
</div>
</div>
)
}
@@ -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}
/>
<WindowsFirewallNotice pairingReady={qrDataUrl != null} address={selectedAddress} />
<MobilePairedDevicesSection
devices={devices}
hasQrCode={qrDataUrl != null}
+11
View File
@@ -10643,6 +10643,17 @@
"trigger-label": "Network address to advertise",
"custom-option": "{{address}} (custom)",
"add-custom": "Add custom address…"
},
"WindowsFirewallNotice": {
"repair-success": "Windows Firewall now allows Orca Mobile on private networks",
"repair-failed": "Could not add the Windows Firewall rule",
"public-title": "Windows marks this network as public",
"missing-title": "Allow phone connections through Windows Firewall",
"public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.",
"missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.",
"open-settings": "Open network settings",
"waiting": "Waiting for Windows…",
"allow": "Allow phone connections"
}
},
"gitlab": {
+11
View File
@@ -10643,6 +10643,17 @@
"trigger-label": "Dirección de red para anunciar",
"custom-option": "{{address}} (personalizada)",
"add-custom": "Añadir dirección personalizada…"
},
"WindowsFirewallNotice": {
"repair-success": "Windows Firewall now allows Orca Mobile on private networks",
"repair-failed": "Could not add the Windows Firewall rule",
"public-title": "Windows marks this network as public",
"missing-title": "Allow phone connections through Windows Firewall",
"public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.",
"missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.",
"open-settings": "Open network settings",
"waiting": "Waiting for Windows…",
"allow": "Allow phone connections"
}
},
"gitlab": {
+11
View File
@@ -10643,6 +10643,17 @@
"trigger-label": "通知するネットワークアドレス",
"custom-option": "{{address}}(カスタム)",
"add-custom": "カスタムアドレスを追加…"
},
"WindowsFirewallNotice": {
"repair-success": "Windows Firewall now allows Orca Mobile on private networks",
"repair-failed": "Could not add the Windows Firewall rule",
"public-title": "Windows marks this network as public",
"missing-title": "Allow phone connections through Windows Firewall",
"public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.",
"missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.",
"open-settings": "Open network settings",
"waiting": "Waiting for Windows…",
"allow": "Allow phone connections"
}
},
"gitlab": {
+11
View File
@@ -10643,6 +10643,17 @@
"trigger-label": "알릴 네트워크 주소",
"custom-option": "{{address}} (사용자 지정)",
"add-custom": "사용자 지정 주소 추가…"
},
"WindowsFirewallNotice": {
"repair-success": "Windows Firewall now allows Orca Mobile on private networks",
"repair-failed": "Could not add the Windows Firewall rule",
"public-title": "Windows marks this network as public",
"missing-title": "Allow phone connections through Windows Firewall",
"public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.",
"missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.",
"open-settings": "Open network settings",
"waiting": "Waiting for Windows…",
"allow": "Allow phone connections"
}
},
"gitlab": {
+11
View File
@@ -10643,6 +10643,17 @@
"trigger-label": "要公布的网络地址",
"custom-option": "{{address}}(自定义)",
"add-custom": "添加自定义地址…"
},
"WindowsFirewallNotice": {
"repair-success": "Windows Firewall now allows Orca Mobile on private networks",
"repair-failed": "Could not add the Windows Firewall rule",
"public-title": "Windows marks this network as public",
"missing-title": "Allow phone connections through Windows Firewall",
"public-description": "Change this trusted Wi-Fi network to Private before allowing Orca Mobile connections.",
"missing-description": "Windows may block the pairing server. Add a rule for this Orca app and TCP port {{port}} on Private networks.",
"open-settings": "Open network settings",
"waiting": "Waiting for Windows…",
"allow": "Allow phone connections"
}
},
"gitlab": {
+3
View File
@@ -750,6 +750,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
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 }),
+16
View File
@@ -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' }