mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
feat(permissions): add local network connection test
This commit is contained in:
@@ -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 }) : {}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<void>((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<void>((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()
|
||||
})
|
||||
})
|
||||
@@ -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<LocalNetworkConnectionTestResult> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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<DeveloperPermissionState[]>
|
||||
request: (args: { id: DeveloperPermissionId }) => Promise<DeveloperPermissionRequestResult>
|
||||
openSettings: (args: { id: DeveloperPermissionId }) => Promise<void>
|
||||
testLocalNetworkConnection: (args: {
|
||||
host: string
|
||||
port: number
|
||||
}) => Promise<LocalNetworkConnectionTestResult>
|
||||
}
|
||||
computerUsePermissions: {
|
||||
getStatus: () => Promise<ComputerUsePermissionStatusResult>
|
||||
|
||||
@@ -2403,7 +2403,9 @@ const api = {
|
||||
request: (args: { id: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('developerPermissions:request', args),
|
||||
openSettings: (args: { id: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('developerPermissions:openSettings', args)
|
||||
ipcRenderer.invoke('developerPermissions:openSettings', args),
|
||||
testLocalNetworkConnection: (args: { host: string; port: number }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args)
|
||||
},
|
||||
|
||||
computerUsePermissions: {
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={permission.id}
|
||||
data-settings-section={settingId}
|
||||
data-highlighted={highlightedSettingId === settingId ? 'true' : undefined}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 transition-[background-color,box-shadow] duration-500 data-[highlighted=true]:bg-accent data-[highlighted=true]:ring-2 data-[highlighted=true]:ring-inset data-[highlighted=true]:ring-ring/50 motion-reduce:transition-none"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 text-muted-foreground">{permission.icon}</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{permission.label}</span>
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${developerPermissionStatusClass(
|
||||
status
|
||||
)}`}
|
||||
>
|
||||
{developerPermissionStatusLabel(permission.id, status)}
|
||||
</span>
|
||||
<div key={permission.id}>
|
||||
<div
|
||||
data-settings-section={settingId}
|
||||
data-highlighted={highlightedSettingId === settingId ? 'true' : undefined}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 transition-[background-color,box-shadow] duration-500 data-[highlighted=true]:bg-accent data-[highlighted=true]:ring-2 data-[highlighted=true]:ring-inset data-[highlighted=true]:ring-ring/50 motion-reduce:transition-none"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 text-muted-foreground">{permission.icon}</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{permission.label}</span>
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${developerPermissionStatusClass(
|
||||
status
|
||||
)}`}
|
||||
>
|
||||
{developerPermissionStatusLabel(permission.id, status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{permission.description}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{permission.description}</p>
|
||||
</div>
|
||||
<DeveloperPermissionActions
|
||||
id={permission.id}
|
||||
actionLabel={permission.actionLabel}
|
||||
pending={pending}
|
||||
status={status}
|
||||
onRequest={(id) => void request(id)}
|
||||
/>
|
||||
</div>
|
||||
<DeveloperPermissionActions
|
||||
id={permission.id}
|
||||
actionLabel={permission.actionLabel}
|
||||
pending={pending}
|
||||
status={status}
|
||||
onRequest={(id) => void request(id)}
|
||||
/>
|
||||
{permission.id === 'local-network' && <LocalNetworkConnectionTest />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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(<LocalNetworkConnectionTest />))
|
||||
|
||||
const host = container.querySelector<HTMLInputElement>('#local-network-test-host')!
|
||||
const port = container.querySelector<HTMLInputElement>('#local-network-test-port')!
|
||||
await act(async () => {
|
||||
inputValue(host, '192.168.1.20')
|
||||
inputValue(port, '3000')
|
||||
})
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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(<LocalNetworkConnectionTest />))
|
||||
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(<LocalNetworkConnectionTest />))
|
||||
|
||||
const host = container.querySelector<HTMLInputElement>('#local-network-test-host')!
|
||||
const port = container.querySelector<HTMLInputElement>('#local-network-test-port')!
|
||||
await act(async () => {
|
||||
inputValue(host, 'devbox.local')
|
||||
inputValue(port, '8080')
|
||||
})
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('button')?.click())
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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')
|
||||
})
|
||||
@@ -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<LocalNetworkConnectionTestFailure | null>(null)
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
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 (
|
||||
<div className="border-t border-border/60 bg-muted/10 px-4 py-3 pl-11">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="min-w-[240px] flex-1 space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.settings.DeveloperPermissionsPane.connectionTestTitle',
|
||||
'Test connection'
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
{lastSuccess ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.settings.DeveloperPermissionsPane.connectionTestLastVerified',
|
||||
'Last verified'
|
||||
)}{' '}
|
||||
{new Date(lastSuccess.testedAt).toLocaleString()} · {formatTarget(lastSuccess)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.DeveloperPermissionsPane.connectionTestNotYetVerified',
|
||||
'No successful test saved.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{failure && (
|
||||
<p className="text-xs text-destructive" role="status">
|
||||
{failureMessage(failure)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<form className="flex items-end gap-2" onSubmit={(event) => void submit(event)}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="local-network-test-host" className="text-[11px]">
|
||||
{translate(
|
||||
'auto.components.settings.DeveloperPermissionsPane.connectionTestHost',
|
||||
'Host'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="local-network-test-host"
|
||||
value={host}
|
||||
onChange={(event) => setHost(event.target.value)}
|
||||
placeholder="192.168.1.20"
|
||||
autoComplete="off"
|
||||
className="w-44"
|
||||
disabled={running}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="local-network-test-port" className="text-[11px]">
|
||||
{translate(
|
||||
'auto.components.settings.DeveloperPermissionsPane.connectionTestPort',
|
||||
'Port'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="local-network-test-port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65_535}
|
||||
value={port}
|
||||
onChange={(event) => setPort(event.target.value)}
|
||||
placeholder="3000"
|
||||
className="w-24"
|
||||
disabled={running}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={running || !host || !port}>
|
||||
{running
|
||||
? translate(
|
||||
'auto.components.settings.DeveloperPermissionsPane.connectionTestRunning',
|
||||
'Testing...'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.DeveloperPermissionsPane.connectionTestAction',
|
||||
'Test Connection'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<LocalNetworkConnectionSuccess>
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -2927,7 +2927,15 @@ function createDeveloperPermissionsApi(): NonNullable<Partial<PreloadApi>['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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user