mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen
This commit is contained in:
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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' }
|
||||
Reference in New Issue
Block a user