From 403818fed249c7bda81c30be7ef3d371c72b6c59 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:22:40 -0700 Subject: [PATCH] feat(permissions): add local network connection test --- src/main/ipc/developer-permissions.ts | 7 + .../ipc/local-network-connection-test.test.ts | 89 +++++++++ src/main/ipc/local-network-connection-test.ts | 167 ++++++++++++++++ src/preload/api-types.ts | 7 +- src/preload/index.ts | 4 +- .../settings/DeveloperPermissionsPane.tsx | 55 ++--- .../LocalNetworkConnectionTest.test.tsx | 96 +++++++++ .../settings/LocalNetworkConnectionTest.tsx | 188 ++++++++++++++++++ .../local-network-connection-history.ts | 45 +++++ src/renderer/src/i18n/locales/en.json | 17 +- src/renderer/src/web/web-preload-api.ts | 10 +- src/shared/developer-permissions-types.ts | 17 ++ 12 files changed, 672 insertions(+), 30 deletions(-) create mode 100644 src/main/ipc/local-network-connection-test.test.ts create mode 100644 src/main/ipc/local-network-connection-test.ts create mode 100644 src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx create mode 100644 src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx create mode 100644 src/renderer/src/components/settings/local-network-connection-history.ts diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index f4a0e91232b..cfc10e61aef 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' import { ipcMain, shell, systemPreferences } from 'electron' import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' +import { testLocalNetworkConnection } from './local-network-connection-test' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, @@ -237,4 +238,10 @@ export function registerDeveloperPermissionHandlers(): void { await openPrivacyPane(args.id) } ) + + ipcMain.handle('developerPermissions:testLocalNetworkConnection', async (_event, args: unknown) => + testLocalNetworkConnection( + args && typeof args === 'object' ? (args as { host?: unknown; port?: unknown }) : {} + ) + ) } diff --git a/src/main/ipc/local-network-connection-test.test.ts b/src/main/ipc/local-network-connection-test.test.ts new file mode 100644 index 00000000000..d9a62863b82 --- /dev/null +++ b/src/main/ipc/local-network-connection-test.test.ts @@ -0,0 +1,89 @@ +import { createServer } from 'node:net' +import { describe, expect, it, vi } from 'vitest' +import { testLocalNetworkConnection } from './local-network-connection-test' + +describe('testLocalNetworkConnection', () => { + it('runs a bounded Electron child probe and reports a verified connection', async () => { + const runChild = vi.fn((_file, _args, _options, callback) => callback(null, '', '')) + + await expect( + testLocalNetworkConnection( + { host: '192.168.1.20', port: 3000 }, + { platform: 'darwin', now: () => 1234, runChild } + ) + ).resolves.toEqual({ + ok: true, + host: '192.168.1.20', + port: 3000, + testedAt: 1234 + }) + expect(runChild).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining(['192.168.1.20', '3000']), + expect.objectContaining({ + timeout: 5000, + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }) + }), + expect.any(Function) + ) + }) + + it('rejects a hostname that resolves to loopback in the real child probe', async () => { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP listener address') + } + + try { + await expect( + testLocalNetworkConnection( + { host: '2130706433', port: address.port }, + { platform: 'darwin' } + ) + ).resolves.toMatchObject({ ok: false, failure: 'invalid-target' }) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + }) + + it.each([ + ['INVALID_TARGET', 'invalid-target'], + ['ETIMEDOUT', 'timeout'], + ['ECONNREFUSED', 'refused'], + ['EHOSTUNREACH', 'unreachable'], + ['ENETUNREACH', 'unreachable'], + ['ENOTFOUND', 'unresolved'] + ] as const)('maps %s without claiming a permission denial', async (stderr, failure) => { + const runChild = vi.fn((_file, _args, _options, callback) => + callback(Object.assign(new Error(stderr), { code: 1 }), '', stderr) + ) + + await expect( + testLocalNetworkConnection( + { host: 'devbox.local', port: 8080 }, + { platform: 'darwin', runChild } + ) + ).resolves.toMatchObject({ ok: false, failure }) + }) + + it.each([ + { host: '127.0.0.1', port: 3000 }, + { host: 'localhost', port: 3000 }, + { host: 'service.localhost', port: 3000 }, + { host: '8.8.8.8', port: 53 }, + { host: 'https://devbox.local', port: 443 }, + { host: 'devbox.local', port: 0 } + ])('rejects a target that does not test a LAN connection: $host:$port', async (target) => { + const runChild = vi.fn() + + await expect( + testLocalNetworkConnection(target, { platform: 'darwin', runChild }) + ).resolves.toMatchObject({ ok: false, failure: 'invalid-target' }) + expect(runChild).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/local-network-connection-test.ts b/src/main/ipc/local-network-connection-test.ts new file mode 100644 index 00000000000..5cc7c35f4ee --- /dev/null +++ b/src/main/ipc/local-network-connection-test.ts @@ -0,0 +1,167 @@ +import { execFile, type ExecFileException, type ExecFileOptions } from 'node:child_process' +import { isIP } from 'node:net' +import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' +import type { + LocalNetworkConnectionTestFailure, + LocalNetworkConnectionTestResult +} from '../../shared/developer-permissions-types' + +const CONNECT_TIMEOUT_MS = 4_000 +const CHILD_TIMEOUT_MS = CONNECT_TIMEOUT_MS + 1_000 +const CONNECT_SCRIPT = ` +const net = require('node:net') +const host = process.argv[1] +const port = Number(process.argv[2]) +const socket = net.createConnection({ host, port }) +let settled = false +function finish(code) { + if (settled) return + settled = true + if (code) { + process.stderr.write(code) + process.exitCode = 1 + } + socket.destroy() +} +function isLanAddress(address) { + const normalized = (address || '').toLowerCase().split('%', 1)[0] + const mapped = normalized.match(/^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/)?.[1] + const ipv4 = mapped || (net.isIP(normalized) === 4 ? normalized : null) + if (ipv4) { + const octets = ipv4.split('.').map(Number) + return octets[0] === 10 || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] === 192 && octets[1] === 168) || + (octets[0] === 169 && octets[1] === 254) + } + if (net.isIP(normalized) === 6) { + const first = Number.parseInt(normalized.split(':')[0], 16) + return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80 + } + return false +} +socket.setTimeout(${CONNECT_TIMEOUT_MS}) +socket.once('connect', () => finish(isLanAddress(socket.remoteAddress) ? undefined : 'INVALID_TARGET')) +socket.once('timeout', () => finish('ETIMEDOUT')) +socket.once('error', (error) => finish(error.code || 'FAILED')) +` + +type ConnectionChildRunner = ( + file: string, + args: string[], + options: ExecFileOptions, + callback: (error: ExecFileException | null, stdout: string, stderr: string) => void +) => unknown + +type LocalNetworkConnectionTestOptions = { + platform?: NodeJS.Platform + now?: () => number + runChild?: ConnectionChildRunner +} + +function normalizeTargetHost(rawHost: unknown): string | null { + if (typeof rawHost !== 'string') { + return null + } + const host = rawHost.trim().replace(/^\[|\]$/g, '') + if ( + !host || + host.startsWith('-') || + host.length > 253 || + /[\s/\\]/.test(host) || + host.includes('://') || + !/^[a-z0-9.:%_-]+$/i.test(host) + ) { + return null + } + const addressWithoutZone = host.split('%', 1)[0] ?? host + const kind = classifyRemotePairingHostname(addressWithoutZone) + if (isIP(addressWithoutZone) === 0) { + return kind === 'loopback' ? null : host + } + const isLinkLocalIpv4 = addressWithoutZone.startsWith('169.254.') + return kind === 'lan' || isLinkLocalIpv4 ? host : null +} + +function normalizeTargetPort(rawPort: unknown): number | null { + return typeof rawPort === 'number' && + Number.isInteger(rawPort) && + rawPort >= 1 && + rawPort <= 65_535 + ? rawPort + : null +} + +function failureFromChild( + error: ExecFileException, + stderr: string +): LocalNetworkConnectionTestFailure { + if (error.killed || error.signal) { + return 'timeout' + } + const code = stderr.trim().split(/\s/, 1)[0] + switch (code) { + case 'INVALID_TARGET': + return 'invalid-target' + case 'ETIMEDOUT': + return 'timeout' + case 'ECONNREFUSED': + return 'refused' + case 'EHOSTUNREACH': + case 'ENETUNREACH': + return 'unreachable' + case 'ENOTFOUND': + case 'EAI_AGAIN': + return 'unresolved' + default: + return 'failed' + } +} + +export function testLocalNetworkConnection( + args: { host?: unknown; port?: unknown }, + options: LocalNetworkConnectionTestOptions = {} +): Promise { + const testedAt = (options.now ?? Date.now)() + const host = normalizeTargetHost(args.host) + const port = normalizeTargetPort(args.port) + if (!host || !port) { + return Promise.resolve({ + ok: false, + host: typeof args.host === 'string' ? args.host.trim() : '', + port: typeof args.port === 'number' ? args.port : 0, + testedAt, + failure: 'invalid-target' + }) + } + if ((options.platform ?? process.platform) !== 'darwin') { + return Promise.resolve({ ok: false, host, port, testedAt, failure: 'unsupported' }) + } + + const runChild = options.runChild ?? (execFile as ConnectionChildRunner) + return new Promise((resolve) => { + runChild( + process.execPath, + ['-e', CONNECT_SCRIPT, host, String(port)], + { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + timeout: CHILD_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 1024 + }, + (error, _stdout, stderr) => { + if (!error) { + resolve({ ok: true, host, port, testedAt }) + return + } + resolve({ + ok: false, + host, + port, + testedAt, + failure: failureFromChild(error, stderr) + }) + } + ) + }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 34d102bcec9..b7bddac0c60 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -430,7 +430,8 @@ import type { import type { DeveloperPermissionId, DeveloperPermissionRequestResult, - DeveloperPermissionState + DeveloperPermissionState, + LocalNetworkConnectionTestResult } from '../shared/developer-permissions-types' import type { ComputerUsePermissionId, @@ -2552,6 +2553,10 @@ export type PreloadApi = { getStatus: () => Promise request: (args: { id: DeveloperPermissionId }) => Promise openSettings: (args: { id: DeveloperPermissionId }) => Promise + testLocalNetworkConnection: (args: { + host: string + port: number + }) => Promise } computerUsePermissions: { getStatus: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ea32e22e182..1896cafd421 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2403,7 +2403,9 @@ const api = { request: (args: { id: string }): Promise => ipcRenderer.invoke('developerPermissions:request', args), openSettings: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:openSettings', args) + ipcRenderer.invoke('developerPermissions:openSettings', args), + testLocalNetworkConnection: (args: { host: string; port: number }): Promise => + ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) }, computerUsePermissions: { diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 7f275deee0f..18b48aa918b 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -20,6 +20,7 @@ import type { import { Button } from '../ui/button' import { translate } from '@/i18n/i18n' import { DeveloperPermissionActions } from './DeveloperPermissionActions' +import { LocalNetworkConnectionTest } from './LocalNetworkConnectionTest' import { developerPermissionStatusClass, developerPermissionStatusLabel @@ -345,35 +346,37 @@ export function DeveloperPermissionsPane({ const settingId = `developer-permissions-${permission.id}` return ( -
-
-
{permission.icon}
-
-
- {permission.label} - - {developerPermissionStatusLabel(permission.id, status)} - +
+
+
+
{permission.icon}
+
+
+ {permission.label} + + {developerPermissionStatusLabel(permission.id, status)} + +
+

{permission.description}

-

{permission.description}

+ void request(id)} + />
- void request(id)} - /> + {permission.id === 'local-network' && }
) })} diff --git a/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx b/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx new file mode 100644 index 00000000000..997e25f5b13 --- /dev/null +++ b/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { LocalNetworkConnectionTest } from './LocalNetworkConnectionTest' + +let container: HTMLDivElement +let root: Root +const testConnectionMock = vi.fn() + +beforeEach(() => { + localStorage.clear() + testConnectionMock.mockReset() + Object.assign(window, { + api: { + developerPermissions: { + testLocalNetworkConnection: testConnectionMock + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + localStorage.clear() + Reflect.deleteProperty(window, 'api') +}) + +function inputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +it('saves and displays the last successful target-specific test', async () => { + testConnectionMock.mockResolvedValue({ + ok: true, + host: '192.168.1.20', + port: 3000, + testedAt: new Date('2026-08-06T07:00:00Z').getTime() + }) + await act(async () => root.render()) + + const host = container.querySelector('#local-network-test-host')! + const port = container.querySelector('#local-network-test-port')! + await act(async () => { + inputValue(host, '192.168.1.20') + inputValue(port, '3000') + }) + await act(async () => container.querySelector('button')?.click()) + + expect(testConnectionMock).toHaveBeenCalledWith({ host: '192.168.1.20', port: 3000 }) + expect(container.textContent).toContain('Last verified') + expect(container.textContent).toContain('192.168.1.20:3000') + + await act(async () => root.render(null)) + await act(async () => root.render()) + expect(container.textContent).toContain('192.168.1.20:3000') +}) + +it('keeps the last success visible when a later test fails', async () => { + testConnectionMock + .mockResolvedValueOnce({ + ok: true, + host: 'devbox.local', + port: 8080, + testedAt: 1000 + }) + .mockResolvedValueOnce({ + ok: false, + host: 'devbox.local', + port: 8080, + testedAt: 2000, + failure: 'refused' + }) + await act(async () => root.render()) + + const host = container.querySelector('#local-network-test-host')! + const port = container.querySelector('#local-network-test-port')! + await act(async () => { + inputValue(host, 'devbox.local') + inputValue(port, '8080') + }) + await act(async () => container.querySelector('button')?.click()) + await act(async () => container.querySelector('button')?.click()) + + expect(container.textContent).toContain('Last verified') + expect(container.textContent).toContain('devbox.local:8080') + expect(container.textContent).toContain('The host responded, but the port refused') + expect(container.textContent).not.toContain('Permission denied') +}) diff --git a/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx b/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx new file mode 100644 index 00000000000..1e0bd81f289 --- /dev/null +++ b/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx @@ -0,0 +1,188 @@ +import { useState, type FormEvent } from 'react' +import { CheckCircle2 } from 'lucide-react' +import type { + LocalNetworkConnectionTestFailure, + LocalNetworkConnectionTestResult +} from '../../../../shared/developer-permissions-types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { + loadLocalNetworkConnectionSuccess, + saveLocalNetworkConnectionSuccess, + type LocalNetworkConnectionSuccess +} from './local-network-connection-history' + +function failureMessage(failure: LocalNetworkConnectionTestFailure | undefined): string { + switch (failure) { + case 'invalid-target': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestInvalidTarget', + 'Enter a hostname or private LAN IP and a port from 1 to 65535.' + ) + case 'timeout': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestTimeout', + 'Connection timed out. Check the target, service, and macOS Local Network settings.' + ) + case 'refused': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestRefused', + 'The host responded, but the port refused the connection.' + ) + case 'unreachable': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnreachable', + 'The target could not be reached.' + ) + case 'unresolved': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnresolved', + 'The hostname could not be resolved.' + ) + case 'unsupported': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnsupported', + 'Connection testing is available in the macOS desktop app.' + ) + case 'failed': + case undefined: + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestFailed', + 'The connection test could not be completed.' + ) + } +} + +function formatTarget(success: LocalNetworkConnectionSuccess): string { + return `${success.host}:${success.port}` +} + +export function LocalNetworkConnectionTest(): React.JSX.Element { + const [lastSuccess, setLastSuccess] = useState(loadLocalNetworkConnectionSuccess) + const [host, setHost] = useState(lastSuccess?.host ?? '') + const [port, setPort] = useState(lastSuccess ? String(lastSuccess.port) : '') + const [running, setRunning] = useState(false) + const [failure, setFailure] = useState(null) + + const submit = async (event: FormEvent): Promise => { + event.preventDefault() + setRunning(true) + setFailure(null) + try { + const result: LocalNetworkConnectionTestResult = + await window.api.developerPermissions.testLocalNetworkConnection({ + host, + port: Number(port) + }) + if (result.ok) { + const success = { + host: result.host, + port: result.port, + testedAt: result.testedAt + } + saveLocalNetworkConnectionSuccess(success) + setLastSuccess(success) + } else { + setFailure(result.failure ?? 'failed') + } + } catch { + setFailure('failed') + } finally { + setRunning(false) + } + } + + return ( +
+
+
+
+ {translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestTitle', + 'Test connection' + )} +
+

+ {translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestDescription', + 'Enter a service on another device on your local network. Orca tests the same network path used by terminal tools.' + )} +

+ {lastSuccess ? ( +

+ + {translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestLastVerified', + 'Last verified' + )}{' '} + {new Date(lastSuccess.testedAt).toLocaleString()} ยท {formatTarget(lastSuccess)} +

+ ) : ( +

+ {translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestNotYetVerified', + 'No successful test saved.' + )} +

+ )} + {failure && ( +

+ {failureMessage(failure)} +

+ )} +
+
void submit(event)}> +
+ + setHost(event.target.value)} + placeholder="192.168.1.20" + autoComplete="off" + className="w-44" + disabled={running} + /> +
+
+ + setPort(event.target.value)} + placeholder="3000" + className="w-24" + disabled={running} + /> +
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/local-network-connection-history.ts b/src/renderer/src/components/settings/local-network-connection-history.ts new file mode 100644 index 00000000000..8712c065d6a --- /dev/null +++ b/src/renderer/src/components/settings/local-network-connection-history.ts @@ -0,0 +1,45 @@ +import type { LocalNetworkConnectionTestResult } from '../../../../shared/developer-permissions-types' + +const STORAGE_KEY = 'orca.developer-permissions.local-network-last-success.v1' + +export type LocalNetworkConnectionSuccess = Pick< + LocalNetworkConnectionTestResult, + 'host' | 'port' | 'testedAt' +> + +function isSavedSuccess(value: unknown): value is LocalNetworkConnectionSuccess { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Partial + return ( + typeof candidate.host === 'string' && + candidate.host.length > 0 && + Number.isInteger(candidate.port) && + candidate.port! >= 1 && + candidate.port! <= 65_535 && + typeof candidate.testedAt === 'number' && + Number.isFinite(candidate.testedAt) + ) +} + +export function loadLocalNetworkConnectionSuccess(): LocalNetworkConnectionSuccess | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) { + return null + } + const parsed: unknown = JSON.parse(raw) + return isSavedSuccess(parsed) ? parsed : null + } catch { + return null + } +} + +export function saveLocalNetworkConnectionSuccess(success: LocalNetworkConnectionSuccess): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(success)) + } catch { + // The live result remains useful when browser storage is unavailable. + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cc9e274af97..d4ce1af49a3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5905,7 +5905,22 @@ "localNetworkOpenSettings": "Open System Settings", "openSettingsFailed": "Could not open System Settings", "localNetworkOpenSystemSettings": "Open System Settings", - "statusManagedByMacOS": "Managed by macOS" + "statusManagedByMacOS": "Managed by macOS", + "connectionTestInvalidTarget": "Enter a hostname or private LAN IP and a port from 1 to 65535.", + "connectionTestTimeout": "Connection timed out. Check the target, service, and macOS Local Network settings.", + "connectionTestRefused": "The host responded, but the port refused the connection.", + "connectionTestUnreachable": "The target could not be reached.", + "connectionTestUnresolved": "The hostname could not be resolved.", + "connectionTestUnsupported": "Connection testing is available in the macOS desktop app.", + "connectionTestFailed": "The connection test could not be completed.", + "connectionTestTitle": "Test connection", + "connectionTestDescription": "Enter a service on another device on your local network. Orca tests the same network path used by terminal tools.", + "connectionTestLastVerified": "Last verified", + "connectionTestNotYetVerified": "No successful test saved.", + "connectionTestHost": "Host", + "connectionTestPort": "Port", + "connectionTestRunning": "Testing...", + "connectionTestAction": "Test Connection" }, "ExperimentalPane": { "9762364929": "Uses APFS clone-copy on macOS when possible, otherwise symlinks configured folders or files into created worktrees.", diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 131be65a6df..0067f122ae6 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2927,7 +2927,15 @@ function createDeveloperPermissionsApi(): NonNullable['devel getStatus: () => Promise.resolve([]), request: ({ id }) => Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const), - openSettings: () => Promise.resolve() + openSettings: () => Promise.resolve(), + testLocalNetworkConnection: ({ host, port }) => + Promise.resolve({ + ok: false, + host, + port, + testedAt: Date.now(), + failure: 'unsupported' + } as const) } } diff --git a/src/shared/developer-permissions-types.ts b/src/shared/developer-permissions-types.ts index 26c7633f021..c97762df028 100644 --- a/src/shared/developer-permissions-types.ts +++ b/src/shared/developer-permissions-types.ts @@ -28,3 +28,20 @@ export type DeveloperPermissionRequestResult = { status: DeveloperPermissionStatus openedSystemSettings: boolean } + +export type LocalNetworkConnectionTestFailure = + | 'invalid-target' + | 'timeout' + | 'refused' + | 'unreachable' + | 'unresolved' + | 'failed' + | 'unsupported' + +export type LocalNetworkConnectionTestResult = { + ok: boolean + host: string + port: number + testedAt: number + failure?: LocalNetworkConnectionTestFailure +}