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",
|
"main": "dist/cli.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/cli.js",
|
".": "./dist/cli.js",
|
||||||
"./daemon": "./dist/daemon.js",
|
|
||||||
"./config": "./dist/config.js",
|
"./config": "./dist/config.js",
|
||||||
|
"./gateway-client": "./dist/gateway-client.js",
|
||||||
"./logger": "./dist/logger.js",
|
"./logger": "./dist/logger.js",
|
||||||
"./gateway-session": "./dist/gateway-session.js"
|
"./gateway-session": "./dist/gateway-session.js",
|
||||||
|
"./settings-store": "./dist/settings-store.js"
|
||||||
},
|
},
|
||||||
"module": "src/cli.ts",
|
"module": "src/cli.ts",
|
||||||
"types": "dist/cli.d.ts",
|
"types": "dist/cli.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
import { SettingsStore } from './settings-store';
|
import { SettingsStore } from './settings-store';
|
||||||
import {
|
import {
|
||||||
editPermissions,
|
editPermissions,
|
||||||
ensureSettingsFile,
|
|
||||||
isAllDeny,
|
isAllDeny,
|
||||||
printPermissionsTable,
|
printPermissionsTable,
|
||||||
promptFilesystemDir,
|
promptFilesystemDir,
|
||||||
|
|
@ -173,7 +172,7 @@ async function main(
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureSettingsFile(config);
|
await SettingsStore.ensureInitialized(config);
|
||||||
|
|
||||||
const settingsStore = await SettingsStore.create();
|
const settingsStore = await SettingsStore.create();
|
||||||
const defaults = settingsStore.getDefaults(parsed.config);
|
const defaults = settingsStore.getDefaults(parsed.config);
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export class GatewayClient {
|
||||||
|
|
||||||
constructor(private readonly options: GatewayClientOptions) {}
|
constructor(private readonly options: GatewayClientOptions) {}
|
||||||
|
|
||||||
/** Return the active API key — session key if available, otherwise the original key. */
|
/** Session key when the server has upgraded the pairing token; otherwise the original token. */
|
||||||
private get apiKey(): string {
|
private get apiKey(): string {
|
||||||
return this.sessionKey ?? this.options.apiKey;
|
return this.sessionKey ?? this.options.apiKey;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,42 @@ describe('SettingsStore.create', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SettingsStore.ensureInitialized
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('SettingsStore.ensureInitialized', () => {
|
||||||
|
it('creates the settings file when absent', async () => {
|
||||||
|
(os.homedir as jest.Mock).mockReturnValue(tmpDir);
|
||||||
|
await SettingsStore.ensureInitialized(BASE_CONFIG);
|
||||||
|
|
||||||
|
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
||||||
|
const parsed = parseJson<Record<string, unknown>>(raw);
|
||||||
|
|
||||||
|
expect(parsed.permissions).toMatchObject({
|
||||||
|
filesystemRead: 'allow',
|
||||||
|
filesystemWrite: 'ask',
|
||||||
|
shell: 'deny',
|
||||||
|
computer: 'deny',
|
||||||
|
browser: 'ask',
|
||||||
|
});
|
||||||
|
expect(parsed.filesystemDir).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite an existing settings file', async () => {
|
||||||
|
(os.homedir as jest.Mock).mockReturnValue(tmpDir);
|
||||||
|
const dir = path.join(tmpDir, '.n8n-gateway');
|
||||||
|
const file = path.join(dir, 'settings.json');
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
const existing = JSON.stringify({ permissions: { shell: 'allow' }, filesystemDir: '/custom' });
|
||||||
|
await fs.writeFile(file, existing, 'utf-8');
|
||||||
|
|
||||||
|
await SettingsStore.ensureInitialized(BASE_CONFIG);
|
||||||
|
const raw = await fs.readFile(file, 'utf-8');
|
||||||
|
expect(raw).toBe(existing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// getDefaults
|
// getDefaults
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
permissionModeSchema,
|
permissionModeSchema,
|
||||||
TOOL_GROUP_DEFINITIONS,
|
TOOL_GROUP_DEFINITIONS,
|
||||||
} from './config';
|
} from './config';
|
||||||
|
import { getTemplate } from './config-templates';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -82,6 +83,33 @@ export class SettingsStore {
|
||||||
// Factory
|
// Factory
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static async ensureInitialized(config: GatewayConfig): Promise<void> {
|
||||||
|
const filePath = getSettingsFilePath();
|
||||||
|
|
||||||
|
// Never overwrite an existing settings file.
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// File does not exist yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = getTemplate('default');
|
||||||
|
const permissions = { ...template.permissions, ...config.permissions };
|
||||||
|
const initialSettings: PersistentSettings = {
|
||||||
|
permissions,
|
||||||
|
filesystemDir: '',
|
||||||
|
resourcePermissions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(initialSettings, null, 2), {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static async create(): Promise<SettingsStore> {
|
static async create(): Promise<SettingsStore> {
|
||||||
const filePath = getSettingsFilePath();
|
const filePath = getSettingsFilePath();
|
||||||
const persistent = await loadFromFile(filePath);
|
const persistent = await loadFromFile(filePath);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ jest.mock('node:os', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
import type { GatewayConfig } from './config';
|
import type { GatewayConfig } from './config';
|
||||||
import { ensureSettingsFile, isAllDeny } from './startup-config-cli';
|
import { SettingsStore } from './settings-store';
|
||||||
|
import { isAllDeny } from './startup-config-cli';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -77,10 +78,10 @@ describe('isAllDeny', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ensureSettingsFile
|
// SettingsStore.ensureInitialized (CLI entry path; replaces legacy ensureSettingsFile helper)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('ensureSettingsFile', () => {
|
describe('SettingsStore.ensureInitialized', () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
@ -95,7 +96,7 @@ describe('ensureSettingsFile', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates the settings file with recommended defaults when absent', async () => {
|
it('creates the settings file with recommended defaults when absent', async () => {
|
||||||
await ensureSettingsFile(BASE_CONFIG);
|
await SettingsStore.ensureInitialized(BASE_CONFIG);
|
||||||
|
|
||||||
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
||||||
const parsed = parseJson<Record<string, unknown>>(raw);
|
const parsed = parseJson<Record<string, unknown>>(raw);
|
||||||
|
|
@ -116,7 +117,7 @@ describe('ensureSettingsFile', () => {
|
||||||
...BASE_CONFIG,
|
...BASE_CONFIG,
|
||||||
permissions: { shell: 'allow' },
|
permissions: { shell: 'allow' },
|
||||||
};
|
};
|
||||||
await ensureSettingsFile(config);
|
await SettingsStore.ensureInitialized(config);
|
||||||
|
|
||||||
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
||||||
const parsed = parseJson<{ permissions: Record<string, string> }>(raw);
|
const parsed = parseJson<{ permissions: Record<string, string> }>(raw);
|
||||||
|
|
@ -134,15 +135,15 @@ describe('ensureSettingsFile', () => {
|
||||||
const existing = JSON.stringify({ permissions: { shell: 'allow' }, filesystemDir: '/custom' });
|
const existing = JSON.stringify({ permissions: { shell: 'allow' }, filesystemDir: '/custom' });
|
||||||
await fs.writeFile(file, existing, 'utf-8');
|
await fs.writeFile(file, existing, 'utf-8');
|
||||||
|
|
||||||
await ensureSettingsFile(BASE_CONFIG);
|
await SettingsStore.ensureInitialized(BASE_CONFIG);
|
||||||
|
|
||||||
const raw = await fs.readFile(file, 'utf-8');
|
const raw = await fs.readFile(file, 'utf-8');
|
||||||
expect(raw).toBe(existing);
|
expect(raw).toBe(existing);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is safe to call multiple times — only creates once', async () => {
|
it('is safe to call multiple times — only creates once', async () => {
|
||||||
await ensureSettingsFile(BASE_CONFIG);
|
await SettingsStore.ensureInitialized(BASE_CONFIG);
|
||||||
await ensureSettingsFile(BASE_CONFIG);
|
await SettingsStore.ensureInitialized(BASE_CONFIG);
|
||||||
|
|
||||||
// Second call must not throw and must not alter the file
|
// Second call must not throw and must not alter the file
|
||||||
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ import { select, input } from '@inquirer/prompts';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as nodePath from 'node:path';
|
import * as nodePath from 'node:path';
|
||||||
|
|
||||||
import type { GatewayConfig, PermissionMode, ToolGroup } from './config';
|
import type { PermissionMode, ToolGroup } from './config';
|
||||||
import { PERMISSION_MODES, getSettingsFilePath, TOOL_GROUP_DEFINITIONS } from './config';
|
import { PERMISSION_MODES, TOOL_GROUP_DEFINITIONS } from './config';
|
||||||
import { getTemplate } from './config-templates';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Display helpers
|
// Display helpers
|
||||||
|
|
@ -74,36 +73,3 @@ export function isAllDeny(permissions: Record<ToolGroup, PermissionMode>): boole
|
||||||
(g) => permissions[g] === 'deny',
|
(g) => permissions[g] === 'deny',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Silently creates the settings file with the default (Recommended) template
|
|
||||||
* if it does not exist. Merges CLI/ENV overrides from config.permissions on top.
|
|
||||||
* filesystemDir is left empty. Does NOT prompt. Safe to call on every startup.
|
|
||||||
*/
|
|
||||||
export async function ensureSettingsFile(config: GatewayConfig): Promise<void> {
|
|
||||||
const filePath = getSettingsFilePath();
|
|
||||||
|
|
||||||
// Only create if truly absent — never overwrite an existing file.
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
return; // File exists — nothing to do.
|
|
||||||
} catch {
|
|
||||||
// File does not exist — proceed to create.
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = getTemplate('default');
|
|
||||||
const permissions = { ...template.permissions, ...config.permissions };
|
|
||||||
|
|
||||||
const content = JSON.stringify(
|
|
||||||
{ permissions, filesystemDir: '', resourcePermissions: {} },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
|
|
||||||
await fs.writeFile(filePath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
# @n8n/local-gateway
|
# @n8n/local-gateway
|
||||||
|
|
||||||
A native tray application that bridges an n8n cloud or self-hosted instance to capabilities on your local machine. It runs silently in the system tray and exposes a secure local HTTP gateway that n8n workflows can connect to.
|
A native tray application that bridges an n8n cloud or self-hosted instance to capabilities on your local machine. It runs silently in the system tray and connects directly to your n8n instance using a gateway token.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
When an n8n workflow needs to interact with your computer — take a screenshot, move the mouse, type text, run a shell command, or read a file — it connects to this gateway. The app listens for incoming connections and, for new origins, prompts you to approve the connection before anything runs.
|
When an n8n workflow needs to interact with your computer — take a screenshot, move the mouse, type text, run a shell command, or read a file — this app runs the local tools and streams requests/results between your machine and n8n.
|
||||||
|
|
||||||
Supported capabilities (each can be individually enabled or disabled):
|
Supported capabilities (each can be individually enabled or disabled):
|
||||||
|
|
||||||
| Capability | Default | Description |
|
|
||||||
|---|---|---|
|
| Capability | Default | Description |
|
||||||
| Filesystem | On | Read/write files in a configurable directory (defaults to home directory) |
|
| ------------------ | ------- | ------------------------------------------------------------------------- |
|
||||||
| Screenshots | On | Capture screen content |
|
| Filesystem | On | Read/write files in a configurable directory (defaults to home directory) |
|
||||||
| Mouse & Keyboard | On | Simulate input events |
|
| Screenshots | On | Capture screen content |
|
||||||
| Browser automation | On | Control a local browser |
|
| Mouse & Keyboard | On | Simulate input events |
|
||||||
| Shell execution | **Off** | Run shell commands — requires explicit opt-in |
|
| Browser automation | On | Control a local browser |
|
||||||
|
| Shell execution | **Off** | Run shell commands — requires explicit opt-in |
|
||||||
|
|
||||||
|
|
||||||
> **Permissions note:** On first use, macOS and Windows will prompt you to grant accessibility and screen recording permissions when an n8n workflow triggers screenshot or mouse/keyboard actions. This is a one-time OS-level prompt per capability.
|
> **Permissions note:** On first use, macOS and Windows will prompt you to grant accessibility and screen recording permissions when an n8n workflow triggers screenshot or mouse/keyboard actions. This is a one-time OS-level prompt per capability.
|
||||||
|
|
||||||
|
|
@ -45,6 +47,8 @@ cd packages/@n8n/local-gateway
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**OS deeplink / connect handshake:** Plain `pnpm start` is fine for tray and settings UI. To verify **`n8n-computer-use://…`** routing from n8n the way end users experience it, build the macOS artefact (**`pnpm dist:mac`**, see below), install or run the generated app under **`packages/@n8n/local-gateway/out/`**, and trigger connect from n8n’s computer-use / local-gateway flow.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Compile the TypeScript sources:
|
Compile the TypeScript sources:
|
||||||
|
|
@ -75,13 +79,41 @@ pnpm --filter=@n8n/local-gateway dist:win
|
||||||
|
|
||||||
Installers are written to the `out/` directory.
|
Installers are written to the `out/` directory.
|
||||||
|
|
||||||
## Configuration
|
## Connecting
|
||||||
|
|
||||||
Settings are persisted across restarts and can be changed via the tray icon → **Settings**:
|
Pairing is done from n8n via the **computer-use / local gateway** flow, which opens an OS deeplink into this app. The Settings window does not accept an instance URL or gateway token; it only stores global preferences (allowed origins, capabilities, etc.).
|
||||||
|
|
||||||
- **Port** — the local port the gateway listens on (default: `7655`)
|
1. In n8n, start the connect flow for the local gateway / computer-use integration so your browser or OS opens the registered protocol URL (see below).
|
||||||
- **Allowed origins** — n8n instance URLs that are pre-approved and skip the connection prompt
|
2. Before connecting, open tray icon → **Settings** and ensure **Allowed origins** includes the origin of your n8n instance (for local dev, add `http://localhost:5678` or your port). Origins are validated before any connection.
|
||||||
|
|
||||||
|
The app registers **`n8n-computer-use`** as the primary OS protocol handler. The URL shape is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
n8n-computer-use://connect?url=<ENCODED_N8N_URL>&token=<TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (manual test of the handler):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open "n8n-computer-use://connect?url=http%3A%2F%2Flocalhost%3A5678&token=YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The gateway token is one-time for pairing.
|
||||||
|
- URL and token are not stored in global settings; connect again after restart using 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
|
- **Capability toggles** — enable or disable individual capabilities
|
||||||
|
- **Filesystem directory** — root path for filesystem tools
|
||||||
|
- **Log level** — controls verbosity of local gateway logs (`~/.n8n-local-gateway/log`)
|
||||||
|
|
||||||
|
Connection URL and gateway token are supplied only via the deeplink (or CLI); not through Settings.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -93,4 +125,6 @@ src/renderer/ — Settings UI (plain HTML/CSS/TS, sandboxed)
|
||||||
src/shared/ — Types shared between main and renderer
|
src/shared/ — Types shared between main and renderer
|
||||||
```
|
```
|
||||||
|
|
||||||
The actual gateway daemon is provided by the `@n8n/computer-use` package and is managed by `DaemonController`, which starts/stops it and surfaces status (`stopped → starting → waiting → connected → disconnected`) to the tray menu and settings window via IPC.
|
The local runtime is provided by `@n8n/computer-use` and managed by `DaemonController`, which handles connect/disconnect and surfaces status (`disconnected` until a session starts, then `connecting → connected → disconnected/error`) to the tray menu and settings window via IPC.
|
||||||
|
|
||||||
|
See `docs/ARCHITECTURE_CONNECTION_VS_SETTINGS.md` for how global settings, connection attempts, and runtime session state relate.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
"private": true,
|
||||||
"main": "dist/main/index.js",
|
"main": "dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'skipped'",
|
"build": "tsc -p tsconfig.renderer.json && tsc -p tsconfig.build.json && pnpm copy:renderer && pnpm copy:assets",
|
||||||
"lint": "echo 'skipped'",
|
"lint": "eslint . --quiet",
|
||||||
"typecheck": "echo 'skipped'",
|
"typecheck": "tsc --noEmit",
|
||||||
"fix:build": "tsc -p tsconfig.renderer.json && tsc -p tsconfig.build.json && pnpm copy:renderer && pnpm copy:assets",
|
|
||||||
"fix:lint": "eslint . --quiet",
|
|
||||||
"fix:typecheck": "tsc --noEmit",
|
|
||||||
"copy:assets": "node scripts/copy-assets.js",
|
"copy:assets": "node scripts/copy-assets.js",
|
||||||
"copy:renderer": "node scripts/copy-renderer.js",
|
"copy:renderer": "node scripts/copy-renderer.js",
|
||||||
"dev": "pnpm copy:assets && pnpm copy:renderer && concurrently \"tsc -p tsconfig.renderer.json --watch\" \"tsc -p tsconfig.build.json --watch\"",
|
"dev": "pnpm copy:assets && pnpm copy:renderer && concurrently \"tsc -p tsconfig.renderer.json --watch\" \"tsc -p tsconfig.build.json --watch\"",
|
||||||
"start": "electron dist/main/index.js",
|
"start": "electron dist/main/index.js",
|
||||||
"dist:mac": "pnpm fix:build && electron-builder --mac --config electron-builder.config.js",
|
"dist:mac": "pnpm build && electron-builder --mac --config electron-builder.config.js",
|
||||||
"dist:win": "pnpm fix:build && electron-builder --win --config electron-builder.config.js",
|
"dist:win": "pnpm build && electron-builder --win --config electron-builder.config.js",
|
||||||
"format": "biome format --write src",
|
"format": "biome format --write src",
|
||||||
"format:check": "biome ci src",
|
"format:check": "biome ci src",
|
||||||
"clean": "rimraf dist out .turbo",
|
"clean": "rimraf dist out .turbo",
|
||||||
|
|
|
||||||
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', () => {
|
describe('DaemonController', () => {
|
||||||
it('should be importable', () => {
|
it('starts disconnected with no session', () => {
|
||||||
expect(true).toBeDefined();
|
const controller = new DaemonController();
|
||||||
|
expect(controller.getSnapshot()).toEqual({
|
||||||
|
status: 'disconnected',
|
||||||
|
connectedUrl: null,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
lastGatewayOptions = undefined;
|
||||||
|
mockGetDefaults.mockReturnValue({
|
||||||
|
dir: '/',
|
||||||
|
permissions: {
|
||||||
|
filesystemRead: 'allow',
|
||||||
|
filesystemWrite: 'ask',
|
||||||
|
shell: 'deny',
|
||||||
|
computer: 'deny',
|
||||||
|
browser: 'ask',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockStart.mockResolvedValue(undefined);
|
||||||
|
mockDisconnect.mockResolvedValue(undefined);
|
||||||
|
mockStop.mockResolvedValue(undefined);
|
||||||
|
mockSessionFlush.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects and updates snapshot state', async () => {
|
||||||
|
const controller = new DaemonController();
|
||||||
|
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token');
|
||||||
|
|
||||||
|
const snapshot = controller.getSnapshot();
|
||||||
|
expect(snapshot.status).toBe('connected');
|
||||||
|
expect(snapshot.connectedUrl).toBe('https://example.n8n.cloud');
|
||||||
|
expect(snapshot.lastError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes trailing slash on URL', async () => {
|
||||||
|
const controller = new DaemonController();
|
||||||
|
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud/', 'gw_token');
|
||||||
|
expect(controller.getSnapshot().connectedUrl).toBe('https://example.n8n.cloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets error state when connect fails', async () => {
|
||||||
|
const controller = new DaemonController();
|
||||||
|
mockStart.mockRejectedValueOnce(new Error('connect failed'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token'),
|
||||||
|
).rejects.toThrow('connect failed');
|
||||||
|
|
||||||
|
const snapshot = controller.getSnapshot();
|
||||||
|
expect(snapshot.status).toBe('error');
|
||||||
|
expect(snapshot.connectedUrl).toBeNull();
|
||||||
|
expect(snapshot.lastError).toBe('connect failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats non-Error rejection for lastError', async () => {
|
||||||
|
const controller = new DaemonController();
|
||||||
|
mockStart.mockRejectedValueOnce('string failure');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token'),
|
||||||
|
).rejects.toThrow('string failure');
|
||||||
|
expect(controller.getSnapshot().lastError).toBe('string failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects and clears connected state', async () => {
|
||||||
|
const controller = new DaemonController();
|
||||||
|
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token');
|
||||||
|
await controller.disconnect();
|
||||||
|
|
||||||
|
const snapshot = controller.getSnapshot();
|
||||||
|
expect(snapshot.status).toBe('disconnected');
|
||||||
|
expect(snapshot.connectedUrl).toBeNull();
|
||||||
|
expect(mockDisconnect).toHaveBeenCalled();
|
||||||
|
expect(mockSessionFlush).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls stop on previous client when connecting again', async () => {
|
||||||
|
const controller = new DaemonController();
|
||||||
|
await controller.connect(BASE_CONFIG, 'https://a.example', 't1');
|
||||||
|
await controller.connect(BASE_CONFIG, 'https://b.example', 't2');
|
||||||
|
expect(mockStop).toHaveBeenCalled();
|
||||||
|
expect(mockStart).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets error state when gateway signals persistent auth failure', async () => {
|
||||||
|
const controller = new DaemonController();
|
||||||
|
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token');
|
||||||
|
lastGatewayOptions?.onPersistentFailure?.();
|
||||||
|
|
||||||
|
await settleNextTurn();
|
||||||
|
|
||||||
|
const snapshot = controller.getSnapshot();
|
||||||
|
expect(snapshot.status).toBe('error');
|
||||||
|
expect(snapshot.lastError).toBe('Gateway authentication failed repeatedly');
|
||||||
|
expect(mockStop).toHaveBeenCalled();
|
||||||
|
expect(mockSessionFlush).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets disconnected state when gateway signals disconnect', async () => {
|
||||||
|
const controller = new DaemonController();
|
||||||
|
await controller.connect(BASE_CONFIG, 'https://example.n8n.cloud', 'gw_token');
|
||||||
|
lastGatewayOptions?.onDisconnected?.();
|
||||||
|
|
||||||
|
await settleNextTurn();
|
||||||
|
|
||||||
|
expect(controller.getSnapshot().status).toBe('disconnected');
|
||||||
|
expect(mockStop).toHaveBeenCalled();
|
||||||
|
expect(mockSessionFlush).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import type { GatewayConfig } from '@n8n/computer-use/config';
|
import type { GatewayConfig } from '@n8n/computer-use/config';
|
||||||
import type { DaemonOptions } from '@n8n/computer-use/daemon';
|
import { GatewayClient } from '@n8n/computer-use/gateway-client';
|
||||||
import { startDaemon } from '@n8n/computer-use/daemon';
|
import { GatewaySession } from '@n8n/computer-use/gateway-session';
|
||||||
import type { GatewaySession } from '@n8n/computer-use/gateway-session';
|
|
||||||
import { logger } from '@n8n/computer-use/logger';
|
import { logger } from '@n8n/computer-use/logger';
|
||||||
|
import { SettingsStore } from '@n8n/computer-use/settings-store';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type * as http from 'node:http';
|
|
||||||
|
|
||||||
import type { DaemonStatus, StatusSnapshot } from '../shared/types';
|
import type { DaemonStatus, StatusSnapshot } from '../shared/types';
|
||||||
|
|
||||||
|
|
@ -15,108 +14,121 @@ export interface DaemonControllerEvents {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DaemonController extends EventEmitter<DaemonControllerEvents> {
|
export class DaemonController extends EventEmitter<DaemonControllerEvents> {
|
||||||
private server: http.Server | null = null;
|
private client: GatewayClient | null = null;
|
||||||
private _port: number | null = null;
|
private session: GatewaySession | null = null;
|
||||||
private _status: DaemonStatus = 'stopped';
|
private settingsStore: SettingsStore | null = null;
|
||||||
|
private _status: DaemonStatus = 'disconnected';
|
||||||
private _connectedUrl: string | null = null;
|
private _connectedUrl: string | null = null;
|
||||||
private _connectedAt: string | null = null;
|
private _lastError: string | null = null;
|
||||||
|
|
||||||
getSnapshot(): StatusSnapshot {
|
getSnapshot(): StatusSnapshot {
|
||||||
return {
|
return {
|
||||||
status: this._status,
|
status: this._status,
|
||||||
connectedUrl: this._connectedUrl,
|
connectedUrl: this._connectedUrl,
|
||||||
connectedAt: this._connectedAt,
|
lastError: this._lastError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
isRunning(): boolean {
|
private async getSettingsStore(): Promise<SettingsStore> {
|
||||||
return this._status !== 'stopped';
|
this.settingsStore = this.settingsStore ?? (await SettingsStore.create());
|
||||||
|
return this.settingsStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(
|
private async closeCurrentConnection(options: { preserveServerSession: boolean }): Promise<void> {
|
||||||
config: GatewayConfig,
|
if (this.client) {
|
||||||
confirmConnect: (url: string, session: GatewaySession) => boolean,
|
|
||||||
): void {
|
|
||||||
if (this.server) {
|
|
||||||
logger.debug('Daemon start requested but already running — ignoring');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._port = config.port;
|
|
||||||
logger.debug('Daemon starting', { port: config.port });
|
|
||||||
|
|
||||||
this.setStatus('starting');
|
|
||||||
|
|
||||||
const options: DaemonOptions = {
|
|
||||||
managedMode: true,
|
|
||||||
confirmConnect,
|
|
||||||
confirmResourceAccess: () => 'denyOnce' as const,
|
|
||||||
onStatusChange: (status, url) => {
|
|
||||||
if (status === 'connected') {
|
|
||||||
logger.info('Daemon connected', { url });
|
|
||||||
this._connectedUrl = url ?? null;
|
|
||||||
this._connectedAt = new Date().toISOString();
|
|
||||||
this.setStatus('connected');
|
|
||||||
} else {
|
|
||||||
logger.info('Daemon disconnected');
|
|
||||||
this._connectedUrl = null;
|
|
||||||
this._connectedAt = null;
|
|
||||||
this.setStatus('disconnected');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.server = startDaemon(config, options);
|
|
||||||
|
|
||||||
// Server is now listening (or will be shortly) — mark as waiting
|
|
||||||
this.server.once('listening', () => {
|
|
||||||
if (this._status === 'starting') {
|
|
||||||
this.setStatus('waiting');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.server.once('error', (e: Error) => {
|
|
||||||
logger.error('Daemon server error', { error: e.message });
|
|
||||||
this.server = null;
|
|
||||||
this.setStatus('stopped');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnectClient(): Promise<void> {
|
|
||||||
logger.debug('Disconnecting client');
|
|
||||||
if (!this.server || this._port === null) return;
|
|
||||||
try {
|
|
||||||
await fetch(`http://localhost:${this._port}/disconnect`, { method: 'POST' });
|
|
||||||
} catch {
|
|
||||||
// Server may be unreachable — ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
logger.debug('Daemon stopping');
|
|
||||||
|
|
||||||
if (!this.server) {
|
|
||||||
this.setStatus('stopped');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._port !== null) {
|
|
||||||
try {
|
try {
|
||||||
await fetch(`http://localhost:${this._port}/disconnect`, { method: 'POST' });
|
if (options.preserveServerSession) {
|
||||||
} catch {
|
await this.client.stop();
|
||||||
// Server may already be unreachable — proceed with close
|
} else {
|
||||||
|
await this.client.disconnect();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Gateway teardown failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
if (this.session) {
|
||||||
this.server!.close(() => {
|
await this.session.flush();
|
||||||
this.server = null;
|
}
|
||||||
this._port = null;
|
|
||||||
this._connectedUrl = null;
|
this.client = null;
|
||||||
this._connectedAt = null;
|
this.session = null;
|
||||||
this.setStatus('stopped');
|
this._connectedUrl = null;
|
||||||
resolve();
|
}
|
||||||
});
|
|
||||||
|
private formatErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(error);
|
||||||
|
} catch {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(config: GatewayConfig, url: string, apiKey: string): Promise<void> {
|
||||||
|
const normalizedUrl = url.replace(/\/$/, '');
|
||||||
|
logger.debug('Direct gateway connect requested', { url: normalizedUrl });
|
||||||
|
|
||||||
|
this.setStatus('connecting');
|
||||||
|
this._lastError = null;
|
||||||
|
|
||||||
|
await this.closeCurrentConnection({ preserveServerSession: true });
|
||||||
|
const store = await this.getSettingsStore();
|
||||||
|
const defaults = store.getDefaults(config);
|
||||||
|
const session = new GatewaySession(defaults, store);
|
||||||
|
const client = new GatewayClient({
|
||||||
|
url: normalizedUrl,
|
||||||
|
apiKey,
|
||||||
|
config,
|
||||||
|
session,
|
||||||
|
confirmResourceAccess: () => 'denyOnce',
|
||||||
|
onPersistentFailure: () => {
|
||||||
|
this.afterGatewayPersistentFailure();
|
||||||
|
},
|
||||||
|
onDisconnected: () => {
|
||||||
|
this.afterGatewayDisconnected();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.start();
|
||||||
|
this.client = client;
|
||||||
|
this.session = session;
|
||||||
|
this._connectedUrl = normalizedUrl;
|
||||||
|
this.setStatus('connected');
|
||||||
|
} catch (error) {
|
||||||
|
this._lastError = this.formatErrorMessage(error);
|
||||||
|
this.clearConnectionState('error');
|
||||||
|
throw new Error(this._lastError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.closeCurrentConnection({ preserveServerSession: false });
|
||||||
|
this.setStatus('disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
private afterGatewayPersistentFailure(): void {
|
||||||
|
this._lastError = 'Gateway authentication failed repeatedly';
|
||||||
|
void this.closeCurrentConnection({ preserveServerSession: true }).finally(() => {
|
||||||
|
this.setStatus('error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private afterGatewayDisconnected(): void {
|
||||||
|
void this.closeCurrentConnection({ preserveServerSession: true }).finally(() => {
|
||||||
|
this.setStatus('disconnected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearConnectionState(status: DaemonStatus): void {
|
||||||
|
this.client = null;
|
||||||
|
this.session = null;
|
||||||
|
this._connectedUrl = null;
|
||||||
|
this.setStatus(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setStatus(status: DaemonStatus): void {
|
private setStatus(status: DaemonStatus): void {
|
||||||
|
|
|
||||||
|
|
@ -1,107 +1,138 @@
|
||||||
import type { GatewaySession } from '@n8n/computer-use/gateway-session';
|
|
||||||
import { configure, logger } from '@n8n/computer-use/logger';
|
import { configure, logger } from '@n8n/computer-use/logger';
|
||||||
import { app, dialog } from 'electron';
|
import { app } from 'electron';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import { assertConnectOriginAllowed } from './connect-origin';
|
||||||
|
import {
|
||||||
|
deepLinkProtocolsInArgv,
|
||||||
|
parseConnectPayload,
|
||||||
|
parseConnectPayloadFromArgv,
|
||||||
|
DEEP_LINK_PROTOCOL,
|
||||||
|
} from './connect-payload';
|
||||||
import { DaemonController } from './daemon-controller';
|
import { DaemonController } from './daemon-controller';
|
||||||
import { registerIpcHandlers } from './ipc-handlers';
|
import { registerIpcHandlers } from './ipc-handlers';
|
||||||
import { SettingsStore } from './settings-store';
|
import { SettingsStore } from './settings-store';
|
||||||
import { openSettingsWindow, notifySettingsWindow } from './settings-window';
|
import { openSettingsWindow, notifySettingsWindow } from './settings-window';
|
||||||
import { createTray } from './tray';
|
import { createTray } from './tray';
|
||||||
|
import type { ConnectPayload } from '../shared/types';
|
||||||
|
|
||||||
// Windows: required for proper taskbar/notification grouping
|
// Windows: required for proper taskbar/notification grouping
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
app.setAppUserModelId('io.n8n.gateway');
|
app.setAppUserModelId('io.n8n.gateway');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the process running even when all windows are closed (tray-only app).
|
if (!app.requestSingleInstanceLock()) {
|
||||||
// Returning false from the handler is not possible via the Electron API directly;
|
app.quit();
|
||||||
// instead we simply never quit — the tray manages app lifetime.
|
} else {
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
// Intentionally do nothing: this is a tray-only app that stays alive
|
// Intentionally do nothing: this is a tray-only app that stays alive
|
||||||
// even when all BrowserWindows are closed.
|
// even when all BrowserWindows are closed.
|
||||||
});
|
|
||||||
|
|
||||||
app
|
|
||||||
.whenReady()
|
|
||||||
.then(() => {
|
|
||||||
// macOS: hide from Dock (tray-only app)
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
app.dock?.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsStore = new SettingsStore();
|
|
||||||
configure({ level: settingsStore.get().logLevel });
|
|
||||||
logger.info('n8n Gateway starting');
|
|
||||||
|
|
||||||
const controller = new DaemonController();
|
|
||||||
|
|
||||||
const preloadPath = path.join(__dirname, 'preload.js');
|
|
||||||
const rendererPath = path.join(__dirname, '..', 'renderer', 'index.html');
|
|
||||||
|
|
||||||
function confirmConnect(url: string, _session: GatewaySession): boolean {
|
|
||||||
const lastUrl = settingsStore.getLastConnectedUrl();
|
|
||||||
if (lastUrl !== null && lastUrl === url) {
|
|
||||||
logger.info('Auto-approving connection from known URL', { url });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const result = dialog.showMessageBoxSync({
|
|
||||||
type: 'question',
|
|
||||||
buttons: ['Allow', 'Reject'],
|
|
||||||
defaultId: 0,
|
|
||||||
cancelId: 1,
|
|
||||||
title: 'n8n Connection Request',
|
|
||||||
message: `Allow n8n to connect?\n\n${url}`,
|
|
||||||
detail: 'Confirm only if you initiated this connection from n8n.',
|
|
||||||
});
|
|
||||||
return result === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function restartDaemon(): void {
|
|
||||||
logger.info('Restarting daemon');
|
|
||||||
const config = settingsStore.toGatewayConfig();
|
|
||||||
void controller
|
|
||||||
.stop()
|
|
||||||
.then(() => {
|
|
||||||
controller.start(config, confirmConnect);
|
|
||||||
})
|
|
||||||
.catch((e: unknown) => {
|
|
||||||
logger.error('Failed to restart daemon', { e: String(e) });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
registerIpcHandlers(controller, settingsStore, restartDaemon);
|
|
||||||
|
|
||||||
// Propagate status changes to the settings window (if open) and persist connection URL
|
|
||||||
controller.on('statusChanged', (snapshot) => {
|
|
||||||
notifySettingsWindow('statusChanged', snapshot);
|
|
||||||
if (snapshot.status === 'connected' && snapshot.connectedUrl) {
|
|
||||||
settingsStore.setLastConnectedUrl(snapshot.connectedUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function onDisconnect(): void {
|
|
||||||
settingsStore.setLastConnectedUrl(null);
|
|
||||||
void controller.disconnectClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
createTray(
|
|
||||||
controller,
|
|
||||||
() => openSettingsWindow(preloadPath, rendererPath),
|
|
||||||
restartDaemon,
|
|
||||||
() => {
|
|
||||||
logger.info('n8n Gateway quitting');
|
|
||||||
void controller.stop().then(() => {
|
|
||||||
app.quit();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onDisconnect,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-start the daemon on launch
|
|
||||||
controller.start(settingsStore.toGatewayConfig(), confirmConnect);
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
logger.error('Failed to initialize app', { error: String(error) });
|
|
||||||
app.quit();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app
|
||||||
|
.whenReady()
|
||||||
|
.then(() => {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
app.dock?.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL);
|
||||||
|
|
||||||
|
const settingsStore = new SettingsStore();
|
||||||
|
configure({ level: settingsStore.get().logLevel });
|
||||||
|
logger.info('n8n Gateway starting');
|
||||||
|
|
||||||
|
const controller = new DaemonController();
|
||||||
|
|
||||||
|
const preloadPath = path.join(__dirname, 'preload.js');
|
||||||
|
const rendererPath = path.join(__dirname, '..', 'renderer', 'index.html');
|
||||||
|
|
||||||
|
async function connect(payload: ConnectPayload): Promise<void> {
|
||||||
|
const settings = settingsStore.get();
|
||||||
|
assertConnectOriginAllowed(payload.url, settings.allowedOrigins);
|
||||||
|
const config = settingsStore.toGatewayConfig(settings);
|
||||||
|
const token = payload.apiKey?.trim();
|
||||||
|
if (!token || token.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'Missing gateway token in deeplink. Connect from n8n using the computer-use link.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await controller.connect(config, payload.url, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectGateway(): Promise<void> {
|
||||||
|
await controller.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConnectPayload(payload: ConnectPayload): void {
|
||||||
|
logger.info('Handling deep-link connection payload', { url: payload.url });
|
||||||
|
void connect(payload).catch((error: unknown) => {
|
||||||
|
logger.error('Deep-link connection failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
openSettingsWindow(preloadPath, rendererPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerIpcHandlers(controller, settingsStore, disconnectGateway);
|
||||||
|
|
||||||
|
controller.on('statusChanged', (snapshot) => {
|
||||||
|
notifySettingsWindow('statusChanged', snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
createTray(
|
||||||
|
controller,
|
||||||
|
() => openSettingsWindow(preloadPath, rendererPath),
|
||||||
|
() => {
|
||||||
|
logger.info('n8n Gateway quitting');
|
||||||
|
void controller
|
||||||
|
.disconnect()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logger.error('Disconnect failed during quit', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
void disconnectGateway();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const payloadFromArgs = parseConnectPayloadFromArgv(process.argv);
|
||||||
|
if (payloadFromArgs) {
|
||||||
|
handleConnectPayload(payloadFromArgs);
|
||||||
|
} else if (deepLinkProtocolsInArgv(process.argv)) {
|
||||||
|
logger.warn(
|
||||||
|
'Deep link present in argv but payload invalid (e.g. missing token); skipping startup connect',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS — `open-url`: Fires when the OS hands the app a `n8n-computer-use://…` URL (browser, another app,
|
||||||
|
// or “Open” from Finder). Runs for a process that is already running and also after launch when the app
|
||||||
|
// was opened via the protocol; cold starts may still receive the URL in `process.argv` — we parse that
|
||||||
|
// above so both paths are covered.
|
||||||
|
app.on('open-url', (event, url) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = parseConnectPayload(url);
|
||||||
|
if (!payload) return;
|
||||||
|
handleConnectPayload(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Windows / Linux — `second-instance`: Fires on the **first** (lock-holding) process when the user starts
|
||||||
|
// the app again while it is already running. The second process exits immediately (`requestSingleInstanceLock`
|
||||||
|
// failed); this event receives that second 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(
|
export function registerIpcHandlers(
|
||||||
controller: DaemonController,
|
controller: DaemonController,
|
||||||
settingsStore: SettingsStore,
|
settingsStore: SettingsStore,
|
||||||
restartDaemon: () => void,
|
/** Tears down the local gateway connection (Electron app). */
|
||||||
|
disconnectGateway: () => Promise<void>,
|
||||||
): void {
|
): void {
|
||||||
ipcMain.handle('settings:get', (): AppSettings => {
|
ipcMain.handle('settings:get', (): AppSettings => {
|
||||||
logger.debug('IPC settings:get');
|
logger.debug('IPC settings:get');
|
||||||
|
|
@ -24,10 +25,7 @@ export function registerIpcHandlers(
|
||||||
configure({ level: partial.logLevel });
|
configure({ level: partial.logLevel });
|
||||||
logger.info('Log level updated', { level: partial.logLevel });
|
logger.info('Log level updated', { level: partial.logLevel });
|
||||||
}
|
}
|
||||||
const requiresRestart = Object.keys(partial).some((k) => k !== 'logLevel');
|
// Changing tool/capability toggles does not hot-reload an active connection; disconnect and connect again if needed.
|
||||||
if (requiresRestart) {
|
|
||||||
restartDaemon();
|
|
||||||
}
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
@ -42,15 +40,9 @@ export function registerIpcHandlers(
|
||||||
return controller.getSnapshot();
|
return controller.getSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('daemon:start', (): { ok: boolean } => {
|
ipcMain.handle('gateway:disconnect', async (): Promise<{ ok: boolean }> => {
|
||||||
logger.debug('IPC daemon:start');
|
logger.debug('IPC gateway:disconnect');
|
||||||
restartDaemon();
|
await disconnectGateway();
|
||||||
return { ok: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('daemon:stop', async (): Promise<{ ok: boolean }> => {
|
|
||||||
logger.debug('IPC daemon:stop');
|
|
||||||
await controller.stop();
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
import type { StatusSnapshot } from './daemon-controller';
|
import type { AppSettings, StatusSnapshot } from '../shared/types';
|
||||||
import type { AppSettings } from './settings-store';
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
getSettings: async (): Promise<AppSettings> =>
|
getSettings: async (): Promise<AppSettings> =>
|
||||||
|
|
@ -13,11 +12,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
getDaemonStatus: async (): Promise<StatusSnapshot> =>
|
getDaemonStatus: async (): Promise<StatusSnapshot> =>
|
||||||
await (ipcRenderer.invoke('daemon:status') as Promise<StatusSnapshot>),
|
await (ipcRenderer.invoke('daemon:status') as Promise<StatusSnapshot>),
|
||||||
|
|
||||||
startDaemon: async (): Promise<{ ok: boolean }> =>
|
disconnectGateway: async (): Promise<{ ok: boolean }> =>
|
||||||
await (ipcRenderer.invoke('daemon:start') as Promise<{ ok: boolean }>),
|
await (ipcRenderer.invoke('gateway:disconnect') as Promise<{ ok: boolean }>),
|
||||||
|
|
||||||
stopDaemon: async (): Promise<{ ok: boolean }> =>
|
|
||||||
await (ipcRenderer.invoke('daemon:stop') as Promise<{ ok: boolean }>),
|
|
||||||
|
|
||||||
onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void): void => {
|
onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void): void => {
|
||||||
ipcRenderer.on('statusChanged', (_event, snapshot: StatusSnapshot) =>
|
ipcRenderer.on('statusChanged', (_event, snapshot: StatusSnapshot) =>
|
||||||
|
|
|
||||||
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 };
|
export type { AppSettings };
|
||||||
|
|
||||||
const DEFAULTS: AppSettings = {
|
const DEFAULTS: AppSettings = {
|
||||||
port: 7655,
|
allowedOrigins: ['https://*.app.n8n.cloud'],
|
||||||
filesystemDir: os.homedir(),
|
filesystemDir: os.homedir(),
|
||||||
filesystemEnabled: true,
|
filesystemEnabled: true,
|
||||||
shellEnabled: false, // disabled by default for security
|
shellEnabled: false, // disabled by default for security
|
||||||
screenshotEnabled: true,
|
screenshotEnabled: true,
|
||||||
mouseKeyboardEnabled: true,
|
mouseKeyboardEnabled: true,
|
||||||
browserEnabled: true,
|
browserEnabled: true,
|
||||||
allowedOrigins: [],
|
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Full shape of what's persisted — includes internal state not exposed as AppSettings. */
|
|
||||||
interface StoredData extends AppSettings {
|
|
||||||
lastConnectedUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SettingsStore {
|
export class SettingsStore {
|
||||||
private readonly store: Store<StoredData>;
|
private readonly store: Store<AppSettings>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.store = new Store<StoredData>({
|
this.store = new Store<AppSettings>({
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
defaults: { ...DEFAULTS, lastConnectedUrl: null },
|
defaults: DEFAULTS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get(): AppSettings {
|
get(): AppSettings {
|
||||||
return {
|
return {
|
||||||
port: this.store.get('port'),
|
allowedOrigins: this.store.get('allowedOrigins'),
|
||||||
filesystemDir: this.store.get('filesystemDir'),
|
filesystemDir: this.store.get('filesystemDir'),
|
||||||
filesystemEnabled: this.store.get('filesystemEnabled'),
|
filesystemEnabled: this.store.get('filesystemEnabled'),
|
||||||
shellEnabled: this.store.get('shellEnabled'),
|
shellEnabled: this.store.get('shellEnabled'),
|
||||||
screenshotEnabled: this.store.get('screenshotEnabled'),
|
screenshotEnabled: this.store.get('screenshotEnabled'),
|
||||||
mouseKeyboardEnabled: this.store.get('mouseKeyboardEnabled'),
|
mouseKeyboardEnabled: this.store.get('mouseKeyboardEnabled'),
|
||||||
browserEnabled: this.store.get('browserEnabled'),
|
browserEnabled: this.store.get('browserEnabled'),
|
||||||
allowedOrigins: this.store.get('allowedOrigins'),
|
|
||||||
logLevel: this.store.get('logLevel'),
|
logLevel: this.store.get('logLevel'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -54,26 +47,22 @@ export class SettingsStore {
|
||||||
for (const [key, value] of Object.entries(partial) as Array<
|
for (const [key, value] of Object.entries(partial) as Array<
|
||||||
[keyof AppSettings, AppSettings[keyof AppSettings]]
|
[keyof AppSettings, AppSettings[keyof AppSettings]]
|
||||||
>) {
|
>) {
|
||||||
this.store.set(key, value);
|
if (value !== undefined) {
|
||||||
|
this.store.set(key, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.debug('Settings updated', { changes: partial });
|
logger.debug('Settings updated', { changes: partial });
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastConnectedUrl(): string | null {
|
toGatewayConfig(preset?: AppSettings): GatewayConfig {
|
||||||
return this.store.get('lastConnectedUrl');
|
const s = preset ?? this.get();
|
||||||
}
|
const origins =
|
||||||
|
Array.isArray(s.allowedOrigins) && s.allowedOrigins.length > 0
|
||||||
setLastConnectedUrl(url: string | null): void {
|
? s.allowedOrigins
|
||||||
this.store.set('lastConnectedUrl', url);
|
: DEFAULTS.allowedOrigins;
|
||||||
logger.debug('Last connected URL updated', { url });
|
|
||||||
}
|
|
||||||
|
|
||||||
toGatewayConfig(): GatewayConfig {
|
|
||||||
const s = this.get();
|
|
||||||
return {
|
return {
|
||||||
logLevel: s.logLevel,
|
logLevel: s.logLevel,
|
||||||
port: s.port,
|
allowedOrigins: origins,
|
||||||
allowedOrigins: s.allowedOrigins,
|
|
||||||
filesystem: { dir: s.filesystemDir },
|
filesystem: { dir: s.filesystemDir },
|
||||||
computer: { shell: { timeout: 30_000 } },
|
computer: { shell: { timeout: 30_000 } },
|
||||||
browser: {
|
browser: {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import { BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
|
||||||
let settingsWindow: BrowserWindow | null = null;
|
let settingsWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
function revealSettingsWindow(windowRef: BrowserWindow): void {
|
||||||
|
if (windowRef.isMinimized()) {
|
||||||
|
windowRef.restore();
|
||||||
|
}
|
||||||
|
windowRef.show();
|
||||||
|
app.focus({ steal: true });
|
||||||
|
windowRef.focus();
|
||||||
|
}
|
||||||
|
|
||||||
export function openSettingsWindow(preloadPath: string, rendererPath: string): void {
|
export function openSettingsWindow(preloadPath: string, rendererPath: string): void {
|
||||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||||
settingsWindow.focus();
|
revealSettingsWindow(settingsWindow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,7 +33,11 @@ export function openSettingsWindow(preloadPath: string, rendererPath: string): v
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
void settingsWindow.loadFile(rendererPath);
|
void settingsWindow.loadFile(rendererPath).then(() => {
|
||||||
|
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||||
|
revealSettingsWindow(settingsWindow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
settingsWindow.on('closed', () => {
|
settingsWindow.on('closed', () => {
|
||||||
settingsWindow = null;
|
settingsWindow = null;
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,24 @@ import type { DaemonController, DaemonStatus, StatusSnapshot } from './daemon-co
|
||||||
|
|
||||||
const STATUS_LABELS: Record<DaemonStatus, string> = {
|
const STATUS_LABELS: Record<DaemonStatus, string> = {
|
||||||
connected: '', // set dynamically with URL
|
connected: '', // set dynamically with URL
|
||||||
waiting: '◌ Waiting for connection',
|
connecting: '○ Connecting...',
|
||||||
starting: '○ Starting...',
|
disconnected: '✕ Disconnected',
|
||||||
disconnected: '✕ Disconnected — reconnecting',
|
error: '✕ Error',
|
||||||
stopped: '■ Stopped',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ICON_NAMES: Record<DaemonStatus, string> = {
|
||||||
|
connected: 'tray-connected',
|
||||||
|
connecting: 'tray-waiting',
|
||||||
|
disconnected: 'tray-disconnected',
|
||||||
|
error: 'tray-disconnected',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconCache = new Map<DaemonStatus, Electron.NativeImage>();
|
||||||
|
|
||||||
function createNativeImage(status: DaemonStatus): Electron.NativeImage {
|
function createNativeImage(status: DaemonStatus): Electron.NativeImage {
|
||||||
const names: Record<DaemonStatus, string> = {
|
|
||||||
connected: 'tray-connected',
|
|
||||||
waiting: 'tray-waiting',
|
|
||||||
starting: 'tray-waiting',
|
|
||||||
disconnected: 'tray-disconnected',
|
|
||||||
stopped: 'tray-stopped',
|
|
||||||
};
|
|
||||||
const assetsDir = path.join(app.getAppPath(), 'assets');
|
const assetsDir = path.join(app.getAppPath(), 'assets');
|
||||||
const path1x = path.join(assetsDir, `${names[status]}.png`);
|
const path1x = path.join(assetsDir, `${ICON_NAMES[status]}.png`);
|
||||||
const path2x = path.join(assetsDir, `${names[status]}@2x.png`);
|
const path2x = path.join(assetsDir, `${ICON_NAMES[status]}@2x.png`);
|
||||||
|
|
||||||
logger.debug('Loading tray icon', { status, path: path1x });
|
logger.debug('Loading tray icon', { status, path: path1x });
|
||||||
|
|
||||||
|
|
@ -47,6 +48,14 @@ function createNativeImage(status: DaemonStatus): Electron.NativeImage {
|
||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTrayIcon(status: DaemonStatus): Electron.NativeImage {
|
||||||
|
const cached = iconCache.get(status);
|
||||||
|
if (cached) return cached;
|
||||||
|
const created = createNativeImage(status);
|
||||||
|
iconCache.set(status, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
function buildStatusLabel(snapshot: StatusSnapshot): string {
|
function buildStatusLabel(snapshot: StatusSnapshot): string {
|
||||||
if (snapshot.status === 'connected' && snapshot.connectedUrl) {
|
if (snapshot.status === 'connected' && snapshot.connectedUrl) {
|
||||||
return `● Connected to ${snapshot.connectedUrl}`;
|
return `● Connected to ${snapshot.connectedUrl}`;
|
||||||
|
|
@ -55,34 +64,21 @@ function buildStatusLabel(snapshot: StatusSnapshot): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMenu(
|
function buildMenu(
|
||||||
controller: DaemonController,
|
|
||||||
snapshot: StatusSnapshot,
|
snapshot: StatusSnapshot,
|
||||||
onSettings: () => void,
|
onSettings: () => void,
|
||||||
onStartDaemon: () => void,
|
|
||||||
onQuit: () => void,
|
onQuit: () => void,
|
||||||
onDisconnect: () => void,
|
onDisconnect: () => void,
|
||||||
): Menu {
|
): Menu {
|
||||||
const running = controller.isRunning();
|
const sessionActive = snapshot.status === 'connected' || snapshot.status === 'connecting';
|
||||||
const connected = snapshot.status === 'connected';
|
|
||||||
return Menu.buildFromTemplate([
|
return Menu.buildFromTemplate([
|
||||||
{
|
{
|
||||||
label: buildStatusLabel(snapshot),
|
label: buildStatusLabel(snapshot),
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
|
||||||
label: running ? 'Stop Daemon' : 'Start Daemon',
|
|
||||||
click: () => {
|
|
||||||
if (running) {
|
|
||||||
void controller.stop();
|
|
||||||
} else {
|
|
||||||
onStartDaemon();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Disconnect',
|
label: 'Disconnect',
|
||||||
visible: connected,
|
visible: sessionActive,
|
||||||
click: onDisconnect,
|
click: onDisconnect,
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
|
@ -101,19 +97,16 @@ function buildMenu(
|
||||||
export function createTray(
|
export function createTray(
|
||||||
controller: DaemonController,
|
controller: DaemonController,
|
||||||
onSettings: () => void,
|
onSettings: () => void,
|
||||||
onStartDaemon: () => void,
|
|
||||||
onQuit: () => void,
|
onQuit: () => void,
|
||||||
onDisconnect: () => void,
|
onDisconnect: () => void,
|
||||||
): Tray {
|
): Tray {
|
||||||
const tray = new Tray(createNativeImage('stopped'));
|
const tray = new Tray(getTrayIcon('disconnected'));
|
||||||
tray.setToolTip('n8n Gateway');
|
tray.setToolTip('n8n Gateway');
|
||||||
|
|
||||||
const update = (snapshot: StatusSnapshot): void => {
|
const update = (snapshot: StatusSnapshot): void => {
|
||||||
logger.debug('Tray updating', { status: snapshot.status, connectedUrl: snapshot.connectedUrl });
|
logger.debug('Tray updating', { status: snapshot.status, connectedUrl: snapshot.connectedUrl });
|
||||||
tray.setImage(createNativeImage(snapshot.status));
|
tray.setImage(getTrayIcon(snapshot.status));
|
||||||
tray.setContextMenu(
|
tray.setContextMenu(buildMenu(snapshot, onSettings, onQuit, onDisconnect));
|
||||||
buildMenu(controller, snapshot, onSettings, onStartDaemon, onQuit, onDisconnect),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
controller.on('statusChanged', update);
|
controller.on('statusChanged', update);
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="header-title">n8n Gateway</span>
|
<span class="header-title">n8n Gateway</span>
|
||||||
<div id="statusBadge" class="status-badge">
|
<div id="statusBadge" class="status-badge">
|
||||||
<span id="statusDot" class="status-dot stopped"></span>
|
<span id="statusDot" class="status-dot disconnected"></span>
|
||||||
<span id="statusText">Stopped</span>
|
<span id="statusText">Disconnected</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="restart-notice" id="restartNotice" style="display: none">
|
<div class="restart-notice" id="restartNotice" style="display: none">
|
||||||
<span class="restart-notice-icon">⚠</span>
|
<span class="restart-notice-icon">⚠</span>
|
||||||
<span>Saving will apply changes. Port and capability changes will restart the daemon.</span>
|
<span>Saving applies changes. If connected, disconnect and connect again for capability changes to apply.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="form" id="settingsForm">
|
<form class="form" id="settingsForm">
|
||||||
|
|
@ -31,28 +31,29 @@
|
||||||
<div class="form-section-title">Connection</div>
|
<div class="form-section-title">Connection</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="form-label" for="port">Port</label>
|
<label class="form-label" for="allowedOrigins">Allowed origins</label>
|
||||||
<input
|
|
||||||
class="form-input"
|
|
||||||
type="number"
|
|
||||||
id="port"
|
|
||||||
name="port"
|
|
||||||
min="1024"
|
|
||||||
max="65535"
|
|
||||||
placeholder="7655"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="form-label">Allowed Origins</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
class="textarea"
|
class="form-input"
|
||||||
id="allowedOrigins"
|
id="allowedOrigins"
|
||||||
name="allowedOrigins"
|
name="allowedOrigins"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="https://my-n8n.com https://another-n8n.example.com"
|
spellcheck="false"
|
||||||
|
placeholder="https://*.app.n8n.cloud http://localhost:5678"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="form-hint">URLs that can connect without confirmation (one per line)</div>
|
<div class="form-hint">
|
||||||
|
One pattern per line or comma-separated. Used to validate instance URLs before connecting (not derived from the URL).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="connection-deeplink-copy form-hint">
|
||||||
|
To connect, use the computer-use / local gateway action in n8n (it opens an
|
||||||
|
<code class="code-inline">n8n-computer-use://</code> link with your instance URL and
|
||||||
|
gateway token). This app does not accept URL or token entry here. See the package
|
||||||
|
README for a manual deeplink example if you need to test the handler.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="inline-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="disconnectBtn">Disconnect</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import type { AppSettings, StatusSnapshot } from '../shared/types';
|
import type { AppSettings, DaemonStatus, LogLevel, StatusSnapshot } from '../shared/types';
|
||||||
|
|
||||||
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -8,19 +6,17 @@ declare global {
|
||||||
getSettings: () => Promise<AppSettings>;
|
getSettings: () => Promise<AppSettings>;
|
||||||
setSettings: (partial: Partial<AppSettings>) => Promise<{ ok: boolean; error?: string }>;
|
setSettings: (partial: Partial<AppSettings>) => Promise<{ ok: boolean; error?: string }>;
|
||||||
getDaemonStatus: () => Promise<StatusSnapshot>;
|
getDaemonStatus: () => Promise<StatusSnapshot>;
|
||||||
startDaemon: () => Promise<{ ok: boolean }>;
|
disconnectGateway: () => Promise<{ ok: boolean }>;
|
||||||
stopDaemon: () => Promise<{ ok: boolean }>;
|
|
||||||
onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void) => void;
|
onStatusChanged: (onChangeCallback: (snapshot: StatusSnapshot) => void) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_TEXT: Record<string, string> = {
|
const STATUS_TEXT: Record<DaemonStatus, string> = {
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
waiting: 'Waiting',
|
connecting: 'Connecting',
|
||||||
starting: 'Starting',
|
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'Disconnected',
|
||||||
stopped: 'Stopped',
|
error: 'Error',
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateStatusBadge(snapshot: StatusSnapshot): void {
|
function updateStatusBadge(snapshot: StatusSnapshot): void {
|
||||||
|
|
@ -29,15 +25,46 @@ function updateStatusBadge(snapshot: StatusSnapshot): void {
|
||||||
if (!dot || !text) return;
|
if (!dot || !text) return;
|
||||||
|
|
||||||
dot.className = `status-dot ${snapshot.status}`;
|
dot.className = `status-dot ${snapshot.status}`;
|
||||||
const label =
|
let label: string;
|
||||||
snapshot.status === 'connected' && snapshot.connectedUrl
|
if (snapshot.status === 'connected' && snapshot.connectedUrl) {
|
||||||
? `Connected to ${snapshot.connectedUrl}`
|
label = `Connected to ${snapshot.connectedUrl}`;
|
||||||
: (STATUS_TEXT[snapshot.status] ?? snapshot.status);
|
} else if (snapshot.status === 'error') {
|
||||||
|
if (snapshot.lastError) {
|
||||||
|
const msg =
|
||||||
|
snapshot.lastError.length > 120
|
||||||
|
? `${snapshot.lastError.slice(0, 117)}...`
|
||||||
|
: snapshot.lastError;
|
||||||
|
label = `${STATUS_TEXT.error} · ${msg}`;
|
||||||
|
} else {
|
||||||
|
label = `${STATUS_TEXT.error} · see logs`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
label = STATUS_TEXT[snapshot.status];
|
||||||
|
}
|
||||||
text.textContent = label;
|
text.textContent = label;
|
||||||
|
const disconnectBtn = document.getElementById('disconnectBtn') as HTMLButtonElement | null;
|
||||||
|
if (disconnectBtn) {
|
||||||
|
const sessionActive = snapshot.status === 'connected' || snapshot.status === 'connecting';
|
||||||
|
disconnectBtn.disabled = !sessionActive;
|
||||||
|
disconnectBtn.style.display = sessionActive ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAllowedOriginsInput(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(/[\n,]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAllowedOriginsForForm(origins: string[]): string {
|
||||||
|
return origins.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function readForm(): Partial<AppSettings> {
|
function readForm(): Partial<AppSettings> {
|
||||||
const port = parseInt((document.getElementById('port') as HTMLInputElement).value, 10);
|
const allowedOriginsRaw = (document.getElementById('allowedOrigins') as HTMLTextAreaElement)
|
||||||
|
.value;
|
||||||
|
const allowedOrigins = parseAllowedOriginsInput(allowedOriginsRaw);
|
||||||
const filesystemDir = (document.getElementById('filesystemDir') as HTMLInputElement).value.trim();
|
const filesystemDir = (document.getElementById('filesystemDir') as HTMLInputElement).value.trim();
|
||||||
const filesystemEnabled = (document.getElementById('filesystemEnabled') as HTMLInputElement)
|
const filesystemEnabled = (document.getElementById('filesystemEnabled') as HTMLInputElement)
|
||||||
.checked;
|
.checked;
|
||||||
|
|
@ -47,28 +74,23 @@ function readForm(): Partial<AppSettings> {
|
||||||
const mouseKeyboardEnabled = (document.getElementById('mouseKeyboardEnabled') as HTMLInputElement)
|
const mouseKeyboardEnabled = (document.getElementById('mouseKeyboardEnabled') as HTMLInputElement)
|
||||||
.checked;
|
.checked;
|
||||||
const browserEnabled = (document.getElementById('browserEnabled') as HTMLInputElement).checked;
|
const browserEnabled = (document.getElementById('browserEnabled') as HTMLInputElement).checked;
|
||||||
const rawOrigins = (document.getElementById('allowedOrigins') as HTMLTextAreaElement).value;
|
|
||||||
const allowedOrigins = rawOrigins
|
|
||||||
.split('\n')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const logLevel = (document.getElementById('logLevel') as HTMLSelectElement).value as LogLevel;
|
const logLevel = (document.getElementById('logLevel') as HTMLSelectElement).value as LogLevel;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(Number.isFinite(port) && port > 0 ? { port } : {}),
|
allowedOrigins,
|
||||||
filesystemDir,
|
filesystemDir,
|
||||||
filesystemEnabled,
|
filesystemEnabled,
|
||||||
shellEnabled,
|
shellEnabled,
|
||||||
screenshotEnabled,
|
screenshotEnabled,
|
||||||
mouseKeyboardEnabled,
|
mouseKeyboardEnabled,
|
||||||
browserEnabled,
|
browserEnabled,
|
||||||
allowedOrigins,
|
|
||||||
logLevel,
|
logLevel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateForm(settings: AppSettings): void {
|
function populateForm(settings: AppSettings): void {
|
||||||
(document.getElementById('port') as HTMLInputElement).value = String(settings.port);
|
(document.getElementById('allowedOrigins') as HTMLTextAreaElement).value =
|
||||||
|
formatAllowedOriginsForForm(settings.allowedOrigins);
|
||||||
(document.getElementById('filesystemDir') as HTMLInputElement).value = settings.filesystemDir;
|
(document.getElementById('filesystemDir') as HTMLInputElement).value = settings.filesystemDir;
|
||||||
(document.getElementById('filesystemEnabled') as HTMLInputElement).checked =
|
(document.getElementById('filesystemEnabled') as HTMLInputElement).checked =
|
||||||
settings.filesystemEnabled;
|
settings.filesystemEnabled;
|
||||||
|
|
@ -78,8 +100,6 @@ function populateForm(settings: AppSettings): void {
|
||||||
(document.getElementById('mouseKeyboardEnabled') as HTMLInputElement).checked =
|
(document.getElementById('mouseKeyboardEnabled') as HTMLInputElement).checked =
|
||||||
settings.mouseKeyboardEnabled;
|
settings.mouseKeyboardEnabled;
|
||||||
(document.getElementById('browserEnabled') as HTMLInputElement).checked = settings.browserEnabled;
|
(document.getElementById('browserEnabled') as HTMLInputElement).checked = settings.browserEnabled;
|
||||||
(document.getElementById('allowedOrigins') as HTMLTextAreaElement).value =
|
|
||||||
settings.allowedOrigins.join('\n');
|
|
||||||
(document.getElementById('logLevel') as HTMLSelectElement).value = settings.logLevel;
|
(document.getElementById('logLevel') as HTMLSelectElement).value = settings.logLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,14 +128,13 @@ function isFormDirty(initial: AppSettings): boolean {
|
||||||
return (
|
return (
|
||||||
JSON.stringify(current) !==
|
JSON.stringify(current) !==
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
port: initial.port,
|
allowedOrigins: initial.allowedOrigins,
|
||||||
filesystemDir: initial.filesystemDir,
|
filesystemDir: initial.filesystemDir,
|
||||||
filesystemEnabled: initial.filesystemEnabled,
|
filesystemEnabled: initial.filesystemEnabled,
|
||||||
shellEnabled: initial.shellEnabled,
|
shellEnabled: initial.shellEnabled,
|
||||||
screenshotEnabled: initial.screenshotEnabled,
|
screenshotEnabled: initial.screenshotEnabled,
|
||||||
mouseKeyboardEnabled: initial.mouseKeyboardEnabled,
|
mouseKeyboardEnabled: initial.mouseKeyboardEnabled,
|
||||||
browserEnabled: initial.browserEnabled,
|
browserEnabled: initial.browserEnabled,
|
||||||
allowedOrigins: initial.allowedOrigins,
|
|
||||||
logLevel: initial.logLevel,
|
logLevel: initial.logLevel,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -133,23 +152,17 @@ async function init(): Promise<void> {
|
||||||
|
|
||||||
let initialSettings = { ...settings };
|
let initialSettings = { ...settings };
|
||||||
|
|
||||||
// Show restart notice and update buttons when form is dirty
|
|
||||||
const form = document.getElementById('settingsForm') as HTMLFormElement;
|
const form = document.getElementById('settingsForm') as HTMLFormElement;
|
||||||
form.addEventListener('change', () => {
|
const updateDirtyState = (): void => {
|
||||||
const dirty = isFormDirty(initialSettings);
|
const dirty = isFormDirty(initialSettings);
|
||||||
setRestartNotice(dirty);
|
setRestartNotice(dirty);
|
||||||
setButtonsState(dirty);
|
setButtonsState(dirty);
|
||||||
});
|
};
|
||||||
form.addEventListener('input', () => {
|
form.addEventListener('change', updateDirtyState);
|
||||||
const dirty = isFormDirty(initialSettings);
|
form.addEventListener('input', updateDirtyState);
|
||||||
setRestartNotice(dirty);
|
|
||||||
setButtonsState(dirty);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Browse button — user can type the path directly (dialog not exposed via preload)
|
document.getElementById('disconnectBtn')?.addEventListener('click', () => {
|
||||||
document.getElementById('browseDirBtn')?.addEventListener('click', () => {
|
void window.electronAPI.disconnectGateway();
|
||||||
// Intentionally left as a no-op: Electron's dialog API is main-process only.
|
|
||||||
// A future improvement could add an IPC channel to open a native folder picker.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply button — save without closing
|
// Apply button — save without closing
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,9 @@
|
||||||
--color-warning: #f59e0b;
|
--color-warning: #f59e0b;
|
||||||
--color-danger: #dc2626;
|
--color-danger: #dc2626;
|
||||||
--color-badge-connected: #22a06b;
|
--color-badge-connected: #22a06b;
|
||||||
--color-badge-waiting: #f59e0b;
|
--color-badge-connecting: #f59e0b;
|
||||||
--color-badge-disconnected: #dc2626;
|
--color-badge-disconnected: #dc2626;
|
||||||
--color-badge-stopped: #999999;
|
--color-badge-error: #dc2626;
|
||||||
--color-badge-starting: #f59e0b;
|
|
||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
/* biome-ignore format: git hooks overwrite formatting to what biome throws an error for */
|
/* biome-ignore format: git hooks overwrite formatting to what biome throws an error for */
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
|
@ -85,17 +84,14 @@ body {
|
||||||
.status-dot.connected {
|
.status-dot.connected {
|
||||||
background: var(--color-badge-connected);
|
background: var(--color-badge-connected);
|
||||||
}
|
}
|
||||||
.status-dot.waiting {
|
.status-dot.connecting {
|
||||||
background: var(--color-badge-waiting);
|
background: var(--color-badge-connecting);
|
||||||
}
|
|
||||||
.status-dot.starting {
|
|
||||||
background: var(--color-badge-starting);
|
|
||||||
}
|
}
|
||||||
.status-dot.disconnected {
|
.status-dot.disconnected {
|
||||||
background: var(--color-badge-disconnected);
|
background: var(--color-badge-disconnected);
|
||||||
}
|
}
|
||||||
.status-dot.stopped {
|
.status-dot.error {
|
||||||
background: var(--color-badge-stopped);
|
background: var(--color-badge-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warning banner */
|
/* Warning banner */
|
||||||
|
|
@ -209,6 +205,12 @@ body {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-label {
|
.toggle-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
@ -264,30 +266,22 @@ body {
|
||||||
transform: translateX(16px);
|
transform: translateX(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: monospace;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 60px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
.form-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-deeplink-copy {
|
||||||
|
margin-top: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-inline {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
|
export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
|
||||||
|
|
||||||
export type DaemonStatus = 'stopped' | 'starting' | 'waiting' | 'connected' | 'disconnected';
|
export type DaemonStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
port: number;
|
/** User-configured origin patterns for validating instances before connect (never derived from URLs). */
|
||||||
|
allowedOrigins: string[];
|
||||||
filesystemDir: string;
|
filesystemDir: string;
|
||||||
filesystemEnabled: boolean;
|
filesystemEnabled: boolean;
|
||||||
shellEnabled: boolean;
|
shellEnabled: boolean;
|
||||||
screenshotEnabled: boolean;
|
screenshotEnabled: boolean;
|
||||||
mouseKeyboardEnabled: boolean;
|
mouseKeyboardEnabled: boolean;
|
||||||
browserEnabled: boolean;
|
browserEnabled: boolean;
|
||||||
allowedOrigins: string[];
|
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusSnapshot {
|
export interface StatusSnapshot {
|
||||||
status: DaemonStatus;
|
status: DaemonStatus;
|
||||||
connectedUrl: string | null;
|
connectedUrl: string | null;
|
||||||
connectedAt: string | null;
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectPayload {
|
||||||
|
url: string;
|
||||||
|
apiKey?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user