feat: Add deeplinkpairing and connection handling for native computer use (no-changelog) (#29445)

This commit is contained in:
Bernhard Wittmann 2026-05-11 14:35:08 +02:00 committed by GitHub
parent 2e21c5fcf8
commit ea98243c2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1192 additions and 429 deletions

View File

@ -25,10 +25,11 @@
"main": "dist/cli.js", "main": "dist/cli.js",
"exports": { "exports": {
".": "./dist/cli.js", ".": "./dist/cli.js",
"./daemon": "./dist/daemon.js",
"./config": "./dist/config.js", "./config": "./dist/config.js",
"./gateway-client": "./dist/gateway-client.js",
"./logger": "./dist/logger.js", "./logger": "./dist/logger.js",
"./gateway-session": "./dist/gateway-session.js" "./gateway-session": "./dist/gateway-session.js",
"./settings-store": "./dist/settings-store.js"
}, },
"module": "src/cli.ts", "module": "src/cli.ts",
"types": "dist/cli.d.ts", "types": "dist/cli.d.ts",

View File

@ -18,7 +18,6 @@ import {
import { SettingsStore } from './settings-store'; import { SettingsStore } from './settings-store';
import { import {
editPermissions, editPermissions,
ensureSettingsFile,
isAllDeny, isAllDeny,
printPermissionsTable, printPermissionsTable,
promptFilesystemDir, promptFilesystemDir,
@ -173,7 +172,7 @@ async function main(
process.exit(1); process.exit(1);
} }
await ensureSettingsFile(config); await SettingsStore.ensureInitialized(config);
const settingsStore = await SettingsStore.create(); const settingsStore = await SettingsStore.create();
const defaults = settingsStore.getDefaults(parsed.config); const defaults = settingsStore.getDefaults(parsed.config);

View File

@ -101,7 +101,7 @@ export class GatewayClient {
constructor(private readonly options: GatewayClientOptions) {} constructor(private readonly options: GatewayClientOptions) {}
/** Return the active API key — session key if available, otherwise the original key. */ /** Session key when the server has upgraded the pairing token; otherwise the original token. */
private get apiKey(): string { private get apiKey(): string {
return this.sessionKey ?? this.options.apiKey; return this.sessionKey ?? this.options.apiKey;
} }

View File

@ -89,6 +89,42 @@ describe('SettingsStore.create', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// SettingsStore.ensureInitialized
// ---------------------------------------------------------------------------
describe('SettingsStore.ensureInitialized', () => {
it('creates the settings file when absent', async () => {
(os.homedir as jest.Mock).mockReturnValue(tmpDir);
await SettingsStore.ensureInitialized(BASE_CONFIG);
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<Record<string, unknown>>(raw);
expect(parsed.permissions).toMatchObject({
filesystemRead: 'allow',
filesystemWrite: 'ask',
shell: 'deny',
computer: 'deny',
browser: 'ask',
});
expect(parsed.filesystemDir).toBe('');
});
it('does not overwrite an existing settings file', async () => {
(os.homedir as jest.Mock).mockReturnValue(tmpDir);
const dir = path.join(tmpDir, '.n8n-gateway');
const file = path.join(dir, 'settings.json');
await fs.mkdir(dir, { recursive: true });
const existing = JSON.stringify({ permissions: { shell: 'allow' }, filesystemDir: '/custom' });
await fs.writeFile(file, existing, 'utf-8');
await SettingsStore.ensureInitialized(BASE_CONFIG);
const raw = await fs.readFile(file, 'utf-8');
expect(raw).toBe(existing);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// getDefaults // getDefaults
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -9,6 +9,7 @@ import {
permissionModeSchema, permissionModeSchema,
TOOL_GROUP_DEFINITIONS, TOOL_GROUP_DEFINITIONS,
} from './config'; } from './config';
import { getTemplate } from './config-templates';
import { logger } from './logger'; import { logger } from './logger';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -82,6 +83,33 @@ export class SettingsStore {
// Factory // Factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static async ensureInitialized(config: GatewayConfig): Promise<void> {
const filePath = getSettingsFilePath();
// Never overwrite an existing settings file.
try {
await fs.access(filePath);
return;
} catch {
// File does not exist yet.
}
const template = getTemplate('default');
const permissions = { ...template.permissions, ...config.permissions };
const initialSettings: PersistentSettings = {
permissions,
filesystemDir: '',
resourcePermissions: {},
};
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(initialSettings, null, 2), {
encoding: 'utf-8',
mode: 0o600,
});
}
static async create(): Promise<SettingsStore> { static async create(): Promise<SettingsStore> {
const filePath = getSettingsFilePath(); const filePath = getSettingsFilePath();
const persistent = await loadFromFile(filePath); const persistent = await loadFromFile(filePath);

View File

@ -8,7 +8,8 @@ jest.mock('node:os', () => {
}); });
import type { GatewayConfig } from './config'; import type { GatewayConfig } from './config';
import { ensureSettingsFile, isAllDeny } from './startup-config-cli'; import { SettingsStore } from './settings-store';
import { isAllDeny } from './startup-config-cli';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -77,10 +78,10 @@ describe('isAllDeny', () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ensureSettingsFile // SettingsStore.ensureInitialized (CLI entry path; replaces legacy ensureSettingsFile helper)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('ensureSettingsFile', () => { describe('SettingsStore.ensureInitialized', () => {
let tmpDir: string; let tmpDir: string;
beforeEach(async () => { beforeEach(async () => {
@ -95,7 +96,7 @@ describe('ensureSettingsFile', () => {
}); });
it('creates the settings file with recommended defaults when absent', async () => { it('creates the settings file with recommended defaults when absent', async () => {
await ensureSettingsFile(BASE_CONFIG); await SettingsStore.ensureInitialized(BASE_CONFIG);
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8'); const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<Record<string, unknown>>(raw); const parsed = parseJson<Record<string, unknown>>(raw);
@ -116,7 +117,7 @@ describe('ensureSettingsFile', () => {
...BASE_CONFIG, ...BASE_CONFIG,
permissions: { shell: 'allow' }, permissions: { shell: 'allow' },
}; };
await ensureSettingsFile(config); await SettingsStore.ensureInitialized(config);
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8'); const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<{ permissions: Record<string, string> }>(raw); const parsed = parseJson<{ permissions: Record<string, string> }>(raw);
@ -134,15 +135,15 @@ describe('ensureSettingsFile', () => {
const existing = JSON.stringify({ permissions: { shell: 'allow' }, filesystemDir: '/custom' }); const existing = JSON.stringify({ permissions: { shell: 'allow' }, filesystemDir: '/custom' });
await fs.writeFile(file, existing, 'utf-8'); await fs.writeFile(file, existing, 'utf-8');
await ensureSettingsFile(BASE_CONFIG); await SettingsStore.ensureInitialized(BASE_CONFIG);
const raw = await fs.readFile(file, 'utf-8'); const raw = await fs.readFile(file, 'utf-8');
expect(raw).toBe(existing); expect(raw).toBe(existing);
}); });
it('is safe to call multiple times — only creates once', async () => { it('is safe to call multiple times — only creates once', async () => {
await ensureSettingsFile(BASE_CONFIG); await SettingsStore.ensureInitialized(BASE_CONFIG);
await ensureSettingsFile(BASE_CONFIG); await SettingsStore.ensureInitialized(BASE_CONFIG);
// Second call must not throw and must not alter the file // Second call must not throw and must not alter the file
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8'); const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');

View File

@ -2,9 +2,8 @@ import { select, input } from '@inquirer/prompts';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as nodePath from 'node:path'; import * as nodePath from 'node:path';
import type { GatewayConfig, PermissionMode, ToolGroup } from './config'; import type { PermissionMode, ToolGroup } from './config';
import { PERMISSION_MODES, getSettingsFilePath, TOOL_GROUP_DEFINITIONS } from './config'; import { PERMISSION_MODES, TOOL_GROUP_DEFINITIONS } from './config';
import { getTemplate } from './config-templates';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Display helpers // Display helpers
@ -74,36 +73,3 @@ export function isAllDeny(permissions: Record<ToolGroup, PermissionMode>): boole
(g) => permissions[g] === 'deny', (g) => permissions[g] === 'deny',
); );
} }
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Silently creates the settings file with the default (Recommended) template
* if it does not exist. Merges CLI/ENV overrides from config.permissions on top.
* filesystemDir is left empty. Does NOT prompt. Safe to call on every startup.
*/
export async function ensureSettingsFile(config: GatewayConfig): Promise<void> {
const filePath = getSettingsFilePath();
// Only create if truly absent — never overwrite an existing file.
try {
await fs.access(filePath);
return; // File exists — nothing to do.
} catch {
// File does not exist — proceed to create.
}
const template = getTemplate('default');
const permissions = { ...template.permissions, ...config.permissions };
const content = JSON.stringify(
{ permissions, filesystemDir: '', resourcePermissions: {} },
null,
2,
);
await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: 'utf-8', mode: 0o600 });
}

View File

@ -1,20 +1,22 @@
# @n8n/local-gateway # @n8n/local-gateway
A native tray application that bridges an n8n cloud or self-hosted instance to capabilities on your local machine. It runs silently in the system tray and exposes a secure local HTTP gateway that n8n workflows can connect to. A native tray application that bridges an n8n cloud or self-hosted instance to capabilities on your local machine. It runs silently in the system tray and connects directly to your n8n instance using a gateway token.
## What it does ## What it does
When an n8n workflow needs to interact with your computer — take a screenshot, move the mouse, type text, run a shell command, or read a file — it connects to this gateway. The app listens for incoming connections and, for new origins, prompts you to approve the connection before anything runs. When an n8n workflow needs to interact with your computer — take a screenshot, move the mouse, type text, run a shell command, or read a file — this app runs the local tools and streams requests/results between your machine and n8n.
Supported capabilities (each can be individually enabled or disabled): Supported capabilities (each can be individually enabled or disabled):
| Capability | Default | Description |
|---|---|---| | Capability | Default | Description |
| Filesystem | On | Read/write files in a configurable directory (defaults to home directory) | | ------------------ | ------- | ------------------------------------------------------------------------- |
| Screenshots | On | Capture screen content | | Filesystem | On | Read/write files in a configurable directory (defaults to home directory) |
| Mouse & Keyboard | On | Simulate input events | | Screenshots | On | Capture screen content |
| Browser automation | On | Control a local browser | | Mouse & Keyboard | On | Simulate input events |
| Shell execution | **Off** | Run shell commands — requires explicit opt-in | | Browser automation | On | Control a local browser |
| Shell execution | **Off** | Run shell commands — requires explicit opt-in |
> **Permissions note:** On first use, macOS and Windows will prompt you to grant accessibility and screen recording permissions when an n8n workflow triggers screenshot or mouse/keyboard actions. This is a one-time OS-level prompt per capability. > **Permissions note:** On first use, macOS and Windows will prompt you to grant accessibility and screen recording permissions when an n8n workflow triggers screenshot or mouse/keyboard actions. This is a one-time OS-level prompt per capability.
@ -45,6 +47,8 @@ cd packages/@n8n/local-gateway
pnpm dev pnpm dev
``` ```
**OS deeplink / connect handshake:** Plain `pnpm start` is fine for tray and settings UI. To verify **`n8n-computer-use://…`** routing from n8n the way end users experience it, build the macOS artefact (**`pnpm dist:mac`**, see below), install or run the generated app under **`packages/@n8n/local-gateway/out/`**, and trigger connect from n8ns computer-use / local-gateway flow.
## Building ## Building
Compile the TypeScript sources: Compile the TypeScript sources:
@ -75,13 +79,41 @@ pnpm --filter=@n8n/local-gateway dist:win
Installers are written to the `out/` directory. Installers are written to the `out/` directory.
## Configuration ## Connecting
Settings are persisted across restarts and can be changed via the tray icon → **Settings**: Pairing is done from n8n via the **computer-use / local gateway** flow, which opens an OS deeplink into this app. The Settings window does not accept an instance URL or gateway token; it only stores global preferences (allowed origins, capabilities, etc.).
- **Port** — the local port the gateway listens on (default: `7655`) 1. In n8n, start the connect flow for the local gateway / computer-use integration so your browser or OS opens the registered protocol URL (see below).
- **Allowed origins** — n8n instance URLs that are pre-approved and skip the connection prompt 2. Before connecting, open tray icon → **Settings** and ensure **Allowed origins** includes the origin of your n8n instance (for local dev, add `http://localhost:5678` or your port). Origins are validated before any connection.
The app registers **`n8n-computer-use`** as the primary OS protocol handler. The URL shape is:
```text
n8n-computer-use://connect?url=<ENCODED_N8N_URL>&token=<TOKEN>
```
Example (manual test of the handler):
```bash
open "n8n-computer-use://connect?url=http%3A%2F%2Flocalhost%3A5678&token=YOUR_TOKEN"
```
Notes:
- The gateway token is one-time for pairing.
- URL and token are not stored in global settings; connect again after restart using n8ns link or a deeplink.
- For headless or scripted use outside Electron, the **`n8n-computer-use` CLI** in `@n8n/computer-use` remains available.
## Settings
Global preferences are persisted across restarts (tray icon → **Settings**). They are separate from connection credentials:
- **Allowed origins** — patterns used to validate an instance URL **before** any connection is attempted (defaults include `https://*.app.n8n.cloud`). Never derived from the URL you paste.
- **Capability toggles** — enable or disable individual capabilities - **Capability toggles** — enable or disable individual capabilities
- **Filesystem directory** — root path for filesystem tools
- **Log level** — controls verbosity of local gateway logs (`~/.n8n-local-gateway/log`)
Connection URL and gateway token are supplied only via the deeplink (or CLI); not through Settings.
## Architecture ## Architecture
@ -93,4 +125,6 @@ src/renderer/ — Settings UI (plain HTML/CSS/TS, sandboxed)
src/shared/ — Types shared between main and renderer src/shared/ — Types shared between main and renderer
``` ```
The actual gateway daemon is provided by the `@n8n/computer-use` package and is managed by `DaemonController`, which starts/stops it and surfaces status (`stopped → starting → waiting → connected → disconnected`) to the tray menu and settings window via IPC. The local runtime is provided by `@n8n/computer-use` and managed by `DaemonController`, which handles connect/disconnect and surfaces status (`disconnected` until a session starts, then `connecting → connected → disconnected/error`) to the tray menu and settings window via IPC.
See `docs/ARCHITECTURE_CONNECTION_VS_SETTINGS.md` for how global settings, connection attempts, and runtime session state relate.

View File

@ -0,0 +1,21 @@
# Local Gateway — settings vs connection vs runtime
This desktop app keeps three concerns separate:
## Global `AppSettings` (electron-store)
**Owns:** Tool toggles, log level, filesystem directory, **explicit `allowedOrigins`** (origin patterns, not derived from a tenant URL), and other preferences that apply regardless of which n8n instance you connect to.
**Merge into `GatewayConfig`:** `SettingsStore.toGatewayConfig()` maps these fields into the structural + permission parts of `GatewayConfig`. **`allowedOrigins` comes only from the persisted list** in `AppSettings` — never from connection URLs or “last connected” display state.
## Connection (ephemeral or future profiles)
**Owns:** The target n8n instance URL and pairing token for a **single connect attempt** or live session. Today the URL and token are **not** written into `AppSettings`.
**Pre-connect:** Before `GatewayClient` is created, the main process normalizes the URL, computes `origin = new URL(url).origin`, and requires `isOriginAllowed(origin, appSettings.allowedOrigins)` (from `@n8n/computer-use/config`). Malicious or mistyped deep links to disallowed origins are rejected before any network client is constructed.
## Runtime (`GatewayClient` / `GatewaySession`)
**Owns:** SSE connection, negotiated tools, session file state under the computer-use settings directory. Upgraded session keys exist in memory and on disk per the computer-use package; they are **not** mixed into global Electron `AppSettings`.
**Note:** Reconnect-on-settings-change was removed; changing tool preferences while connected may require an explicit disconnect until mid-session updates exist (see product tickets).

View File

@ -5,18 +5,15 @@
"private": true, "private": true,
"main": "dist/main/index.js", "main": "dist/main/index.js",
"scripts": { "scripts": {
"build": "echo 'skipped'", "build": "tsc -p tsconfig.renderer.json && tsc -p tsconfig.build.json && pnpm copy:renderer && pnpm copy:assets",
"lint": "echo 'skipped'", "lint": "eslint . --quiet",
"typecheck": "echo 'skipped'", "typecheck": "tsc --noEmit",
"fix:build": "tsc -p tsconfig.renderer.json && tsc -p tsconfig.build.json && pnpm copy:renderer && pnpm copy:assets",
"fix:lint": "eslint . --quiet",
"fix:typecheck": "tsc --noEmit",
"copy:assets": "node scripts/copy-assets.js", "copy:assets": "node scripts/copy-assets.js",
"copy:renderer": "node scripts/copy-renderer.js", "copy:renderer": "node scripts/copy-renderer.js",
"dev": "pnpm copy:assets && pnpm copy:renderer && concurrently \"tsc -p tsconfig.renderer.json --watch\" \"tsc -p tsconfig.build.json --watch\"", "dev": "pnpm copy:assets && pnpm copy:renderer && concurrently \"tsc -p tsconfig.renderer.json --watch\" \"tsc -p tsconfig.build.json --watch\"",
"start": "electron dist/main/index.js", "start": "electron dist/main/index.js",
"dist:mac": "pnpm fix:build && electron-builder --mac --config electron-builder.config.js", "dist:mac": "pnpm build && electron-builder --mac --config electron-builder.config.js",
"dist:win": "pnpm fix:build && electron-builder --win --config electron-builder.config.js", "dist:win": "pnpm build && electron-builder --win --config electron-builder.config.js",
"format": "biome format --write src", "format": "biome format --write src",
"format:check": "biome ci src", "format:check": "biome ci src",
"clean": "rimraf dist out .turbo", "clean": "rimraf dist out .turbo",

View File

@ -0,0 +1,21 @@
import { assertConnectOriginAllowed } from './connect-origin';
describe('assertConnectOriginAllowed', () => {
it('does not throw when origin matches an allowed pattern', () => {
expect(() =>
assertConnectOriginAllowed('https://foo.app.n8n.cloud/', ['https://*.app.n8n.cloud']),
).not.toThrow();
});
it('throws when origin is not allowed', () => {
expect(() =>
assertConnectOriginAllowed('https://evil.example/', ['https://*.app.n8n.cloud']),
).toThrow(/not in your allowed origins/);
});
it('throws on invalid URL', () => {
expect(() => assertConnectOriginAllowed('not-a-url', ['https://*.app.n8n.cloud'])).toThrow(
/Invalid instance URL/,
);
});
});

View File

@ -0,0 +1,19 @@
import { isOriginAllowed } from '@n8n/computer-use/config';
/**
* Throws if the normalized instance URL's origin is not allowed by the configured patterns.
* Call before constructing GatewayClient (deep link / IPC connect).
*/
export function assertConnectOriginAllowed(url: string, allowedOriginPatterns: string[]): void {
let origin: string;
try {
origin = new URL(url.replace(/\/$/, '')).origin;
} catch {
throw new Error('Invalid instance URL.');
}
if (!isOriginAllowed(origin, allowedOriginPatterns)) {
throw new Error(
'This instance URL is not in your allowed origins list. Open Settings and add its origin, or use a deeplink from your trusted n8n.',
);
}
}

View File

@ -0,0 +1,92 @@
import {
DEEP_LINK_PROTOCOL,
deepLinkProtocolsInArgv,
parseConnectPayload,
parseConnectPayloadFromArgv,
} from './connect-payload';
describe('parseConnectPayload', () => {
it('returns null for non-URL strings', () => {
expect(parseConnectPayload('not a url')).toBeNull();
});
it('returns null for wrong protocol', () => {
expect(parseConnectPayload('https://connect/?url=https://n.example')).toBeNull();
});
it('returns null when hostname is not connect', () => {
expect(
parseConnectPayload(`${DEEP_LINK_PROTOCOL}://other/?url=https://n.example&token=t`),
).toBeNull();
});
it('returns null when url param is missing', () => {
expect(parseConnectPayload(`${DEEP_LINK_PROTOCOL}://connect?token=abc`)).toBeNull();
});
it('returns null when token param is missing', () => {
expect(parseConnectPayload(`${DEEP_LINK_PROTOCOL}://connect?url=https://n.example`)).toBeNull();
});
it('returns null when token param is empty or whitespace-only', () => {
expect(
parseConnectPayload(`${DEEP_LINK_PROTOCOL}://connect?url=https://n.example&token=%20%20`),
).toBeNull();
expect(
parseConnectPayload(`${DEEP_LINK_PROTOCOL}://connect?url=https://n.example&token=+%09`),
).toBeNull();
});
it('returns null when url param is not a valid absolute URL', () => {
expect(parseConnectPayload(`${DEEP_LINK_PROTOCOL}://connect?url=not-a-url&token=t`)).toBeNull();
});
it('parses url and token', () => {
expect(
parseConnectPayload(`${DEEP_LINK_PROTOCOL}://connect?url=https://n.example&token=gw_abc`),
).toEqual({
url: 'https://n.example',
apiKey: 'gw_abc',
});
});
it('trims token with surrounding whitespace', () => {
expect(
parseConnectPayload(`${DEEP_LINK_PROTOCOL}://connect?url=https://n.example&token=%20gw_x%20`),
).toEqual({
url: 'https://n.example',
apiKey: 'gw_x',
});
});
});
describe('parseConnectPayloadFromArgv', () => {
it('returns null when no argv entry matches', () => {
expect(parseConnectPayloadFromArgv(['--foo', 'bar'])).toBeNull();
});
it('returns first matching payload', () => {
const good = `${DEEP_LINK_PROTOCOL}://connect?url=https://a.example&token=1`;
const also = `${DEEP_LINK_PROTOCOL}://connect?url=https://b.example&token=2`;
expect(parseConnectPayloadFromArgv(['--x', good, also])).toEqual({
url: 'https://a.example',
apiKey: '1',
});
});
});
describe('deepLinkProtocolsInArgv', () => {
it('returns false when argv has no deep-link string', () => {
expect(deepLinkProtocolsInArgv(['electron', '/app/main.js'])).toBe(false);
});
it('returns true when argv includes our scheme even if parse fails', () => {
expect(
deepLinkProtocolsInArgv([
'electron',
`${DEEP_LINK_PROTOCOL}://connect?url=https://x.example`,
]),
).toBe(true);
});
});

View File

@ -0,0 +1,47 @@
import type { ConnectPayload } from '../shared/types';
/** Registered with the OS for deeplinks (`n8n-computer-use://…`). */
export const DEEP_LINK_PROTOCOL = 'n8n-computer-use';
/**
* Parses `n8n-computer-use://connect?url=…&token=…`. Host must be `connect`. Requires non-empty `token=` after trim.
*/
export function parseConnectPayload(value: string): ConnectPayload | null {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return null;
}
if (parsed.protocol !== `${DEEP_LINK_PROTOCOL}:`) return null;
if (parsed.hostname !== 'connect') return null;
const url = parsed.searchParams.get('url') ?? '';
const rawToken = parsed.searchParams.get('token');
const apiKey = rawToken === null || rawToken.trim().length === 0 ? undefined : rawToken.trim();
if (!url) return null;
if (!apiKey) return null;
try {
new URL(url);
} catch {
return null;
}
return { url, apiKey };
}
/** True if argv appears to include our deeplink scheme (even when parse fails). */
export function deepLinkProtocolsInArgv(argv: string[]): boolean {
const needle = `${DEEP_LINK_PROTOCOL}://`;
return argv.some((arg) => arg.includes(needle));
}
/** Returns the first argv element that parses as a valid connect payload. */
export function parseConnectPayloadFromArgv(argv: string[]): ConnectPayload | null {
for (const arg of argv) {
const payload = parseConnectPayload(arg);
if (payload) return payload;
}
return null;
}

View File

@ -1,5 +1,186 @@
const mockStart = jest.fn();
const mockDisconnect = jest.fn();
const mockStop = jest.fn();
const mockGetDefaults = jest.fn();
const mockSessionFlush = jest.fn();
type GatewayClientOptions = {
url: string;
apiKey: string;
onPersistentFailure?: () => void;
onDisconnected?: () => void;
};
let lastGatewayOptions: GatewayClientOptions | undefined;
jest.mock('@n8n/computer-use/settings-store', () => ({
['SettingsStore']: {
create: jest.fn(
async () =>
await Promise.resolve({
getDefaults: mockGetDefaults,
}),
),
},
}));
jest.mock('@n8n/computer-use/gateway-session', () => ({
['GatewaySession']: jest.fn().mockImplementation(() => ({
flush: mockSessionFlush,
})),
}));
jest.mock('@n8n/computer-use/gateway-client', () => ({
['GatewayClient']: jest.fn().mockImplementation((options: GatewayClientOptions) => {
lastGatewayOptions = options;
return {
start: mockStart,
disconnect: mockDisconnect,
stop: mockStop,
};
}),
}));
jest.mock('@n8n/computer-use/logger', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
import type { GatewayConfig } from '@n8n/computer-use/config';
import { DaemonController } from './daemon-controller';
const BASE_CONFIG: GatewayConfig = {
logLevel: 'info',
allowedOrigins: ['https://*.app.n8n.cloud'],
filesystem: { dir: '/' },
computer: { shell: { timeout: 30_000 } },
browser: { defaultBrowser: 'chrome' },
permissions: {},
permissionConfirmation: 'instance',
};
/** Fire-and-forget `void closeCurrentConnection()` chains multiple async steps; flush past microtasks */
async function settleNextTurn(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe('DaemonController', () => { describe('DaemonController', () => {
it('should be importable', () => { it('starts disconnected with no session', () => {
expect(true).toBeDefined(); const controller = new DaemonController();
expect(controller.getSnapshot()).toEqual({
status: 'disconnected',
connectedUrl: null,
lastError: null,
});
});
beforeEach(() => {
jest.clearAllMocks();
lastGatewayOptions = undefined;
mockGetDefaults.mockReturnValue({
dir: '/',
permissions: {
filesystemRead: 'allow',
filesystemWrite: 'ask',
shell: 'deny',
computer: 'deny',
browser: 'ask',
},
});
mockStart.mockResolvedValue(undefined);
mockDisconnect.mockResolvedValue(undefined);
mockStop.mockResolvedValue(undefined);
mockSessionFlush.mockResolvedValue(undefined);
});
it('connects and updates snapshot state', async () => {
const controller = new DaemonController();
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token');
const snapshot = controller.getSnapshot();
expect(snapshot.status).toBe('connected');
expect(snapshot.connectedUrl).toBe('https://example.n8n.cloud');
expect(snapshot.lastError).toBeNull();
});
it('normalizes trailing slash on URL', async () => {
const controller = new DaemonController();
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud/', 'gw_token');
expect(controller.getSnapshot().connectedUrl).toBe('https://example.n8n.cloud');
});
it('sets error state when connect fails', async () => {
const controller = new DaemonController();
mockStart.mockRejectedValueOnce(new Error('connect failed'));
await expect(
controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token'),
).rejects.toThrow('connect failed');
const snapshot = controller.getSnapshot();
expect(snapshot.status).toBe('error');
expect(snapshot.connectedUrl).toBeNull();
expect(snapshot.lastError).toBe('connect failed');
});
it('formats non-Error rejection for lastError', async () => {
const controller = new DaemonController();
mockStart.mockRejectedValueOnce('string failure');
await expect(
controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token'),
).rejects.toThrow('string failure');
expect(controller.getSnapshot().lastError).toBe('string failure');
});
it('disconnects and clears connected state', async () => {
const controller = new DaemonController();
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token');
await controller.disconnect();
const snapshot = controller.getSnapshot();
expect(snapshot.status).toBe('disconnected');
expect(snapshot.connectedUrl).toBeNull();
expect(mockDisconnect).toHaveBeenCalled();
expect(mockSessionFlush).toHaveBeenCalled();
});
it('calls stop on previous client when connecting again', async () => {
const controller = new DaemonController();
await controller.connect(BASE_CONFIG, 'https://a.example', 't1');
await controller.connect(BASE_CONFIG, 'https://b.example', 't2');
expect(mockStop).toHaveBeenCalled();
expect(mockStart).toHaveBeenCalledTimes(2);
});
it('sets error state when gateway signals persistent auth failure', async () => {
const controller = new DaemonController();
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token');
lastGatewayOptions?.onPersistentFailure?.();
await settleNextTurn();
const snapshot = controller.getSnapshot();
expect(snapshot.status).toBe('error');
expect(snapshot.lastError).toBe('Gateway authentication failed repeatedly');
expect(mockStop).toHaveBeenCalled();
expect(mockSessionFlush).toHaveBeenCalled();
});
it('sets disconnected state when gateway signals disconnect', async () => {
const controller = new DaemonController();
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token');
lastGatewayOptions?.onDisconnected?.();
await settleNextTurn();
expect(controller.getSnapshot().status).toBe('disconnected');
expect(mockStop).toHaveBeenCalled();
expect(mockSessionFlush).toHaveBeenCalled();
}); });
}); });

View File

@ -1,10 +1,9 @@
import type { GatewayConfig } from '@n8n/computer-use/config'; import type { GatewayConfig } from '@n8n/computer-use/config';
import type { DaemonOptions } from '@n8n/computer-use/daemon'; import { GatewayClient } from '@n8n/computer-use/gateway-client';
import { startDaemon } from '@n8n/computer-use/daemon'; import { GatewaySession } from '@n8n/computer-use/gateway-session';
import type { GatewaySession } from '@n8n/computer-use/gateway-session';
import { logger } from '@n8n/computer-use/logger'; import { logger } from '@n8n/computer-use/logger';
import { SettingsStore } from '@n8n/computer-use/settings-store';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import type * as http from 'node:http';
import type { DaemonStatus, StatusSnapshot } from '../shared/types'; import type { DaemonStatus, StatusSnapshot } from '../shared/types';
@ -15,108 +14,121 @@ export interface DaemonControllerEvents {
} }
export class DaemonController extends EventEmitter<DaemonControllerEvents> { export class DaemonController extends EventEmitter<DaemonControllerEvents> {
private server: http.Server | null = null; private client: GatewayClient | null = null;
private _port: number | null = null; private session: GatewaySession | null = null;
private _status: DaemonStatus = 'stopped'; private settingsStore: SettingsStore | null = null;
private _status: DaemonStatus = 'disconnected';
private _connectedUrl: string | null = null; private _connectedUrl: string | null = null;
private _connectedAt: string | null = null; private _lastError: string | null = null;
getSnapshot(): StatusSnapshot { getSnapshot(): StatusSnapshot {
return { return {
status: this._status, status: this._status,
connectedUrl: this._connectedUrl, connectedUrl: this._connectedUrl,
connectedAt: this._connectedAt, lastError: this._lastError,
}; };
} }
isRunning(): boolean { private async getSettingsStore(): Promise<SettingsStore> {
return this._status !== 'stopped'; this.settingsStore = this.settingsStore ?? (await SettingsStore.create());
return this.settingsStore;
} }
start( private async closeCurrentConnection(options: { preserveServerSession: boolean }): Promise<void> {
config: GatewayConfig, if (this.client) {
confirmConnect: (url: string, session: GatewaySession) => boolean,
): void {
if (this.server) {
logger.debug('Daemon start requested but already running — ignoring');
return;
}
this._port = config.port;
logger.debug('Daemon starting', { port: config.port });
this.setStatus('starting');
const options: DaemonOptions = {
managedMode: true,
confirmConnect,
confirmResourceAccess: () => 'denyOnce' as const,
onStatusChange: (status, url) => {
if (status === 'connected') {
logger.info('Daemon connected', { url });
this._connectedUrl = url ?? null;
this._connectedAt = new Date().toISOString();
this.setStatus('connected');
} else {
logger.info('Daemon disconnected');
this._connectedUrl = null;
this._connectedAt = null;
this.setStatus('disconnected');
}
},
};
this.server = startDaemon(config, options);
// Server is now listening (or will be shortly) — mark as waiting
this.server.once('listening', () => {
if (this._status === 'starting') {
this.setStatus('waiting');
}
});
this.server.once('error', (e: Error) => {
logger.error('Daemon server error', { error: e.message });
this.server = null;
this.setStatus('stopped');
});
}
async disconnectClient(): Promise<void> {
logger.debug('Disconnecting client');
if (!this.server || this._port === null) return;
try {
await fetch(`http://localhost:${this._port}/disconnect`, { method: 'POST' });
} catch {
// Server may be unreachable — ignore
}
}
async stop(): Promise<void> {
logger.debug('Daemon stopping');
if (!this.server) {
this.setStatus('stopped');
return;
}
if (this._port !== null) {
try { try {
await fetch(`http://localhost:${this._port}/disconnect`, { method: 'POST' }); if (options.preserveServerSession) {
} catch { await this.client.stop();
// Server may already be unreachable — proceed with close } else {
await this.client.disconnect();
}
} catch (error) {
logger.warn('Gateway teardown failed', {
error: error instanceof Error ? error.message : String(error),
});
} }
} }
await new Promise<void>((resolve) => { if (this.session) {
this.server!.close(() => { await this.session.flush();
this.server = null; }
this._port = null;
this._connectedUrl = null; this.client = null;
this._connectedAt = null; this.session = null;
this.setStatus('stopped'); this._connectedUrl = null;
resolve(); }
});
private formatErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
async connect(config: GatewayConfig, url: string, apiKey: string): Promise<void> {
const normalizedUrl = url.replace(/\/$/, '');
logger.debug('Direct gateway connect requested', { url: normalizedUrl });
this.setStatus('connecting');
this._lastError = null;
await this.closeCurrentConnection({ preserveServerSession: true });
const store = await this.getSettingsStore();
const defaults = store.getDefaults(config);
const session = new GatewaySession(defaults, store);
const client = new GatewayClient({
url: normalizedUrl,
apiKey,
config,
session,
confirmResourceAccess: () => 'denyOnce',
onPersistentFailure: () => {
this.afterGatewayPersistentFailure();
},
onDisconnected: () => {
this.afterGatewayDisconnected();
},
}); });
try {
await client.start();
this.client = client;
this.session = session;
this._connectedUrl = normalizedUrl;
this.setStatus('connected');
} catch (error) {
this._lastError = this.formatErrorMessage(error);
this.clearConnectionState('error');
throw new Error(this._lastError);
}
}
async disconnect(): Promise<void> {
await this.closeCurrentConnection({ preserveServerSession: false });
this.setStatus('disconnected');
}
private afterGatewayPersistentFailure(): void {
this._lastError = 'Gateway authentication failed repeatedly';
void this.closeCurrentConnection({ preserveServerSession: true }).finally(() => {
this.setStatus('error');
});
}
private afterGatewayDisconnected(): void {
void this.closeCurrentConnection({ preserveServerSession: true }).finally(() => {
this.setStatus('disconnected');
});
}
private clearConnectionState(status: DaemonStatus): void {
this.client = null;
this.session = null;
this._connectedUrl = null;
this.setStatus(status);
} }
private setStatus(status: DaemonStatus): void { private setStatus(status: DaemonStatus): void {

View File

@ -1,107 +1,138 @@
import type { GatewaySession } from '@n8n/computer-use/gateway-session';
import { configure, logger } from '@n8n/computer-use/logger'; import { configure, logger } from '@n8n/computer-use/logger';
import { app, dialog } from 'electron'; import { app } from 'electron';
import * as path from 'node:path'; import * as path from 'node:path';
import { assertConnectOriginAllowed } from './connect-origin';
import {
deepLinkProtocolsInArgv,
parseConnectPayload,
parseConnectPayloadFromArgv,
DEEP_LINK_PROTOCOL,
} from './connect-payload';
import { DaemonController } from './daemon-controller'; import { DaemonController } from './daemon-controller';
import { registerIpcHandlers } from './ipc-handlers'; import { registerIpcHandlers } from './ipc-handlers';
import { SettingsStore } from './settings-store'; import { SettingsStore } from './settings-store';
import { openSettingsWindow, notifySettingsWindow } from './settings-window'; import { openSettingsWindow, notifySettingsWindow } from './settings-window';
import { createTray } from './tray'; import { createTray } from './tray';
import type { ConnectPayload } from '../shared/types';
// Windows: required for proper taskbar/notification grouping // Windows: required for proper taskbar/notification grouping
if (process.platform === 'win32') { if (process.platform === 'win32') {
app.setAppUserModelId('io.n8n.gateway'); app.setAppUserModelId('io.n8n.gateway');
} }
// Keep the process running even when all windows are closed (tray-only app). if (!app.requestSingleInstanceLock()) {
// Returning false from the handler is not possible via the Electron API directly; app.quit();
// instead we simply never quit — the tray manages app lifetime. } else {
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
// Intentionally do nothing: this is a tray-only app that stays alive // Intentionally do nothing: this is a tray-only app that stays alive
// even when all BrowserWindows are closed. // even when all BrowserWindows are closed.
});
app
.whenReady()
.then(() => {
// macOS: hide from Dock (tray-only app)
if (process.platform === 'darwin') {
app.dock?.hide();
}
const settingsStore = new SettingsStore();
configure({ level: settingsStore.get().logLevel });
logger.info('n8n Gateway starting');
const controller = new DaemonController();
const preloadPath = path.join(__dirname, 'preload.js');
const rendererPath = path.join(__dirname, '..', 'renderer', 'index.html');
function confirmConnect(url: string, _session: GatewaySession): boolean {
const lastUrl = settingsStore.getLastConnectedUrl();
if (lastUrl !== null && lastUrl === url) {
logger.info('Auto-approving connection from known URL', { url });
return true;
}
const result = dialog.showMessageBoxSync({
type: 'question',
buttons: ['Allow', 'Reject'],
defaultId: 0,
cancelId: 1,
title: 'n8n Connection Request',
message: `Allow n8n to connect?\n\n${url}`,
detail: 'Confirm only if you initiated this connection from n8n.',
});
return result === 0;
}
function restartDaemon(): void {
logger.info('Restarting daemon');
const config = settingsStore.toGatewayConfig();
void controller
.stop()
.then(() => {
controller.start(config, confirmConnect);
})
.catch((e: unknown) => {
logger.error('Failed to restart daemon', { e: String(e) });
});
}
registerIpcHandlers(controller, settingsStore, restartDaemon);
// Propagate status changes to the settings window (if open) and persist connection URL
controller.on('statusChanged', (snapshot) => {
notifySettingsWindow('statusChanged', snapshot);
if (snapshot.status === 'connected' && snapshot.connectedUrl) {
settingsStore.setLastConnectedUrl(snapshot.connectedUrl);
}
});
function onDisconnect(): void {
settingsStore.setLastConnectedUrl(null);
void controller.disconnectClient();
}
createTray(
controller,
() => openSettingsWindow(preloadPath, rendererPath),
restartDaemon,
() => {
logger.info('n8n Gateway quitting');
void controller.stop().then(() => {
app.quit();
});
},
onDisconnect,
);
// Auto-start the daemon on launch
controller.start(settingsStore.toGatewayConfig(), confirmConnect);
})
.catch((error: unknown) => {
logger.error('Failed to initialize app', { error: String(error) });
app.quit();
}); });
app
.whenReady()
.then(() => {
if (process.platform === 'darwin') {
app.dock?.hide();
}
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL);
const settingsStore = new SettingsStore();
configure({ level: settingsStore.get().logLevel });
logger.info('n8n Gateway starting');
const controller = new DaemonController();
const preloadPath = path.join(__dirname, 'preload.js');
const rendererPath = path.join(__dirname, '..', 'renderer', 'index.html');
async function connect(payload: ConnectPayload): Promise<void> {
const settings = settingsStore.get();
assertConnectOriginAllowed(payload.url, settings.allowedOrigins);
const config = settingsStore.toGatewayConfig(settings);
const token = payload.apiKey?.trim();
if (!token || token.length === 0) {
throw new Error(
'Missing gateway token in deeplink. Connect from n8n using the computer-use link.',
);
}
await controller.connect(config, payload.url, token);
}
async function disconnectGateway(): Promise<void> {
await controller.disconnect();
}
function handleConnectPayload(payload: ConnectPayload): void {
logger.info('Handling deep-link connection payload', { url: payload.url });
void connect(payload).catch((error: unknown) => {
logger.error('Deep-link connection failed', {
error: error instanceof Error ? error.message : String(error),
});
openSettingsWindow(preloadPath, rendererPath);
});
}
registerIpcHandlers(controller, settingsStore, disconnectGateway);
controller.on('statusChanged', (snapshot) => {
notifySettingsWindow('statusChanged', snapshot);
});
createTray(
controller,
() => openSettingsWindow(preloadPath, rendererPath),
() => {
logger.info('n8n Gateway quitting');
void controller
.disconnect()
.catch((error: unknown) => {
logger.error('Disconnect failed during quit', {
error: error instanceof Error ? error.message : String(error),
});
})
.finally(() => {
app.quit();
});
},
() => {
void disconnectGateway();
},
);
const payloadFromArgs = parseConnectPayloadFromArgv(process.argv);
if (payloadFromArgs) {
handleConnectPayload(payloadFromArgs);
} else if (deepLinkProtocolsInArgv(process.argv)) {
logger.warn(
'Deep link present in argv but payload invalid (e.g. missing token); skipping startup connect',
);
}
// macOS — `open-url`: Fires when the OS hands the app a `n8n-computer-use://…` URL (browser, another app,
// or “Open” from Finder). Runs for a process that is already running and also after launch when the app
// was opened via the protocol; cold starts may still receive the URL in `process.argv` — we parse that
// above so both paths are covered.
app.on('open-url', (event, url) => {
event.preventDefault();
const payload = parseConnectPayload(url);
if (!payload) return;
handleConnectPayload(payload);
});
// Windows / Linux — `second-instance`: Fires on the **first** (lock-holding) process when the user starts
// the app again while it is already running. The second process exits immediately (`requestSingleInstanceLock`
// failed); this event receives that second processs `argv`, which often includes the deeplink the OS
// passed to the new launch, so we connect from here instead of argv-only startup parsing.
app.on('second-instance', (_event, argv) => {
const payload = parseConnectPayloadFromArgv(argv);
if (!payload) return;
handleConnectPayload(payload);
});
})
.catch((error: unknown) => {
logger.error('Failed to initialize app', { error: String(error) });
app.quit();
});
}

View File

@ -0,0 +1,194 @@
type HandlerFn = (...args: unknown[]) => unknown;
const mockHandle = jest.fn();
const mockConfigure = jest.fn();
const registeredHandlers = new Map<string, HandlerFn>();
jest.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: HandlerFn) => {
registeredHandlers.set(channel, handler);
mockHandle(channel, handler);
},
},
}));
jest.mock('@n8n/computer-use/logger', () => ({
configure: (options: { level?: string }) => {
mockConfigure(options);
},
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
import { logger } from '@n8n/computer-use/logger';
import { registerIpcHandlers } from './ipc-handlers';
function getRegisteredHandler(channel: string): HandlerFn {
const handler = registeredHandlers.get(channel);
if (!handler) {
throw new Error(`No handler found for channel: ${channel}`);
}
return handler;
}
describe('registerIpcHandlers', () => {
beforeEach(() => {
jest.clearAllMocks();
registeredHandlers.clear();
});
it('invokes disconnect gateway callback on IPC gateway:disconnect', async () => {
const controller = {
disconnect: jest.fn().mockResolvedValue(undefined),
getSnapshot: jest.fn(),
};
const settingsStore = {
get: jest.fn(),
set: jest.fn(),
toGatewayConfig: jest.fn(),
};
const disconnectGateway = jest.fn().mockResolvedValue(undefined);
registerIpcHandlers(controller as never, settingsStore as never, disconnectGateway);
const disconnectHandler = getRegisteredHandler('gateway:disconnect');
const result = await disconnectHandler();
expect(disconnectGateway).toHaveBeenCalled();
expect(result).toEqual({ ok: true });
});
it('returns settings from settings:get', () => {
const appSettings = {
allowedOrigins: ['https://*.app.n8n.cloud'],
filesystemDir: '/tmp',
filesystemEnabled: true,
shellEnabled: false,
screenshotEnabled: true,
mouseKeyboardEnabled: true,
browserEnabled: true,
logLevel: 'info' as const,
};
const controller = {
disconnect: jest.fn(),
getSnapshot: jest.fn(),
};
const settingsStore = {
get: jest.fn().mockReturnValue(appSettings),
set: jest.fn(),
toGatewayConfig: jest.fn(),
};
registerIpcHandlers(
controller as never,
settingsStore as never,
jest.fn().mockResolvedValue(undefined),
);
const result = getRegisteredHandler('settings:get')();
expect(settingsStore.get).toHaveBeenCalled();
expect(result).toEqual(appSettings);
});
it('returns daemon snapshot from daemon:status', () => {
const snapshot = {
status: 'connected' as const,
connectedUrl: 'https://n.example',
lastError: null,
};
const controller = {
disconnect: jest.fn(),
getSnapshot: jest.fn().mockReturnValue(snapshot),
};
registerIpcHandlers(
controller as never,
{
get: jest.fn(),
set: jest.fn(),
toGatewayConfig: jest.fn(),
} as never,
jest.fn().mockResolvedValue(undefined),
);
const result = getRegisteredHandler('daemon:status')();
expect(controller.getSnapshot).toHaveBeenCalled();
expect(result).toEqual(snapshot);
});
it('settings:set returns ok false when set throws', async () => {
const controller = {
disconnect: jest.fn(),
getSnapshot: jest.fn(),
};
const settingsStore = {
get: jest.fn(),
set: jest.fn().mockImplementation(() => {
throw new Error('persist failed');
}),
toGatewayConfig: jest.fn(),
};
registerIpcHandlers(
controller as never,
settingsStore as never,
jest.fn().mockResolvedValue(undefined),
);
const result = await getRegisteredHandler('settings:set')(undefined, {
allowedOrigins: ['https://x.example'],
});
expect(result).toEqual({ ok: false, error: 'persist failed' });
expect(logger.error).toHaveBeenCalled();
});
it('settings:set updates log level without invoking disconnect', async () => {
const controller = {
disconnect: jest.fn(),
getSnapshot: jest.fn(),
};
const settingsStore = {
get: jest.fn(),
set: jest.fn(),
toGatewayConfig: jest.fn(),
};
const disconnectGateway = jest.fn();
registerIpcHandlers(controller as never, settingsStore as never, disconnectGateway);
const settingsSetHandler = getRegisteredHandler('settings:set');
const result = await settingsSetHandler(undefined, { logLevel: 'debug' });
expect(mockConfigure).toHaveBeenCalledWith({ level: 'debug' });
expect(disconnectGateway).not.toHaveBeenCalled();
expect(result).toEqual({ ok: true });
});
it('settings:set persists capability toggles', async () => {
const controller = {
disconnect: jest.fn(),
getSnapshot: jest.fn(),
};
const settingsStore = {
get: jest.fn(),
set: jest.fn(),
toGatewayConfig: jest.fn(),
};
registerIpcHandlers(
controller as never,
settingsStore as never,
jest.fn().mockResolvedValue(undefined),
);
const result = await getRegisteredHandler('settings:set')(undefined, {
filesystemEnabled: false,
});
expect(settingsStore.set).toHaveBeenCalledWith({ filesystemEnabled: false });
expect(result).toEqual({ ok: true });
});
});

View File

@ -7,7 +7,8 @@ import type { AppSettings, SettingsStore } from './settings-store';
export function registerIpcHandlers( export function registerIpcHandlers(
controller: DaemonController, controller: DaemonController,
settingsStore: SettingsStore, settingsStore: SettingsStore,
restartDaemon: () => void, /** Tears down the local gateway connection (Electron app). */
disconnectGateway: () => Promise<void>,
): void { ): void {
ipcMain.handle('settings:get', (): AppSettings => { ipcMain.handle('settings:get', (): AppSettings => {
logger.debug('IPC settings:get'); logger.debug('IPC settings:get');
@ -24,10 +25,7 @@ export function registerIpcHandlers(
configure({ level: partial.logLevel }); configure({ level: partial.logLevel });
logger.info('Log level updated', { level: partial.logLevel }); logger.info('Log level updated', { level: partial.logLevel });
} }
const requiresRestart = Object.keys(partial).some((k) => k !== 'logLevel'); // Changing tool/capability toggles does not hot-reload an active connection; disconnect and connect again if needed.
if (requiresRestart) {
restartDaemon();
}
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@ -42,15 +40,9 @@ export function registerIpcHandlers(
return controller.getSnapshot(); return controller.getSnapshot();
}); });
ipcMain.handle('daemon:start', (): { ok: boolean } => { ipcMain.handle('gateway:disconnect', async (): Promise<{ ok: boolean }> => {
logger.debug('IPC daemon:start'); logger.debug('IPC gateway:disconnect');
restartDaemon(); await disconnectGateway();
return { ok: true };
});
ipcMain.handle('daemon:stop', async (): Promise<{ ok: boolean }> => {
logger.debug('IPC daemon:stop');
await controller.stop();
return { ok: true }; return { ok: true };
}); });
} }

View File

@ -1,7 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import type { StatusSnapshot } from './daemon-controller'; import type { AppSettings, StatusSnapshot } from '../shared/types';
import type { AppSettings } from './settings-store';
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
getSettings: async (): Promise<AppSettings> => getSettings: async (): Promise<AppSettings> =>
@ -13,11 +12,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getDaemonStatus: async (): Promise<StatusSnapshot> => getDaemonStatus: async (): Promise<StatusSnapshot> =>
await (ipcRenderer.invoke('daemon:status') as Promise<StatusSnapshot>), await (ipcRenderer.invoke('daemon:status') as Promise<StatusSnapshot>),
startDaemon: async (): Promise<{ ok: boolean }> => disconnectGateway: async (): Promise<{ ok: boolean }> =>
await (ipcRenderer.invoke('daemon:start') as Promise<{ ok: boolean }>), await (ipcRenderer.invoke('gateway:disconnect') as Promise<{ ok: boolean }>),
stopDaemon: async (): Promise<{ ok: boolean }> =>
await (ipcRenderer.invoke('daemon:stop') as Promise<{ ok: boolean }>),
onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void): void => { onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void): void => {
ipcRenderer.on('statusChanged', (_event, snapshot: StatusSnapshot) => ipcRenderer.on('statusChanged', (_event, snapshot: StatusSnapshot) =>

View File

@ -0,0 +1,87 @@
jest.mock('node:os', () => {
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access -- jest.requireActual is untyped */
const actual = jest.requireActual('node:os');
return {
...actual,
homedir: jest.fn((): string => '/mock/home'),
};
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access */
});
jest.mock('electron', () => ({
app: {
getPath: jest.fn((name: string) => (name === 'userData' ? '/mock/userData' : `/mock/${name}`)),
},
}));
jest.mock('@n8n/computer-use/logger', () => ({
logger: {
debug: jest.fn(),
warn: jest.fn(),
},
}));
jest.mock('electron-store');
import { app } from 'electron';
import Store from 'electron-store';
import * as path from 'node:path';
import { SettingsStore } from './settings-store';
const MockStore = Store as jest.MockedClass<typeof Store>;
describe('SettingsStore (Electron)', () => {
beforeEach(() => {
jest.clearAllMocks();
MockStore.mockImplementation((opts?: { defaults?: Record<string, unknown> }) => {
const data: Record<string, unknown> = { ...(opts?.defaults ?? {}) };
return {
get: (key: string) => data[key],
set: (key: string, val: unknown) => {
data[key] = val;
},
} as unknown as InstanceType<typeof Store>;
});
});
it('toGatewayConfig uses persisted allowedOrigins', () => {
const store = new SettingsStore();
store.set({
allowedOrigins: ['https://tenant.example', 'https://*.app.n8n.cloud'],
});
const config = store.toGatewayConfig();
expect(config.allowedOrigins).toEqual(['https://tenant.example', 'https://*.app.n8n.cloud']);
expect(config.filesystem.dir).toBe('/mock/home');
});
it('toGatewayConfig falls back to default origins when list is empty after coercion', () => {
const store = new SettingsStore();
store.set({ allowedOrigins: [] });
const config = store.toGatewayConfig();
expect(config.allowedOrigins).toEqual(['https://*.app.n8n.cloud']);
});
it('maps capability toggles to gateway permissions', () => {
const store = new SettingsStore();
store.set({
filesystemEnabled: false,
shellEnabled: true,
screenshotEnabled: false,
mouseKeyboardEnabled: false,
browserEnabled: false,
});
const config = store.toGatewayConfig();
expect(config.permissions.filesystemRead).toBe('deny');
expect(config.permissions.filesystemWrite).toBe('deny');
expect(config.permissions.shell).toBe('ask');
expect(config.permissions.computer).toBe('deny');
expect(config.permissions.browser).toBe('deny');
});
it('getStorePath joins Electron userData with settings file name', () => {
const store = new SettingsStore();
expect(store.getStorePath()).toBe(path.join('/mock/userData', 'settings.json'));
expect(app.getPath).toHaveBeenCalledWith('userData');
});
});

View File

@ -10,42 +10,35 @@ import type { AppSettings } from '../shared/types';
export type { AppSettings }; export type { AppSettings };
const DEFAULTS: AppSettings = { const DEFAULTS: AppSettings = {
port: 7655, allowedOrigins: ['https://*.app.n8n.cloud'],
filesystemDir: os.homedir(), filesystemDir: os.homedir(),
filesystemEnabled: true, filesystemEnabled: true,
shellEnabled: false, // disabled by default for security shellEnabled: false, // disabled by default for security
screenshotEnabled: true, screenshotEnabled: true,
mouseKeyboardEnabled: true, mouseKeyboardEnabled: true,
browserEnabled: true, browserEnabled: true,
allowedOrigins: [],
logLevel: 'info', logLevel: 'info',
}; };
/** Full shape of what's persisted — includes internal state not exposed as AppSettings. */
interface StoredData extends AppSettings {
lastConnectedUrl: string | null;
}
export class SettingsStore { export class SettingsStore {
private readonly store: Store<StoredData>; private readonly store: Store<AppSettings>;
constructor() { constructor() {
this.store = new Store<StoredData>({ this.store = new Store<AppSettings>({
name: 'settings', name: 'settings',
defaults: { ...DEFAULTS, lastConnectedUrl: null }, defaults: DEFAULTS,
}); });
} }
get(): AppSettings { get(): AppSettings {
return { return {
port: this.store.get('port'), allowedOrigins: this.store.get('allowedOrigins'),
filesystemDir: this.store.get('filesystemDir'), filesystemDir: this.store.get('filesystemDir'),
filesystemEnabled: this.store.get('filesystemEnabled'), filesystemEnabled: this.store.get('filesystemEnabled'),
shellEnabled: this.store.get('shellEnabled'), shellEnabled: this.store.get('shellEnabled'),
screenshotEnabled: this.store.get('screenshotEnabled'), screenshotEnabled: this.store.get('screenshotEnabled'),
mouseKeyboardEnabled: this.store.get('mouseKeyboardEnabled'), mouseKeyboardEnabled: this.store.get('mouseKeyboardEnabled'),
browserEnabled: this.store.get('browserEnabled'), browserEnabled: this.store.get('browserEnabled'),
allowedOrigins: this.store.get('allowedOrigins'),
logLevel: this.store.get('logLevel'), logLevel: this.store.get('logLevel'),
}; };
} }
@ -54,26 +47,22 @@ export class SettingsStore {
for (const [key, value] of Object.entries(partial) as Array< for (const [key, value] of Object.entries(partial) as Array<
[keyof AppSettings, AppSettings[keyof AppSettings]] [keyof AppSettings, AppSettings[keyof AppSettings]]
>) { >) {
this.store.set(key, value); if (value !== undefined) {
this.store.set(key, value);
}
} }
logger.debug('Settings updated', { changes: partial }); logger.debug('Settings updated', { changes: partial });
} }
getLastConnectedUrl(): string | null { toGatewayConfig(preset?: AppSettings): GatewayConfig {
return this.store.get('lastConnectedUrl'); const s = preset ?? this.get();
} const origins =
Array.isArray(s.allowedOrigins) && s.allowedOrigins.length > 0
setLastConnectedUrl(url: string | null): void { ? s.allowedOrigins
this.store.set('lastConnectedUrl', url); : DEFAULTS.allowedOrigins;
logger.debug('Last connected URL updated', { url });
}
toGatewayConfig(): GatewayConfig {
const s = this.get();
return { return {
logLevel: s.logLevel, logLevel: s.logLevel,
port: s.port, allowedOrigins: origins,
allowedOrigins: s.allowedOrigins,
filesystem: { dir: s.filesystemDir }, filesystem: { dir: s.filesystemDir },
computer: { shell: { timeout: 30_000 } }, computer: { shell: { timeout: 30_000 } },
browser: { browser: {

View File

@ -1,10 +1,19 @@
import { BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
let settingsWindow: BrowserWindow | null = null; let settingsWindow: BrowserWindow | null = null;
function revealSettingsWindow(windowRef: BrowserWindow): void {
if (windowRef.isMinimized()) {
windowRef.restore();
}
windowRef.show();
app.focus({ steal: true });
windowRef.focus();
}
export function openSettingsWindow(preloadPath: string, rendererPath: string): void { export function openSettingsWindow(preloadPath: string, rendererPath: string): void {
if (settingsWindow && !settingsWindow.isDestroyed()) { if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus(); revealSettingsWindow(settingsWindow);
return; return;
} }
@ -24,7 +33,11 @@ export function openSettingsWindow(preloadPath: string, rendererPath: string): v
}, },
}); });
void settingsWindow.loadFile(rendererPath); void settingsWindow.loadFile(rendererPath).then(() => {
if (settingsWindow && !settingsWindow.isDestroyed()) {
revealSettingsWindow(settingsWindow);
}
});
settingsWindow.on('closed', () => { settingsWindow.on('closed', () => {
settingsWindow = null; settingsWindow = null;

View File

@ -7,23 +7,24 @@ import type { DaemonController, DaemonStatus, StatusSnapshot } from './daemon-co
const STATUS_LABELS: Record<DaemonStatus, string> = { const STATUS_LABELS: Record<DaemonStatus, string> = {
connected: '', // set dynamically with URL connected: '', // set dynamically with URL
waiting: '◌ Waiting for connection', connecting: '○ Connecting...',
starting: '○ Starting...', disconnected: '✕ Disconnected',
disconnected: '✕ Disconnected — reconnecting', error: '✕ Error',
stopped: '■ Stopped',
}; };
const ICON_NAMES: Record<DaemonStatus, string> = {
connected: 'tray-connected',
connecting: 'tray-waiting',
disconnected: 'tray-disconnected',
error: 'tray-disconnected',
};
const iconCache = new Map<DaemonStatus, Electron.NativeImage>();
function createNativeImage(status: DaemonStatus): Electron.NativeImage { function createNativeImage(status: DaemonStatus): Electron.NativeImage {
const names: Record<DaemonStatus, string> = {
connected: 'tray-connected',
waiting: 'tray-waiting',
starting: 'tray-waiting',
disconnected: 'tray-disconnected',
stopped: 'tray-stopped',
};
const assetsDir = path.join(app.getAppPath(), 'assets'); const assetsDir = path.join(app.getAppPath(), 'assets');
const path1x = path.join(assetsDir, `${names[status]}.png`); const path1x = path.join(assetsDir, `${ICON_NAMES[status]}.png`);
const path2x = path.join(assetsDir, `${names[status]}@2x.png`); const path2x = path.join(assetsDir, `${ICON_NAMES[status]}@2x.png`);
logger.debug('Loading tray icon', { status, path: path1x }); logger.debug('Loading tray icon', { status, path: path1x });
@ -47,6 +48,14 @@ function createNativeImage(status: DaemonStatus): Electron.NativeImage {
return img; return img;
} }
function getTrayIcon(status: DaemonStatus): Electron.NativeImage {
const cached = iconCache.get(status);
if (cached) return cached;
const created = createNativeImage(status);
iconCache.set(status, created);
return created;
}
function buildStatusLabel(snapshot: StatusSnapshot): string { function buildStatusLabel(snapshot: StatusSnapshot): string {
if (snapshot.status === 'connected' && snapshot.connectedUrl) { if (snapshot.status === 'connected' && snapshot.connectedUrl) {
return `● Connected to ${snapshot.connectedUrl}`; return `● Connected to ${snapshot.connectedUrl}`;
@ -55,34 +64,21 @@ function buildStatusLabel(snapshot: StatusSnapshot): string {
} }
function buildMenu( function buildMenu(
controller: DaemonController,
snapshot: StatusSnapshot, snapshot: StatusSnapshot,
onSettings: () => void, onSettings: () => void,
onStartDaemon: () => void,
onQuit: () => void, onQuit: () => void,
onDisconnect: () => void, onDisconnect: () => void,
): Menu { ): Menu {
const running = controller.isRunning(); const sessionActive = snapshot.status === 'connected' || snapshot.status === 'connecting';
const connected = snapshot.status === 'connected';
return Menu.buildFromTemplate([ return Menu.buildFromTemplate([
{ {
label: buildStatusLabel(snapshot), label: buildStatusLabel(snapshot),
enabled: false, enabled: false,
}, },
{ type: 'separator' }, { type: 'separator' },
{
label: running ? 'Stop Daemon' : 'Start Daemon',
click: () => {
if (running) {
void controller.stop();
} else {
onStartDaemon();
}
},
},
{ {
label: 'Disconnect', label: 'Disconnect',
visible: connected, visible: sessionActive,
click: onDisconnect, click: onDisconnect,
}, },
{ type: 'separator' }, { type: 'separator' },
@ -101,19 +97,16 @@ function buildMenu(
export function createTray( export function createTray(
controller: DaemonController, controller: DaemonController,
onSettings: () => void, onSettings: () => void,
onStartDaemon: () => void,
onQuit: () => void, onQuit: () => void,
onDisconnect: () => void, onDisconnect: () => void,
): Tray { ): Tray {
const tray = new Tray(createNativeImage('stopped')); const tray = new Tray(getTrayIcon('disconnected'));
tray.setToolTip('n8n Gateway'); tray.setToolTip('n8n Gateway');
const update = (snapshot: StatusSnapshot): void => { const update = (snapshot: StatusSnapshot): void => {
logger.debug('Tray updating', { status: snapshot.status, connectedUrl: snapshot.connectedUrl }); logger.debug('Tray updating', { status: snapshot.status, connectedUrl: snapshot.connectedUrl });
tray.setImage(createNativeImage(snapshot.status)); tray.setImage(getTrayIcon(snapshot.status));
tray.setContextMenu( tray.setContextMenu(buildMenu(snapshot, onSettings, onQuit, onDisconnect));
buildMenu(controller, snapshot, onSettings, onStartDaemon, onQuit, onDisconnect),
);
}; };
controller.on('statusChanged', update); controller.on('statusChanged', update);

View File

@ -15,14 +15,14 @@
<div class="header"> <div class="header">
<span class="header-title">n8n Gateway</span> <span class="header-title">n8n Gateway</span>
<div id="statusBadge" class="status-badge"> <div id="statusBadge" class="status-badge">
<span id="statusDot" class="status-dot stopped"></span> <span id="statusDot" class="status-dot disconnected"></span>
<span id="statusText">Stopped</span> <span id="statusText">Disconnected</span>
</div> </div>
</div> </div>
<div class="restart-notice" id="restartNotice" style="display: none"> <div class="restart-notice" id="restartNotice" style="display: none">
<span class="restart-notice-icon"></span> <span class="restart-notice-icon"></span>
<span>Saving will apply changes. Port and capability changes will restart the daemon.</span> <span>Saving applies changes. If connected, disconnect and connect again for capability changes to apply.</span>
</div> </div>
<form class="form" id="settingsForm"> <form class="form" id="settingsForm">
@ -31,28 +31,29 @@
<div class="form-section-title">Connection</div> <div class="form-section-title">Connection</div>
<div class="form-row"> <div class="form-row">
<label class="form-label" for="port">Port</label> <label class="form-label" for="allowedOrigins">Allowed origins</label>
<input
class="form-input"
type="number"
id="port"
name="port"
min="1024"
max="65535"
placeholder="7655"
/>
</div>
<div class="form-row">
<label class="form-label">Allowed Origins</label>
<textarea <textarea
class="textarea" class="form-input"
id="allowedOrigins" id="allowedOrigins"
name="allowedOrigins" name="allowedOrigins"
rows="3" rows="3"
placeholder="https://my-n8n.com&#10;https://another-n8n.example.com" spellcheck="false"
placeholder="https://*.app.n8n.cloud&#10;http://localhost:5678"
></textarea> ></textarea>
<div class="form-hint">URLs that can connect without confirmation (one per line)</div> <div class="form-hint">
One pattern per line or comma-separated. Used to validate instance URLs before connecting (not derived from the URL).
</div>
</div>
<p class="connection-deeplink-copy form-hint">
To connect, use the computer-use / local gateway action in n8n (it opens an
<code class="code-inline">n8n-computer-use://</code> link with your instance URL and
gateway token). This app does not accept URL or token entry here. See the package
README for a manual deeplink example if you need to test the handler.
</p>
<div class="inline-actions">
<button type="button" class="btn btn-secondary" id="disconnectBtn">Disconnect</button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,4 @@
import type { AppSettings, StatusSnapshot } from '../shared/types'; import type { AppSettings, DaemonStatus, LogLevel, StatusSnapshot } from '../shared/types';
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
declare global { declare global {
interface Window { interface Window {
@ -8,19 +6,17 @@ declare global {
getSettings: () => Promise<AppSettings>; getSettings: () => Promise<AppSettings>;
setSettings: (partial: Partial<AppSettings>) => Promise<{ ok: boolean; error?: string }>; setSettings: (partial: Partial<AppSettings>) => Promise<{ ok: boolean; error?: string }>;
getDaemonStatus: () => Promise<StatusSnapshot>; getDaemonStatus: () => Promise<StatusSnapshot>;
startDaemon: () => Promise<{ ok: boolean }>; disconnectGateway: () => Promise<{ ok: boolean }>;
stopDaemon: () => Promise<{ ok: boolean }>;
onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void) => void; onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void) => void;
}; };
} }
} }
const STATUS_TEXT: Record<string, string> = { const STATUS_TEXT: Record<DaemonStatus, string> = {
connected: 'Connected', connected: 'Connected',
waiting: 'Waiting', connecting: 'Connecting',
starting: 'Starting',
disconnected: 'Disconnected', disconnected: 'Disconnected',
stopped: 'Stopped', error: 'Error',
}; };
function updateStatusBadge(snapshot: StatusSnapshot): void { function updateStatusBadge(snapshot: StatusSnapshot): void {
@ -29,15 +25,46 @@ function updateStatusBadge(snapshot: StatusSnapshot): void {
if (!dot || !text) return; if (!dot || !text) return;
dot.className = `status-dot ${snapshot.status}`; dot.className = `status-dot ${snapshot.status}`;
const label = let label: string;
snapshot.status === 'connected' && snapshot.connectedUrl if (snapshot.status === 'connected' && snapshot.connectedUrl) {
? `Connected to ${snapshot.connectedUrl}` label = `Connected to ${snapshot.connectedUrl}`;
: (STATUS_TEXT[snapshot.status] ?? snapshot.status); } else if (snapshot.status === 'error') {
if (snapshot.lastError) {
const msg =
snapshot.lastError.length > 120
? `${snapshot.lastError.slice(0, 117)}...`
: snapshot.lastError;
label = `${STATUS_TEXT.error} · ${msg}`;
} else {
label = `${STATUS_TEXT.error} · see logs`;
}
} else {
label = STATUS_TEXT[snapshot.status];
}
text.textContent = label; text.textContent = label;
const disconnectBtn = document.getElementById('disconnectBtn') as HTMLButtonElement | null;
if (disconnectBtn) {
const sessionActive = snapshot.status === 'connected' || snapshot.status === 'connecting';
disconnectBtn.disabled = !sessionActive;
disconnectBtn.style.display = sessionActive ? 'inline-flex' : 'none';
}
}
function parseAllowedOriginsInput(raw: string): string[] {
return raw
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
function formatAllowedOriginsForForm(origins: string[]): string {
return origins.join('\n');
} }
function readForm(): Partial<AppSettings> { function readForm(): Partial<AppSettings> {
const port = parseInt((document.getElementById('port') as HTMLInputElement).value, 10); const allowedOriginsRaw = (document.getElementById('allowedOrigins') as HTMLTextAreaElement)
.value;
const allowedOrigins = parseAllowedOriginsInput(allowedOriginsRaw);
const filesystemDir = (document.getElementById('filesystemDir') as HTMLInputElement).value.trim(); const filesystemDir = (document.getElementById('filesystemDir') as HTMLInputElement).value.trim();
const filesystemEnabled = (document.getElementById('filesystemEnabled') as HTMLInputElement) const filesystemEnabled = (document.getElementById('filesystemEnabled') as HTMLInputElement)
.checked; .checked;
@ -47,28 +74,23 @@ function readForm(): Partial<AppSettings> {
const mouseKeyboardEnabled = (document.getElementById('mouseKeyboardEnabled') as HTMLInputElement) const mouseKeyboardEnabled = (document.getElementById('mouseKeyboardEnabled') as HTMLInputElement)
.checked; .checked;
const browserEnabled = (document.getElementById('browserEnabled') as HTMLInputElement).checked; const browserEnabled = (document.getElementById('browserEnabled') as HTMLInputElement).checked;
const rawOrigins = (document.getElementById('allowedOrigins') as HTMLTextAreaElement).value;
const allowedOrigins = rawOrigins
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
const logLevel = (document.getElementById('logLevel') as HTMLSelectElement).value as LogLevel; const logLevel = (document.getElementById('logLevel') as HTMLSelectElement).value as LogLevel;
return { return {
...(Number.isFinite(port) && port > 0 ? { port } : {}), allowedOrigins,
filesystemDir, filesystemDir,
filesystemEnabled, filesystemEnabled,
shellEnabled, shellEnabled,
screenshotEnabled, screenshotEnabled,
mouseKeyboardEnabled, mouseKeyboardEnabled,
browserEnabled, browserEnabled,
allowedOrigins,
logLevel, logLevel,
}; };
} }
function populateForm(settings: AppSettings): void { function populateForm(settings: AppSettings): void {
(document.getElementById('port') as HTMLInputElement).value = String(settings.port); (document.getElementById('allowedOrigins') as HTMLTextAreaElement).value =
formatAllowedOriginsForForm(settings.allowedOrigins);
(document.getElementById('filesystemDir') as HTMLInputElement).value = settings.filesystemDir; (document.getElementById('filesystemDir') as HTMLInputElement).value = settings.filesystemDir;
(document.getElementById('filesystemEnabled') as HTMLInputElement).checked = (document.getElementById('filesystemEnabled') as HTMLInputElement).checked =
settings.filesystemEnabled; settings.filesystemEnabled;
@ -78,8 +100,6 @@ function populateForm(settings: AppSettings): void {
(document.getElementById('mouseKeyboardEnabled') as HTMLInputElement).checked = (document.getElementById('mouseKeyboardEnabled') as HTMLInputElement).checked =
settings.mouseKeyboardEnabled; settings.mouseKeyboardEnabled;
(document.getElementById('browserEnabled') as HTMLInputElement).checked = settings.browserEnabled; (document.getElementById('browserEnabled') as HTMLInputElement).checked = settings.browserEnabled;
(document.getElementById('allowedOrigins') as HTMLTextAreaElement).value =
settings.allowedOrigins.join('\n');
(document.getElementById('logLevel') as HTMLSelectElement).value = settings.logLevel; (document.getElementById('logLevel') as HTMLSelectElement).value = settings.logLevel;
} }
@ -108,14 +128,13 @@ function isFormDirty(initial: AppSettings): boolean {
return ( return (
JSON.stringify(current) !== JSON.stringify(current) !==
JSON.stringify({ JSON.stringify({
port: initial.port, allowedOrigins: initial.allowedOrigins,
filesystemDir: initial.filesystemDir, filesystemDir: initial.filesystemDir,
filesystemEnabled: initial.filesystemEnabled, filesystemEnabled: initial.filesystemEnabled,
shellEnabled: initial.shellEnabled, shellEnabled: initial.shellEnabled,
screenshotEnabled: initial.screenshotEnabled, screenshotEnabled: initial.screenshotEnabled,
mouseKeyboardEnabled: initial.mouseKeyboardEnabled, mouseKeyboardEnabled: initial.mouseKeyboardEnabled,
browserEnabled: initial.browserEnabled, browserEnabled: initial.browserEnabled,
allowedOrigins: initial.allowedOrigins,
logLevel: initial.logLevel, logLevel: initial.logLevel,
}) })
); );
@ -133,23 +152,17 @@ async function init(): Promise<void> {
let initialSettings = { ...settings }; let initialSettings = { ...settings };
// Show restart notice and update buttons when form is dirty
const form = document.getElementById('settingsForm') as HTMLFormElement; const form = document.getElementById('settingsForm') as HTMLFormElement;
form.addEventListener('change', () => { const updateDirtyState = (): void => {
const dirty = isFormDirty(initialSettings); const dirty = isFormDirty(initialSettings);
setRestartNotice(dirty); setRestartNotice(dirty);
setButtonsState(dirty); setButtonsState(dirty);
}); };
form.addEventListener('input', () => { form.addEventListener('change', updateDirtyState);
const dirty = isFormDirty(initialSettings); form.addEventListener('input', updateDirtyState);
setRestartNotice(dirty);
setButtonsState(dirty);
});
// Browse button — user can type the path directly (dialog not exposed via preload) document.getElementById('disconnectBtn')?.addEventListener('click', () => {
document.getElementById('browseDirBtn')?.addEventListener('click', () => { void window.electronAPI.disconnectGateway();
// Intentionally left as a no-op: Electron's dialog API is main-process only.
// A future improvement could add an IPC channel to open a native folder picker.
}); });
// Apply button — save without closing // Apply button — save without closing

View File

@ -9,10 +9,9 @@
--color-warning: #f59e0b; --color-warning: #f59e0b;
--color-danger: #dc2626; --color-danger: #dc2626;
--color-badge-connected: #22a06b; --color-badge-connected: #22a06b;
--color-badge-waiting: #f59e0b; --color-badge-connecting: #f59e0b;
--color-badge-disconnected: #dc2626; --color-badge-disconnected: #dc2626;
--color-badge-stopped: #999999; --color-badge-error: #dc2626;
--color-badge-starting: #f59e0b;
--radius: 6px; --radius: 6px;
/* biome-ignore format: git hooks overwrite formatting to what biome throws an error for */ /* biome-ignore format: git hooks overwrite formatting to what biome throws an error for */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
@ -85,17 +84,14 @@ body {
.status-dot.connected { .status-dot.connected {
background: var(--color-badge-connected); background: var(--color-badge-connected);
} }
.status-dot.waiting { .status-dot.connecting {
background: var(--color-badge-waiting); background: var(--color-badge-connecting);
}
.status-dot.starting {
background: var(--color-badge-starting);
} }
.status-dot.disconnected { .status-dot.disconnected {
background: var(--color-badge-disconnected); background: var(--color-badge-disconnected);
} }
.status-dot.stopped { .status-dot.error {
background: var(--color-badge-stopped); background: var(--color-badge-error);
} }
/* Warning banner */ /* Warning banner */
@ -209,6 +205,12 @@ body {
border-bottom: none; border-bottom: none;
} }
.inline-actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
.toggle-label { .toggle-label {
font-size: 13px; font-size: 13px;
} }
@ -264,30 +266,22 @@ body {
transform: translateX(16px); transform: translateX(16px);
} }
.textarea {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 12px;
font-family: monospace;
background: var(--color-bg);
color: var(--color-text);
resize: vertical;
min-height: 60px;
outline: none;
}
.textarea:focus {
border-color: var(--color-accent);
}
.form-hint { .form-hint {
font-size: 11px; font-size: 11px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin-top: 3px; margin-top: 3px;
} }
.connection-deeplink-copy {
margin-top: 10px;
line-height: 1.45;
}
.code-inline {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
/* Footer */ /* Footer */
.footer { .footer {
padding: 12px 20px; padding: 12px 20px;

View File

@ -1,21 +1,26 @@
export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
export type DaemonStatus = 'stopped' | 'starting' | 'waiting' | 'connected' | 'disconnected'; export type DaemonStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
export interface AppSettings { export interface AppSettings {
port: number; /** User-configured origin patterns for validating instances before connect (never derived from URLs). */
allowedOrigins: string[];
filesystemDir: string; filesystemDir: string;
filesystemEnabled: boolean; filesystemEnabled: boolean;
shellEnabled: boolean; shellEnabled: boolean;
screenshotEnabled: boolean; screenshotEnabled: boolean;
mouseKeyboardEnabled: boolean; mouseKeyboardEnabled: boolean;
browserEnabled: boolean; browserEnabled: boolean;
allowedOrigins: string[];
logLevel: LogLevel; logLevel: LogLevel;
} }
export interface StatusSnapshot { export interface StatusSnapshot {
status: DaemonStatus; status: DaemonStatus;
connectedUrl: string | null; connectedUrl: string | null;
connectedAt: string | null; lastError: string | null;
}
export interface ConnectPayload {
url: string;
apiKey?: string;
} }