mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat: Add deeplinkpairing and connection handling for native computer use (no-changelog) (#29445)
This commit is contained in:
parent
2e21c5fcf8
commit
ea98243c2b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
const filePath = getSettingsFilePath();
|
||||
const persistent = await loadFromFile(filePath);
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>(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<string, string> }>(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');
|
||||
|
|
|
|||
|
|
@ -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<ToolGroup, PermissionMode>): 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<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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
# @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 |
|
||||
|
||||
|
||||
> **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.
|
||||
|
||||
## Platform support
|
||||
|
|
@ -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=<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 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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
21
packages/@n8n/local-gateway/src/main/connect-origin.test.ts
Normal file
21
packages/@n8n/local-gateway/src/main/connect-origin.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
19
packages/@n8n/local-gateway/src/main/connect-origin.ts
Normal file
19
packages/@n8n/local-gateway/src/main/connect-origin.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
92
packages/@n8n/local-gateway/src/main/connect-payload.test.ts
Normal file
92
packages/@n8n/local-gateway/src/main/connect-payload.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
packages/@n8n/local-gateway/src/main/connect-payload.ts
Normal file
47
packages/@n8n/local-gateway/src/main/connect-payload.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<DaemonControllerEvents> {
|
||||
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<SettingsStore> {
|
||||
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');
|
||||
private async closeCurrentConnection(options: { preserveServerSession: boolean }): Promise<void> {
|
||||
if (this.client) {
|
||||
try {
|
||||
if (options.preserveServerSession) {
|
||||
await this.client.stop();
|
||||
} else {
|
||||
logger.info('Daemon disconnected');
|
||||
await this.client.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Gateway teardown failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.session) {
|
||||
await this.session.flush();
|
||||
}
|
||||
|
||||
this.client = null;
|
||||
this.session = null;
|
||||
this._connectedUrl = null;
|
||||
this._connectedAt = 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<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');
|
||||
}
|
||||
},
|
||||
};
|
||||
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');
|
||||
private afterGatewayPersistentFailure(): void {
|
||||
this._lastError = 'Gateway authentication failed repeatedly';
|
||||
void this.closeCurrentConnection({ preserveServerSession: true }).finally(() => {
|
||||
this.setStatus('error');
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
private afterGatewayDisconnected(): void {
|
||||
void this.closeCurrentConnection({ preserveServerSession: true }).finally(() => {
|
||||
this.setStatus('disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.debug('Daemon stopping');
|
||||
|
||||
if (!this.server) {
|
||||
this.setStatus('stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._port !== null) {
|
||||
try {
|
||||
await fetch(`http://localhost:${this._port}/disconnect`, { method: 'POST' });
|
||||
} catch {
|
||||
// Server may already be unreachable — proceed with close
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.server!.close(() => {
|
||||
this.server = null;
|
||||
this._port = null;
|
||||
private clearConnectionState(status: DaemonStatus): void {
|
||||
this.client = null;
|
||||
this.session = null;
|
||||
this._connectedUrl = null;
|
||||
this._connectedAt = null;
|
||||
this.setStatus('stopped');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.setStatus(status);
|
||||
}
|
||||
|
||||
private setStatus(status: DaemonStatus): void {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
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.
|
||||
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.
|
||||
|
|
@ -25,11 +32,12 @@ app.on('window-all-closed', () => {
|
|||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
// macOS: hide from Dock (tray-only app)
|
||||
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');
|
||||
|
|
@ -39,69 +47,92 @@ app
|
|||
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;
|
||||
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.',
|
||||
);
|
||||
}
|
||||
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;
|
||||
await controller.connect(config, payload.url, token);
|
||||
}
|
||||
|
||||
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) });
|
||||
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, restartDaemon);
|
||||
registerIpcHandlers(controller, settingsStore, disconnectGateway);
|
||||
|
||||
// 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(() => {
|
||||
void controller
|
||||
.disconnect()
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Disconnect failed during quit', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
app.quit();
|
||||
});
|
||||
},
|
||||
onDisconnect,
|
||||
() => {
|
||||
void disconnectGateway();
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-start the daemon on launch
|
||||
controller.start(settingsStore.toGatewayConfig(), confirmConnect);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
194
packages/@n8n/local-gateway/src/main/ipc-handlers.test.ts
Normal file
194
packages/@n8n/local-gateway/src/main/ipc-handlers.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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>,
|
||||
): 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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppSettings> =>
|
||||
|
|
@ -13,11 +12,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
getDaemonStatus: async (): Promise<StatusSnapshot> =>
|
||||
await (ipcRenderer.invoke('daemon:status') as Promise<StatusSnapshot>),
|
||||
|
||||
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) =>
|
||||
|
|
|
|||
87
packages/@n8n/local-gateway/src/main/settings-store.test.ts
Normal file
87
packages/@n8n/local-gateway/src/main/settings-store.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<StoredData>;
|
||||
private readonly store: Store<AppSettings>;
|
||||
|
||||
constructor() {
|
||||
this.store = new Store<StoredData>({
|
||||
this.store = new Store<AppSettings>({
|
||||
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]]
|
||||
>) {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -7,23 +7,24 @@ import type { DaemonController, DaemonStatus, StatusSnapshot } from './daemon-co
|
|||
|
||||
const STATUS_LABELS: Record<DaemonStatus, string> = {
|
||||
connected: '', // set dynamically with URL
|
||||
waiting: '◌ Waiting for connection',
|
||||
starting: '○ Starting...',
|
||||
disconnected: '✕ Disconnected — reconnecting',
|
||||
stopped: '■ Stopped',
|
||||
connecting: '○ Connecting...',
|
||||
disconnected: '✕ Disconnected',
|
||||
error: '✕ Error',
|
||||
};
|
||||
|
||||
function createNativeImage(status: DaemonStatus): Electron.NativeImage {
|
||||
const names: Record<DaemonStatus, string> = {
|
||||
const ICON_NAMES: Record<DaemonStatus, string> = {
|
||||
connected: 'tray-connected',
|
||||
waiting: 'tray-waiting',
|
||||
starting: 'tray-waiting',
|
||||
connecting: 'tray-waiting',
|
||||
disconnected: 'tray-disconnected',
|
||||
stopped: 'tray-stopped',
|
||||
error: 'tray-disconnected',
|
||||
};
|
||||
|
||||
const iconCache = new Map<DaemonStatus, Electron.NativeImage>();
|
||||
|
||||
function createNativeImage(status: DaemonStatus): Electron.NativeImage {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@
|
|||
<div class="header">
|
||||
<span class="header-title">n8n Gateway</span>
|
||||
<div id="statusBadge" class="status-badge">
|
||||
<span id="statusDot" class="status-dot stopped"></span>
|
||||
<span id="statusText">Stopped</span>
|
||||
<span id="statusDot" class="status-dot disconnected"></span>
|
||||
<span id="statusText">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="restart-notice" id="restartNotice" style="display: none">
|
||||
<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>
|
||||
|
||||
<form class="form" id="settingsForm">
|
||||
|
|
@ -31,28 +31,29 @@
|
|||
<div class="form-section-title">Connection</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="port">Port</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>
|
||||
<label class="form-label" for="allowedOrigins">Allowed origins</label>
|
||||
<textarea
|
||||
class="textarea"
|
||||
class="form-input"
|
||||
id="allowedOrigins"
|
||||
name="allowedOrigins"
|
||||
rows="3"
|
||||
placeholder="https://my-n8n.com https://another-n8n.example.com"
|
||||
spellcheck="false"
|
||||
placeholder="https://*.app.n8n.cloud http://localhost:5678"
|
||||
></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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppSettings>;
|
||||
setSettings: (partial: Partial<AppSettings>) => Promise<{ ok: boolean; error?: string }>;
|
||||
getDaemonStatus: () => Promise<StatusSnapshot>;
|
||||
startDaemon: () => Promise<{ ok: boolean }>;
|
||||
stopDaemon: () => Promise<{ ok: boolean }>;
|
||||
disconnectGateway: () => Promise<{ ok: boolean }>;
|
||||
onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
const STATUS_TEXT: Record<DaemonStatus, string> = {
|
||||
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<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 filesystemEnabled = (document.getElementById('filesystemEnabled') as HTMLInputElement)
|
||||
.checked;
|
||||
|
|
@ -47,28 +74,23 @@ function readForm(): Partial<AppSettings> {
|
|||
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<void> {
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user