From ea98243c2bdb37da4714a14f88fe49bad69ac9b7 Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Mon, 11 May 2026 14:35:08 +0200 Subject: [PATCH] feat: Add deeplinkpairing and connection handling for native computer use (no-changelog) (#29445) --- packages/@n8n/computer-use/package.json | 5 +- packages/@n8n/computer-use/src/cli.ts | 3 +- .../@n8n/computer-use/src/gateway-client.ts | 2 +- .../computer-use/src/settings-store.test.ts | 36 +++ .../@n8n/computer-use/src/settings-store.ts | 28 +++ .../src/startup-config-cli.test.ts | 17 +- .../computer-use/src/startup-config-cli.ts | 38 +--- packages/@n8n/local-gateway/README.md | 62 +++-- .../ARCHITECTURE_CONNECTION_VS_SETTINGS.md | 21 ++ packages/@n8n/local-gateway/package.json | 13 +- .../src/main/connect-origin.test.ts | 21 ++ .../local-gateway/src/main/connect-origin.ts | 19 ++ .../src/main/connect-payload.test.ts | 92 ++++++++ .../local-gateway/src/main/connect-payload.ts | 47 ++++ .../src/main/daemon-controller.test.ts | 185 ++++++++++++++- .../src/main/daemon-controller.ts | 192 ++++++++-------- packages/@n8n/local-gateway/src/main/index.ts | 215 ++++++++++-------- .../src/main/ipc-handlers.test.ts | 194 ++++++++++++++++ .../local-gateway/src/main/ipc-handlers.ts | 20 +- .../@n8n/local-gateway/src/main/preload.ts | 10 +- .../src/main/settings-store.test.ts | 87 +++++++ .../local-gateway/src/main/settings-store.ts | 41 ++-- .../local-gateway/src/main/settings-window.ts | 19 +- packages/@n8n/local-gateway/src/main/tray.ts | 61 +++-- .../local-gateway/src/renderer/index.html | 41 ++-- .../local-gateway/src/renderer/settings.ts | 89 ++++---- .../local-gateway/src/renderer/styles.css | 50 ++-- .../@n8n/local-gateway/src/shared/types.ts | 13 +- 28 files changed, 1192 insertions(+), 429 deletions(-) create mode 100644 packages/@n8n/local-gateway/docs/ARCHITECTURE_CONNECTION_VS_SETTINGS.md create mode 100644 packages/@n8n/local-gateway/src/main/connect-origin.test.ts create mode 100644 packages/@n8n/local-gateway/src/main/connect-origin.ts create mode 100644 packages/@n8n/local-gateway/src/main/connect-payload.test.ts create mode 100644 packages/@n8n/local-gateway/src/main/connect-payload.ts create mode 100644 packages/@n8n/local-gateway/src/main/ipc-handlers.test.ts create mode 100644 packages/@n8n/local-gateway/src/main/settings-store.test.ts diff --git a/packages/@n8n/computer-use/package.json b/packages/@n8n/computer-use/package.json index bc42a5320af..bc6bf4eb620 100644 --- a/packages/@n8n/computer-use/package.json +++ b/packages/@n8n/computer-use/package.json @@ -25,10 +25,11 @@ "main": "dist/cli.js", "exports": { ".": "./dist/cli.js", - "./daemon": "./dist/daemon.js", "./config": "./dist/config.js", + "./gateway-client": "./dist/gateway-client.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", "types": "dist/cli.d.ts", diff --git a/packages/@n8n/computer-use/src/cli.ts b/packages/@n8n/computer-use/src/cli.ts index cfd0d552568..7f87ab77d08 100644 --- a/packages/@n8n/computer-use/src/cli.ts +++ b/packages/@n8n/computer-use/src/cli.ts @@ -18,7 +18,6 @@ import { import { SettingsStore } from './settings-store'; import { editPermissions, - ensureSettingsFile, isAllDeny, printPermissionsTable, promptFilesystemDir, @@ -173,7 +172,7 @@ async function main( process.exit(1); } - await ensureSettingsFile(config); + await SettingsStore.ensureInitialized(config); const settingsStore = await SettingsStore.create(); const defaults = settingsStore.getDefaults(parsed.config); diff --git a/packages/@n8n/computer-use/src/gateway-client.ts b/packages/@n8n/computer-use/src/gateway-client.ts index bc227b58a8f..9ba5f792e77 100644 --- a/packages/@n8n/computer-use/src/gateway-client.ts +++ b/packages/@n8n/computer-use/src/gateway-client.ts @@ -101,7 +101,7 @@ export class GatewayClient { 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 { return this.sessionKey ?? this.options.apiKey; } diff --git a/packages/@n8n/computer-use/src/settings-store.test.ts b/packages/@n8n/computer-use/src/settings-store.test.ts index a99102d3a66..8198e37d510 100644 --- a/packages/@n8n/computer-use/src/settings-store.test.ts +++ b/packages/@n8n/computer-use/src/settings-store.test.ts @@ -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>(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 // --------------------------------------------------------------------------- diff --git a/packages/@n8n/computer-use/src/settings-store.ts b/packages/@n8n/computer-use/src/settings-store.ts index fcb2151bc27..0825a503e51 100644 --- a/packages/@n8n/computer-use/src/settings-store.ts +++ b/packages/@n8n/computer-use/src/settings-store.ts @@ -9,6 +9,7 @@ import { permissionModeSchema, TOOL_GROUP_DEFINITIONS, } from './config'; +import { getTemplate } from './config-templates'; import { logger } from './logger'; // --------------------------------------------------------------------------- @@ -82,6 +83,33 @@ export class SettingsStore { // Factory // --------------------------------------------------------------------------- + static async ensureInitialized(config: GatewayConfig): Promise { + 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 { const filePath = getSettingsFilePath(); const persistent = await loadFromFile(filePath); diff --git a/packages/@n8n/computer-use/src/startup-config-cli.test.ts b/packages/@n8n/computer-use/src/startup-config-cli.test.ts index 1a644b5f789..e15c13f563a 100644 --- a/packages/@n8n/computer-use/src/startup-config-cli.test.ts +++ b/packages/@n8n/computer-use/src/startup-config-cli.test.ts @@ -8,7 +8,8 @@ jest.mock('node:os', () => { }); import type { GatewayConfig } from './config'; -import { ensureSettingsFile, isAllDeny } from './startup-config-cli'; +import { SettingsStore } from './settings-store'; +import { isAllDeny } from './startup-config-cli'; // --------------------------------------------------------------------------- // 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; beforeEach(async () => { @@ -95,7 +96,7 @@ describe('ensureSettingsFile', () => { }); 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 parsed = parseJson>(raw); @@ -116,7 +117,7 @@ describe('ensureSettingsFile', () => { ...BASE_CONFIG, 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 parsed = parseJson<{ permissions: Record }>(raw); @@ -134,15 +135,15 @@ describe('ensureSettingsFile', () => { const existing = JSON.stringify({ permissions: { shell: 'allow' }, filesystemDir: '/custom' }); await fs.writeFile(file, existing, 'utf-8'); - await ensureSettingsFile(BASE_CONFIG); + await SettingsStore.ensureInitialized(BASE_CONFIG); const raw = await fs.readFile(file, 'utf-8'); expect(raw).toBe(existing); }); it('is safe to call multiple times — only creates once', async () => { - await ensureSettingsFile(BASE_CONFIG); - await ensureSettingsFile(BASE_CONFIG); + await SettingsStore.ensureInitialized(BASE_CONFIG); + await SettingsStore.ensureInitialized(BASE_CONFIG); // 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'); diff --git a/packages/@n8n/computer-use/src/startup-config-cli.ts b/packages/@n8n/computer-use/src/startup-config-cli.ts index 5bd3e2434c4..fb2b6442010 100644 --- a/packages/@n8n/computer-use/src/startup-config-cli.ts +++ b/packages/@n8n/computer-use/src/startup-config-cli.ts @@ -2,9 +2,8 @@ import { select, input } from '@inquirer/prompts'; import * as fs from 'node:fs/promises'; import * as nodePath from 'node:path'; -import type { GatewayConfig, PermissionMode, ToolGroup } from './config'; -import { PERMISSION_MODES, getSettingsFilePath, TOOL_GROUP_DEFINITIONS } from './config'; -import { getTemplate } from './config-templates'; +import type { PermissionMode, ToolGroup } from './config'; +import { PERMISSION_MODES, TOOL_GROUP_DEFINITIONS } from './config'; // --------------------------------------------------------------------------- // Display helpers @@ -74,36 +73,3 @@ export function isAllDeny(permissions: Record): boole (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 { - 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 }); -} diff --git a/packages/@n8n/local-gateway/README.md b/packages/@n8n/local-gateway/README.md index 3bbcdd72171..892ec2d0f79 100644 --- a/packages/@n8n/local-gateway/README.md +++ b/packages/@n8n/local-gateway/README.md @@ -1,20 +1,22 @@ # @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 -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): -| Capability | Default | Description | -|---|---|---| -| Filesystem | On | Read/write files in a configurable directory (defaults to home directory) | -| Screenshots | On | Capture screen content | -| Mouse & Keyboard | On | Simulate input events | -| Browser automation | On | Control a local browser | -| Shell execution | **Off** | Run shell commands — requires explicit opt-in | + +| Capability | Default | Description | +| ------------------ | ------- | ------------------------------------------------------------------------- | +| Filesystem | On | Read/write files in a configurable directory (defaults to home directory) | +| Screenshots | On | Capture screen content | +| Mouse & Keyboard | On | Simulate input events | +| 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. @@ -45,6 +47,8 @@ cd packages/@n8n/local-gateway 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 n8n’s computer-use / local-gateway flow. + ## Building Compile the TypeScript sources: @@ -75,13 +79,41 @@ pnpm --filter=@n8n/local-gateway dist:win 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`) -- **Allowed origins** — n8n instance URLs that are pre-approved and skip the connection prompt +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). +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=&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 n8n’s 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 +- **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 @@ -93,4 +125,6 @@ src/renderer/ — Settings UI (plain HTML/CSS/TS, sandboxed) 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. diff --git a/packages/@n8n/local-gateway/docs/ARCHITECTURE_CONNECTION_VS_SETTINGS.md b/packages/@n8n/local-gateway/docs/ARCHITECTURE_CONNECTION_VS_SETTINGS.md new file mode 100644 index 00000000000..2ca7b683c9a --- /dev/null +++ b/packages/@n8n/local-gateway/docs/ARCHITECTURE_CONNECTION_VS_SETTINGS.md @@ -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). diff --git a/packages/@n8n/local-gateway/package.json b/packages/@n8n/local-gateway/package.json index 578725923ec..80c8ae9515e 100644 --- a/packages/@n8n/local-gateway/package.json +++ b/packages/@n8n/local-gateway/package.json @@ -5,18 +5,15 @@ "private": true, "main": "dist/main/index.js", "scripts": { - "build": "echo 'skipped'", - "lint": "echo 'skipped'", - "typecheck": "echo 'skipped'", - "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", + "build": "tsc -p tsconfig.renderer.json && tsc -p tsconfig.build.json && pnpm copy:renderer && pnpm copy:assets", + "lint": "eslint . --quiet", + "typecheck": "tsc --noEmit", "copy:assets": "node scripts/copy-assets.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\"", "start": "electron dist/main/index.js", - "dist:mac": "pnpm fix:build && electron-builder --mac --config electron-builder.config.js", - "dist:win": "pnpm fix:build && electron-builder --win --config electron-builder.config.js", + "dist:mac": "pnpm build && electron-builder --mac --config electron-builder.config.js", + "dist:win": "pnpm build && electron-builder --win --config electron-builder.config.js", "format": "biome format --write src", "format:check": "biome ci src", "clean": "rimraf dist out .turbo", diff --git a/packages/@n8n/local-gateway/src/main/connect-origin.test.ts b/packages/@n8n/local-gateway/src/main/connect-origin.test.ts new file mode 100644 index 00000000000..a7836b7bebc --- /dev/null +++ b/packages/@n8n/local-gateway/src/main/connect-origin.test.ts @@ -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/, + ); + }); +}); diff --git a/packages/@n8n/local-gateway/src/main/connect-origin.ts b/packages/@n8n/local-gateway/src/main/connect-origin.ts new file mode 100644 index 00000000000..53ac20519b4 --- /dev/null +++ b/packages/@n8n/local-gateway/src/main/connect-origin.ts @@ -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.', + ); + } +} diff --git a/packages/@n8n/local-gateway/src/main/connect-payload.test.ts b/packages/@n8n/local-gateway/src/main/connect-payload.test.ts new file mode 100644 index 00000000000..7c95e0fdd6d --- /dev/null +++ b/packages/@n8n/local-gateway/src/main/connect-payload.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/local-gateway/src/main/connect-payload.ts b/packages/@n8n/local-gateway/src/main/connect-payload.ts new file mode 100644 index 00000000000..ab2ee5ce82f --- /dev/null +++ b/packages/@n8n/local-gateway/src/main/connect-payload.ts @@ -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; +} diff --git a/packages/@n8n/local-gateway/src/main/daemon-controller.test.ts b/packages/@n8n/local-gateway/src/main/daemon-controller.test.ts index 1d42a9d2580..8e9a0a5ce12 100644 --- a/packages/@n8n/local-gateway/src/main/daemon-controller.test.ts +++ b/packages/@n8n/local-gateway/src/main/daemon-controller.test.ts @@ -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 { + await new Promise((resolve) => setImmediate(resolve)); +} + describe('DaemonController', () => { - it('should be importable', () => { - expect(true).toBeDefined(); + it('starts disconnected with no session', () => { + 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(); }); }); diff --git a/packages/@n8n/local-gateway/src/main/daemon-controller.ts b/packages/@n8n/local-gateway/src/main/daemon-controller.ts index d06b4d5a6c6..80c35a01b29 100644 --- a/packages/@n8n/local-gateway/src/main/daemon-controller.ts +++ b/packages/@n8n/local-gateway/src/main/daemon-controller.ts @@ -1,10 +1,9 @@ import type { GatewayConfig } from '@n8n/computer-use/config'; -import type { DaemonOptions } from '@n8n/computer-use/daemon'; -import { startDaemon } from '@n8n/computer-use/daemon'; -import type { GatewaySession } from '@n8n/computer-use/gateway-session'; +import { GatewayClient } from '@n8n/computer-use/gateway-client'; +import { GatewaySession } from '@n8n/computer-use/gateway-session'; import { logger } from '@n8n/computer-use/logger'; +import { SettingsStore } from '@n8n/computer-use/settings-store'; import { EventEmitter } from 'node:events'; -import type * as http from 'node:http'; import type { DaemonStatus, StatusSnapshot } from '../shared/types'; @@ -15,108 +14,121 @@ export interface DaemonControllerEvents { } export class DaemonController extends EventEmitter { - private server: http.Server | null = null; - private _port: number | null = null; - private _status: DaemonStatus = 'stopped'; + private client: GatewayClient | null = null; + private session: GatewaySession | null = null; + private settingsStore: SettingsStore | null = null; + private _status: DaemonStatus = 'disconnected'; private _connectedUrl: string | null = null; - private _connectedAt: string | null = null; + private _lastError: string | null = null; getSnapshot(): StatusSnapshot { return { status: this._status, connectedUrl: this._connectedUrl, - connectedAt: this._connectedAt, + lastError: this._lastError, }; } - isRunning(): boolean { - return this._status !== 'stopped'; + private async getSettingsStore(): Promise { + this.settingsStore = this.settingsStore ?? (await SettingsStore.create()); + return this.settingsStore; } - start( - config: GatewayConfig, - 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 { - 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 { - logger.debug('Daemon stopping'); - - if (!this.server) { - this.setStatus('stopped'); - return; - } - - if (this._port !== null) { + private async closeCurrentConnection(options: { preserveServerSession: boolean }): Promise { + if (this.client) { try { - await fetch(`http://localhost:${this._port}/disconnect`, { method: 'POST' }); - } catch { - // Server may already be unreachable — proceed with close + if (options.preserveServerSession) { + await this.client.stop(); + } else { + await this.client.disconnect(); + } + } catch (error) { + logger.warn('Gateway teardown failed', { + error: error instanceof Error ? error.message : String(error), + }); } } - await new Promise((resolve) => { - this.server!.close(() => { - this.server = null; - this._port = null; - this._connectedUrl = null; - this._connectedAt = null; - this.setStatus('stopped'); - resolve(); - }); + if (this.session) { + await this.session.flush(); + } + + this.client = null; + this.session = null; + this._connectedUrl = null; + } + + 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 { + 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 { + 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 { diff --git a/packages/@n8n/local-gateway/src/main/index.ts b/packages/@n8n/local-gateway/src/main/index.ts index 022909b1e56..d39b45d8720 100644 --- a/packages/@n8n/local-gateway/src/main/index.ts +++ b/packages/@n8n/local-gateway/src/main/index.ts @@ -1,107 +1,138 @@ -import type { GatewaySession } from '@n8n/computer-use/gateway-session'; import { configure, logger } from '@n8n/computer-use/logger'; -import { app, dialog } from 'electron'; +import { app } from 'electron'; 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 { registerIpcHandlers } from './ipc-handlers'; import { SettingsStore } from './settings-store'; import { openSettingsWindow, notifySettingsWindow } from './settings-window'; import { createTray } from './tray'; +import type { ConnectPayload } from '../shared/types'; // Windows: required for proper taskbar/notification grouping if (process.platform === 'win32') { app.setAppUserModelId('io.n8n.gateway'); } -// Keep the process running even when all windows are closed (tray-only app). -// Returning false from the handler is not possible via the Electron API directly; -// instead we simply never quit — the tray manages app lifetime. -app.on('window-all-closed', () => { - // Intentionally do nothing: this is a tray-only app that stays alive - // 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(); +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + app.on('window-all-closed', () => { + // Intentionally do nothing: this is a tray-only app that stays alive + // even when all BrowserWindows are closed. }); + + 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 { + 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 { + 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 process’s `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(); + }); +} diff --git a/packages/@n8n/local-gateway/src/main/ipc-handlers.test.ts b/packages/@n8n/local-gateway/src/main/ipc-handlers.test.ts new file mode 100644 index 00000000000..a34a695d7b6 --- /dev/null +++ b/packages/@n8n/local-gateway/src/main/ipc-handlers.test.ts @@ -0,0 +1,194 @@ +type HandlerFn = (...args: unknown[]) => unknown; + +const mockHandle = jest.fn(); +const mockConfigure = jest.fn(); +const registeredHandlers = new Map(); + +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 }); + }); +}); diff --git a/packages/@n8n/local-gateway/src/main/ipc-handlers.ts b/packages/@n8n/local-gateway/src/main/ipc-handlers.ts index 16ea7d8cbb4..68e3381752d 100644 --- a/packages/@n8n/local-gateway/src/main/ipc-handlers.ts +++ b/packages/@n8n/local-gateway/src/main/ipc-handlers.ts @@ -7,7 +7,8 @@ import type { AppSettings, SettingsStore } from './settings-store'; export function registerIpcHandlers( controller: DaemonController, settingsStore: SettingsStore, - restartDaemon: () => void, + /** Tears down the local gateway connection (Electron app). */ + disconnectGateway: () => Promise, ): void { ipcMain.handle('settings:get', (): AppSettings => { logger.debug('IPC settings:get'); @@ -24,10 +25,7 @@ export function registerIpcHandlers( configure({ level: partial.logLevel }); logger.info('Log level updated', { level: partial.logLevel }); } - const requiresRestart = Object.keys(partial).some((k) => k !== 'logLevel'); - if (requiresRestart) { - restartDaemon(); - } + // Changing tool/capability toggles does not hot-reload an active connection; disconnect and connect again if needed. return { ok: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -42,15 +40,9 @@ export function registerIpcHandlers( return controller.getSnapshot(); }); - ipcMain.handle('daemon:start', (): { ok: boolean } => { - logger.debug('IPC daemon:start'); - restartDaemon(); - return { ok: true }; - }); - - ipcMain.handle('daemon:stop', async (): Promise<{ ok: boolean }> => { - logger.debug('IPC daemon:stop'); - await controller.stop(); + ipcMain.handle('gateway:disconnect', async (): Promise<{ ok: boolean }> => { + logger.debug('IPC gateway:disconnect'); + await disconnectGateway(); return { ok: true }; }); } diff --git a/packages/@n8n/local-gateway/src/main/preload.ts b/packages/@n8n/local-gateway/src/main/preload.ts index 2cdaacc9d00..aa70f4a59d6 100644 --- a/packages/@n8n/local-gateway/src/main/preload.ts +++ b/packages/@n8n/local-gateway/src/main/preload.ts @@ -1,7 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron'; -import type { StatusSnapshot } from './daemon-controller'; -import type { AppSettings } from './settings-store'; +import type { AppSettings, StatusSnapshot } from '../shared/types'; contextBridge.exposeInMainWorld('electronAPI', { getSettings: async (): Promise => @@ -13,11 +12,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getDaemonStatus: async (): Promise => await (ipcRenderer.invoke('daemon:status') as Promise), - startDaemon: async (): Promise<{ ok: boolean }> => - await (ipcRenderer.invoke('daemon:start') as Promise<{ ok: boolean }>), - - stopDaemon: async (): Promise<{ ok: boolean }> => - await (ipcRenderer.invoke('daemon:stop') as Promise<{ ok: boolean }>), + disconnectGateway: async (): Promise<{ ok: boolean }> => + await (ipcRenderer.invoke('gateway:disconnect') as Promise<{ ok: boolean }>), onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void): void => { ipcRenderer.on('statusChanged', (_event, snapshot: StatusSnapshot) => diff --git a/packages/@n8n/local-gateway/src/main/settings-store.test.ts b/packages/@n8n/local-gateway/src/main/settings-store.test.ts new file mode 100644 index 00000000000..2484fc8ca26 --- /dev/null +++ b/packages/@n8n/local-gateway/src/main/settings-store.test.ts @@ -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; + +describe('SettingsStore (Electron)', () => { + beforeEach(() => { + jest.clearAllMocks(); + MockStore.mockImplementation((opts?: { defaults?: Record }) => { + const data: Record = { ...(opts?.defaults ?? {}) }; + return { + get: (key: string) => data[key], + set: (key: string, val: unknown) => { + data[key] = val; + }, + } as unknown as InstanceType; + }); + }); + + 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'); + }); +}); diff --git a/packages/@n8n/local-gateway/src/main/settings-store.ts b/packages/@n8n/local-gateway/src/main/settings-store.ts index 3e2fcca3b9f..6b3991d425b 100644 --- a/packages/@n8n/local-gateway/src/main/settings-store.ts +++ b/packages/@n8n/local-gateway/src/main/settings-store.ts @@ -10,42 +10,35 @@ import type { AppSettings } from '../shared/types'; export type { AppSettings }; const DEFAULTS: AppSettings = { - port: 7655, + allowedOrigins: ['https://*.app.n8n.cloud'], filesystemDir: os.homedir(), filesystemEnabled: true, shellEnabled: false, // disabled by default for security screenshotEnabled: true, mouseKeyboardEnabled: true, browserEnabled: true, - allowedOrigins: [], 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 { - private readonly store: Store; + private readonly store: Store; constructor() { - this.store = new Store({ + this.store = new Store({ name: 'settings', - defaults: { ...DEFAULTS, lastConnectedUrl: null }, + defaults: DEFAULTS, }); } get(): AppSettings { return { - port: this.store.get('port'), + allowedOrigins: this.store.get('allowedOrigins'), filesystemDir: this.store.get('filesystemDir'), filesystemEnabled: this.store.get('filesystemEnabled'), shellEnabled: this.store.get('shellEnabled'), screenshotEnabled: this.store.get('screenshotEnabled'), mouseKeyboardEnabled: this.store.get('mouseKeyboardEnabled'), browserEnabled: this.store.get('browserEnabled'), - allowedOrigins: this.store.get('allowedOrigins'), logLevel: this.store.get('logLevel'), }; } @@ -54,26 +47,22 @@ export class SettingsStore { for (const [key, value] of Object.entries(partial) as Array< [keyof AppSettings, AppSettings[keyof AppSettings]] >) { - this.store.set(key, value); + if (value !== undefined) { + this.store.set(key, value); + } } logger.debug('Settings updated', { changes: partial }); } - getLastConnectedUrl(): string | null { - return this.store.get('lastConnectedUrl'); - } - - setLastConnectedUrl(url: string | null): void { - this.store.set('lastConnectedUrl', url); - logger.debug('Last connected URL updated', { url }); - } - - toGatewayConfig(): GatewayConfig { - const s = this.get(); + toGatewayConfig(preset?: AppSettings): GatewayConfig { + const s = preset ?? this.get(); + const origins = + Array.isArray(s.allowedOrigins) && s.allowedOrigins.length > 0 + ? s.allowedOrigins + : DEFAULTS.allowedOrigins; return { logLevel: s.logLevel, - port: s.port, - allowedOrigins: s.allowedOrigins, + allowedOrigins: origins, filesystem: { dir: s.filesystemDir }, computer: { shell: { timeout: 30_000 } }, browser: { diff --git a/packages/@n8n/local-gateway/src/main/settings-window.ts b/packages/@n8n/local-gateway/src/main/settings-window.ts index 238a23eae6f..feecc40c198 100644 --- a/packages/@n8n/local-gateway/src/main/settings-window.ts +++ b/packages/@n8n/local-gateway/src/main/settings-window.ts @@ -1,10 +1,19 @@ -import { BrowserWindow } from 'electron'; +import { app, BrowserWindow } from 'electron'; 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 { if (settingsWindow && !settingsWindow.isDestroyed()) { - settingsWindow.focus(); + revealSettingsWindow(settingsWindow); 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 = null; diff --git a/packages/@n8n/local-gateway/src/main/tray.ts b/packages/@n8n/local-gateway/src/main/tray.ts index d44616fd585..c398613bd1b 100644 --- a/packages/@n8n/local-gateway/src/main/tray.ts +++ b/packages/@n8n/local-gateway/src/main/tray.ts @@ -7,23 +7,24 @@ import type { DaemonController, DaemonStatus, StatusSnapshot } from './daemon-co const STATUS_LABELS: Record = { connected: '', // set dynamically with URL - waiting: '◌ Waiting for connection', - starting: '○ Starting...', - disconnected: '✕ Disconnected — reconnecting', - stopped: '■ Stopped', + connecting: '○ Connecting...', + disconnected: '✕ Disconnected', + error: '✕ Error', }; +const ICON_NAMES: Record = { + connected: 'tray-connected', + connecting: 'tray-waiting', + disconnected: 'tray-disconnected', + error: 'tray-disconnected', +}; + +const iconCache = new Map(); + function createNativeImage(status: DaemonStatus): Electron.NativeImage { - const names: Record = { - connected: 'tray-connected', - waiting: 'tray-waiting', - starting: 'tray-waiting', - disconnected: 'tray-disconnected', - stopped: 'tray-stopped', - }; const assetsDir = path.join(app.getAppPath(), 'assets'); - const path1x = path.join(assetsDir, `${names[status]}.png`); - const path2x = path.join(assetsDir, `${names[status]}@2x.png`); + const path1x = path.join(assetsDir, `${ICON_NAMES[status]}.png`); + const path2x = path.join(assetsDir, `${ICON_NAMES[status]}@2x.png`); logger.debug('Loading tray icon', { status, path: path1x }); @@ -47,6 +48,14 @@ function createNativeImage(status: DaemonStatus): Electron.NativeImage { 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 { if (snapshot.status === 'connected' && snapshot.connectedUrl) { return `● Connected to ${snapshot.connectedUrl}`; @@ -55,34 +64,21 @@ function buildStatusLabel(snapshot: StatusSnapshot): string { } function buildMenu( - controller: DaemonController, snapshot: StatusSnapshot, onSettings: () => void, - onStartDaemon: () => void, onQuit: () => void, onDisconnect: () => void, ): Menu { - const running = controller.isRunning(); - const connected = snapshot.status === 'connected'; + const sessionActive = snapshot.status === 'connected' || snapshot.status === 'connecting'; return Menu.buildFromTemplate([ { label: buildStatusLabel(snapshot), enabled: false, }, { type: 'separator' }, - { - label: running ? 'Stop Daemon' : 'Start Daemon', - click: () => { - if (running) { - void controller.stop(); - } else { - onStartDaemon(); - } - }, - }, { label: 'Disconnect', - visible: connected, + visible: sessionActive, click: onDisconnect, }, { type: 'separator' }, @@ -101,19 +97,16 @@ function buildMenu( export function createTray( controller: DaemonController, onSettings: () => void, - onStartDaemon: () => void, onQuit: () => void, onDisconnect: () => void, ): Tray { - const tray = new Tray(createNativeImage('stopped')); + const tray = new Tray(getTrayIcon('disconnected')); tray.setToolTip('n8n Gateway'); const update = (snapshot: StatusSnapshot): void => { logger.debug('Tray updating', { status: snapshot.status, connectedUrl: snapshot.connectedUrl }); - tray.setImage(createNativeImage(snapshot.status)); - tray.setContextMenu( - buildMenu(controller, snapshot, onSettings, onStartDaemon, onQuit, onDisconnect), - ); + tray.setImage(getTrayIcon(snapshot.status)); + tray.setContextMenu(buildMenu(snapshot, onSettings, onQuit, onDisconnect)); }; controller.on('statusChanged', update); diff --git a/packages/@n8n/local-gateway/src/renderer/index.html b/packages/@n8n/local-gateway/src/renderer/index.html index b40eb8ea5b1..5d330cbc3e4 100644 --- a/packages/@n8n/local-gateway/src/renderer/index.html +++ b/packages/@n8n/local-gateway/src/renderer/index.html @@ -15,14 +15,14 @@
n8n Gateway
- - Stopped + + Disconnected
@@ -31,28 +31,29 @@
Connection
- - -
- -
- + -
URLs that can connect without confirmation (one per line)
+
+ One pattern per line or comma-separated. Used to validate instance URLs before connecting (not derived from the URL). +
+
+ + + +
+
diff --git a/packages/@n8n/local-gateway/src/renderer/settings.ts b/packages/@n8n/local-gateway/src/renderer/settings.ts index 3cedda2833b..2cb713e17ba 100644 --- a/packages/@n8n/local-gateway/src/renderer/settings.ts +++ b/packages/@n8n/local-gateway/src/renderer/settings.ts @@ -1,6 +1,4 @@ -import type { AppSettings, StatusSnapshot } from '../shared/types'; - -type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; +import type { AppSettings, DaemonStatus, LogLevel, StatusSnapshot } from '../shared/types'; declare global { interface Window { @@ -8,19 +6,17 @@ declare global { getSettings: () => Promise; setSettings: (partial: Partial) => Promise<{ ok: boolean; error?: string }>; getDaemonStatus: () => Promise; - startDaemon: () => Promise<{ ok: boolean }>; - stopDaemon: () => Promise<{ ok: boolean }>; + disconnectGateway: () => Promise<{ ok: boolean }>; onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void) => void; }; } } -const STATUS_TEXT: Record = { +const STATUS_TEXT: Record = { connected: 'Connected', - waiting: 'Waiting', - starting: 'Starting', + connecting: 'Connecting', disconnected: 'Disconnected', - stopped: 'Stopped', + error: 'Error', }; function updateStatusBadge(snapshot: StatusSnapshot): void { @@ -29,15 +25,46 @@ function updateStatusBadge(snapshot: StatusSnapshot): void { if (!dot || !text) return; dot.className = `status-dot ${snapshot.status}`; - const label = - snapshot.status === 'connected' && snapshot.connectedUrl - ? `Connected to ${snapshot.connectedUrl}` - : (STATUS_TEXT[snapshot.status] ?? snapshot.status); + let label: string; + if (snapshot.status === 'connected' && snapshot.connectedUrl) { + label = `Connected to ${snapshot.connectedUrl}`; + } 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; + 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 { - 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 filesystemEnabled = (document.getElementById('filesystemEnabled') as HTMLInputElement) .checked; @@ -47,28 +74,23 @@ function readForm(): Partial { const mouseKeyboardEnabled = (document.getElementById('mouseKeyboardEnabled') 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; return { - ...(Number.isFinite(port) && port > 0 ? { port } : {}), + allowedOrigins, filesystemDir, filesystemEnabled, shellEnabled, screenshotEnabled, mouseKeyboardEnabled, browserEnabled, - allowedOrigins, logLevel, }; } 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('filesystemEnabled') as HTMLInputElement).checked = settings.filesystemEnabled; @@ -78,8 +100,6 @@ function populateForm(settings: AppSettings): void { (document.getElementById('mouseKeyboardEnabled') as HTMLInputElement).checked = settings.mouseKeyboardEnabled; (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; } @@ -108,14 +128,13 @@ function isFormDirty(initial: AppSettings): boolean { return ( JSON.stringify(current) !== JSON.stringify({ - port: initial.port, + allowedOrigins: initial.allowedOrigins, filesystemDir: initial.filesystemDir, filesystemEnabled: initial.filesystemEnabled, shellEnabled: initial.shellEnabled, screenshotEnabled: initial.screenshotEnabled, mouseKeyboardEnabled: initial.mouseKeyboardEnabled, browserEnabled: initial.browserEnabled, - allowedOrigins: initial.allowedOrigins, logLevel: initial.logLevel, }) ); @@ -133,23 +152,17 @@ async function init(): Promise { let initialSettings = { ...settings }; - // Show restart notice and update buttons when form is dirty const form = document.getElementById('settingsForm') as HTMLFormElement; - form.addEventListener('change', () => { + const updateDirtyState = (): void => { const dirty = isFormDirty(initialSettings); setRestartNotice(dirty); setButtonsState(dirty); - }); - form.addEventListener('input', () => { - const dirty = isFormDirty(initialSettings); - setRestartNotice(dirty); - setButtonsState(dirty); - }); + }; + form.addEventListener('change', updateDirtyState); + form.addEventListener('input', updateDirtyState); - // Browse button — user can type the path directly (dialog not exposed via preload) - document.getElementById('browseDirBtn')?.addEventListener('click', () => { - // 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. + document.getElementById('disconnectBtn')?.addEventListener('click', () => { + void window.electronAPI.disconnectGateway(); }); // Apply button — save without closing diff --git a/packages/@n8n/local-gateway/src/renderer/styles.css b/packages/@n8n/local-gateway/src/renderer/styles.css index 3e1a6080f0d..294ff9a58d3 100644 --- a/packages/@n8n/local-gateway/src/renderer/styles.css +++ b/packages/@n8n/local-gateway/src/renderer/styles.css @@ -9,10 +9,9 @@ --color-warning: #f59e0b; --color-danger: #dc2626; --color-badge-connected: #22a06b; - --color-badge-waiting: #f59e0b; + --color-badge-connecting: #f59e0b; --color-badge-disconnected: #dc2626; - --color-badge-stopped: #999999; - --color-badge-starting: #f59e0b; + --color-badge-error: #dc2626; --radius: 6px; /* biome-ignore format: git hooks overwrite formatting to what biome throws an error for */ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; @@ -85,17 +84,14 @@ body { .status-dot.connected { background: var(--color-badge-connected); } -.status-dot.waiting { - background: var(--color-badge-waiting); -} -.status-dot.starting { - background: var(--color-badge-starting); +.status-dot.connecting { + background: var(--color-badge-connecting); } .status-dot.disconnected { background: var(--color-badge-disconnected); } -.status-dot.stopped { - background: var(--color-badge-stopped); +.status-dot.error { + background: var(--color-badge-error); } /* Warning banner */ @@ -209,6 +205,12 @@ body { border-bottom: none; } +.inline-actions { + margin-top: 12px; + display: flex; + gap: 8px; +} + .toggle-label { font-size: 13px; } @@ -264,30 +266,22 @@ body { 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 { font-size: 11px; color: var(--color-text-secondary); 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 { padding: 12px 20px; diff --git a/packages/@n8n/local-gateway/src/shared/types.ts b/packages/@n8n/local-gateway/src/shared/types.ts index d37c048405e..c3c60df47d0 100644 --- a/packages/@n8n/local-gateway/src/shared/types.ts +++ b/packages/@n8n/local-gateway/src/shared/types.ts @@ -1,21 +1,26 @@ 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 { - port: number; + /** User-configured origin patterns for validating instances before connect (never derived from URLs). */ + allowedOrigins: string[]; filesystemDir: string; filesystemEnabled: boolean; shellEnabled: boolean; screenshotEnabled: boolean; mouseKeyboardEnabled: boolean; browserEnabled: boolean; - allowedOrigins: string[]; logLevel: LogLevel; } export interface StatusSnapshot { status: DaemonStatus; connectedUrl: string | null; - connectedAt: string | null; + lastError: string | null; +} + +export interface ConnectPayload { + url: string; + apiKey?: string; }