Merge branch 'develop' into feat/custom-lint-rules

This commit is contained in:
Fares Osman
2026-05-20 11:28:30 -04:00
186 changed files with 4200 additions and 1200 deletions

View File

@@ -15,6 +15,15 @@
- **No unsolicited formatting.** Rely on ESLint/Prettier. Do not reformat existing code.
- **Strict scoping.** Only modify code directly related to the prompt. Do not refactor adjacent code unless asked.
## Command Output
Prefer quiet command variants to minimise output volume:
- `git log --oneline -20` not `git log`
- `git diff --stat` not `git diff`
- `npm test --silent` not `npm test`
- `tsc --noEmit 2>&1 | head -50` for type-check failures
- Use the `Read` tool with `limit` rather than `cat` on large files
- Use `Grep` with `head_limit` rather than unrestricted searches
## Validation Commands
Run from repo root before considering work complete:
@@ -72,3 +81,47 @@ Organization
## Sensitive Data
- **Vault system (AES-GCM):** For environment secrets (`EnvironmentKvPairDataType.SECRET`).
- **Electron safeStorage:** Platform-native encryption (`window.main.secretStorage`).
## cx — Semantic Code Navigation
Prefer cx over reading files. Escalate: overview → symbols → definition/references → Read tool.
### Quick reference
```
cx overview PATH file or directory table of contents
cx overview DIR --full directory overview with signatures
cx symbols [--kind K] [--name GLOB] [--file PATH] search symbols project-wide
cx symbols --kinds [--file PATH] list distinct kinds with counts
cx definition --name NAME [--from PATH] [--kind K] get a function/type body
cx references --name NAME [--file PATH] [--unique] find all usages (--unique: one per caller)
cx lang list show supported languages
cx lang add LANG [LANG...] install language grammars
```
Aliases: `cx o`, `cx s`, `cx d`, `cx r`
Kinds: fn, struct, enum, trait, type, const, class, interface, module, event
### Key patterns
- Start with `cx overview .`, drill into subdirectories — cheaper than ls + reading files
- `cx definition --name X` gives exact text for Edit tool's `old_string` without reading the whole file
- `cx references --name X --unique` shows one row per caller — use before refactoring to check blast radius
- After context compression, use `cx overview` / `cx definition` to re-orient — don't re-read full files
- Check signatures for `pub`/`export` to identify public API without reading the file
### Pagination
Default limits: definition 3, symbols 100, references 50. When truncated, stderr shows:
```
cx: 3/32 definitions for "X" | --from PATH to narrow | --offset 3 for more | --all
```
`--offset N` pages forward, `--all` bypasses, `--limit N` overrides. Narrowing with `--from`/`--file`/`--kind` is usually better than paging.
JSON: paginated → `{total, offset, limit, results: [...]}`, non-paginated → bare array.
### Missing grammars
If cx reports a missing grammar, install with `cx lang add <lang>`. Run `cx lang list` to see what's installed.

14
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"workspaces": [
"packages/insomnia-testing",
"packages/insomnia",
"packages/insomnia-analytics",
"packages/insomnia-api",
"packages/insomnia-inso",
"packages/insomnia-smoke-test",
@@ -18409,6 +18410,10 @@
"resolved": "packages/insomnia",
"link": true
},
"node_modules/insomnia-analytics": {
"resolved": "packages/insomnia-analytics",
"link": true
},
"node_modules/insomnia-api": {
"resolved": "packages/insomnia-api",
"link": true
@@ -29183,7 +29188,6 @@
"@rjsf/utils": "6.0.0-beta.15",
"@rjsf/validator-ajv8": "6.0.0-beta.15",
"@seald-io/nedb": "^4.1.1",
"@segment/analytics-node": "2.2.1",
"@sentry/electron": "^6.5.0",
"@stoplight/spectral-core": "^1.22.0",
"@stoplight/spectral-formats": "^1.8.2",
@@ -29338,6 +29342,13 @@
"@kong/insomnia-plugin-external-vault": "0.1.4-dev.20251224090833"
}
},
"packages/insomnia-analytics": {
"version": "12.5.1-alpha.0",
"license": "Apache-2.0",
"dependencies": {
"@segment/analytics-node": "2.2.1"
}
},
"packages/insomnia-api": {
"version": "12.5.1-alpha.0",
"license": "Apache-2.0",
@@ -29350,7 +29361,6 @@
"license": "Apache-2.0",
"dependencies": {
"@seald-io/nedb": "^4.1.1",
"@segment/analytics-node": "^2.2.1",
"@stoplight/spectral-core": "^1.22.0",
"@stoplight/spectral-formats": "^1.8.2",
"@stoplight/spectral-ruleset-bundler": "1.7.0",

View File

@@ -17,6 +17,7 @@
"workspaces": [
"packages/insomnia-testing",
"packages/insomnia",
"packages/insomnia-analytics",
"packages/insomnia-api",
"packages/insomnia-inso",
"packages/insomnia-smoke-test",

View File

@@ -0,0 +1,36 @@
{
"private": true,
"name": "insomnia-analytics",
"license": "Apache-2.0",
"version": "12.5.1-alpha.0",
"author": "Kong <office@konghq.com>",
"description": "Shared analytics client for the Insomnia desktop app and CLI",
"repository": {
"type": "git",
"url": "git+https://github.com/Kong/insomnia.git",
"directory": "packages/insomnia-analytics"
},
"bugs": {
"url": "https://github.com/kong/insomnia/issues"
},
"homepage": "https://github.com/Kong/insomnia#readme",
"sideEffects": false,
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./events": {
"import": "./src/events.ts",
"types": "./src/events.ts"
}
},
"scripts": {
"lint": "eslint . --ext .ts --cache",
"type-check": "tsc --noEmit --project tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"@segment/analytics-node": "2.2.1"
}
}

View File

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockTrack = vi.fn();
const mockPage = vi.fn();
const mockCloseAndFlush = vi.fn().mockResolvedValue(void 0);
vi.mock('@segment/analytics-node', () => ({
Analytics: vi.fn(() => ({
track: mockTrack,
page: mockPage,
closeAndFlush: mockCloseAndFlush,
})),
}));
describe('InsomniaAnalytics', () => {
beforeEach(() => {
vi.resetModules();
mockTrack.mockReset();
mockPage.mockReset();
mockCloseAndFlush.mockReset().mockResolvedValue(void 0);
});
it('forwards track calls with platform-tagged properties and an app+os context', async () => {
const { InsomniaAnalytics } = await import('./analytics');
const analytics = new InsomniaAnalytics({
writeKey: 'test',
app: { appName: 'inso', appVersion: '1.2.3', osVersion: '14.0', platform: 'cli' },
});
analytics.track({ event: 'inso_run_test', properties: { suite: 'echo' }, anonymousId: 'anon-1' });
expect(mockTrack).toHaveBeenCalledTimes(1);
const [payload] = mockTrack.mock.calls[0];
expect(payload).toMatchObject({
event: 'inso_run_test',
anonymousId: 'anon-1',
userId: '',
properties: { suite: 'echo', platform: 'cli' },
context: { app: { name: 'inso', version: '1.2.3' }, os: { version: '14.0' } },
});
});
it('resolves osVersion lazily when provided as a function', async () => {
const { InsomniaAnalytics } = await import('./analytics');
let v = '1.0';
const analytics = new InsomniaAnalytics({
writeKey: 'test',
app: { appName: 'app', appVersion: '0.1', osVersion: () => v, platform: 'app' },
});
v = '2.0';
analytics.track({ event: 'App Started', anonymousId: 'a', userId: 'u' });
const [payload] = mockTrack.mock.calls[0];
expect(payload.context.os.version).toBe('2.0');
});
it('routes analytics errors through the onError handler', async () => {
const onError = vi.fn();
const { InsomniaAnalytics } = await import('./analytics');
const analytics = new InsomniaAnalytics({
writeKey: 'test',
app: { appName: 'app', appVersion: '0.1', osVersion: '1.0', platform: 'app' },
onError,
});
analytics.track({ event: 'App Started', anonymousId: 'a' });
const [, callback] = mockTrack.mock.calls[0];
callback(new Error('segment failure'));
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});
});

View File

@@ -0,0 +1,103 @@
import { Analytics, type AnalyticsSettings } from '@segment/analytics-node';
export interface AppContext {
appName: string;
appVersion: string;
osVersion: string | (() => string);
platform: 'app' | 'cli';
}
export interface TrackOptions {
event: string;
properties?: Record<string, unknown>;
anonymousId: string;
userId?: string;
}
export interface PageOptions {
name: string;
anonymousId: string;
userId?: string;
}
export interface InsomniaAnalyticsOptions {
writeKey: string;
app: AppContext;
settings?: Omit<AnalyticsSettings, 'writeKey'>;
onError?: (error: unknown) => void;
}
export function getNormalizedOsName(platform: NodeJS.Platform | string): string {
switch (platform) {
case 'darwin': {
return 'mac';
}
case 'win32': {
return 'windows';
}
default: {
return platform;
}
}
}
export class InsomniaAnalytics {
private readonly client: Analytics;
private readonly app: AppContext;
private readonly onError: (error: unknown) => void;
constructor({ writeKey, app, settings, onError }: InsomniaAnalyticsOptions) {
this.client = new Analytics({ writeKey, ...settings });
this.app = app;
this.onError = onError ?? (() => {});
}
track({ event, properties, anonymousId, userId }: TrackOptions): void {
this.client.track(
{
event,
anonymousId,
userId: userId ?? '',
properties: { ...properties, platform: this.app.platform },
context: this.buildContext(),
},
error => {
if (error) {
this.onError(error);
}
},
);
}
page({ name, anonymousId, userId }: PageOptions): void {
this.client.page(
{
name,
anonymousId,
userId: userId ?? '',
context: this.buildContext(),
},
error => {
if (error) {
this.onError(error);
}
},
);
}
async closeAndFlush(timeoutMs = 5000): Promise<void> {
try {
await this.client.closeAndFlush({ timeout: timeoutMs });
} catch (error) {
this.onError(error);
}
}
private buildContext() {
const osVersion = typeof this.app.osVersion === 'function' ? this.app.osVersion() : this.app.osVersion;
return {
app: { name: this.app.appName, version: this.app.appVersion },
os: { name: getNormalizedOsName(process.platform), version: osVersion },
};
}
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { AnalyticsEvent, InsoEvent } from './events';
describe('events', () => {
it('AnalyticsEvent has expected entries', () => {
expect(AnalyticsEvent.appStarted).toBe('App Started');
expect(AnalyticsEvent.unitTestRun).toBe('Ran Individual Unit Test');
expect(AnalyticsEvent.installPlugin).toBe('Plugin Installed');
});
it('InsoEvent prefixes with inso_', () => {
expect(InsoEvent.runTest).toBe('inso_run_test');
expect(InsoEvent.script).toBe('inso_script');
});
});

View File

@@ -0,0 +1,142 @@
export enum AnalyticsEvent {
appStarted = 'App Started',
analyticsDisabled = 'Analytics Disabled',
collectionCreate = 'Collection Created',
dataExport = 'Data Exported',
exportCompleted = 'Export Completed',
dataImport = 'Data Imported',
importStarted = 'Import Started',
importScanned = 'Import Scanned',
importCompleted = 'Import Completed',
importLoginRequired = 'Import Login Required',
importResumedAfterLogin = 'Import Resumed After Login',
importedRequestFirstSend = 'Imported Request First Send',
documentCreate = 'Document Created',
mockCreateModalOpened = 'Mock Server Create Modal Opened',
mockCreate = 'Mock Created',
mockEdit = 'Mock Server Edited',
mockDelete = 'Mock Server Deleted',
mockRouteCreate = 'Mock Route Created',
mockRouteEdit = 'Mock Route Edited',
mockRouteDelete = 'Mock Route Deleted',
generateCollection = 'Generated Collection',
generateCollectionFromMock = 'Generate Collection From Mock',
environmentCreate = 'Environment Created',
loginSuccess = 'Login Success',
inviteTrigger = 'Invite Triggered From App',
exportAllCollections = 'Exported All Collections',
kongConnected = 'Kong Connected',
kongSync = 'Kong Synced',
requestBodyTypeSelect = 'Request Body Type Selected',
requestCreated = 'Request Created',
requestExecuted = 'Request Executed',
requestEdit = 'Request Edited',
requestDeleted = 'Request Deleted',
requestRenamed = 'Request Renamed',
requestUrlCopied = 'Request URL Copied',
collectionRunExecute = 'Collection Run Executed',
projectLocalCreate = 'Local Project Created',
projectLocalDelete = 'Local Project Deleted',
selectScratchpad = 'Scratchpad Selected at Login',
syncConflictResolutionStart = 'Sync Conflict Resolution Started',
syncConflictResolutionCompleteMine = 'Sync Conflict Resolution Completed Mine',
syncConflictResolutionCompleteTheirs = 'Sync Conflict Resolution Completed Theirs',
testSuiteCreate = 'Test Suite Created',
testSuiteDelete = 'Test Suite Deleted',
unitTestCreate = 'Unit Test Created',
unitTestDelete = 'Unit Test Deleted',
unitTestRun = 'Ran Individual Unit Test',
unitTestRunAll = 'Ran All Unit Tests',
vcsSyncStart = 'VCS Sync Started',
vcsSyncComplete = 'VCS Sync Completed',
vcsAction = 'VCS Action Executed',
gitAuthenticationCompleted = 'Git Authentication Completed',
gitAuthenticationUpdated = 'Git Authentication Updated',
buttonClick = 'Button Clicked',
inviteMember = 'Invite Sent',
inviteResent = 'Invite Resent',
inviteRevoked = 'Invite Revoked',
projectCreated = 'Project Created',
projectUpdated = 'Project Updated',
exportStarted = 'Export Started',
exportRequestsChosen = 'Export Requests Chosen',
recommendCommitsGenerated = 'Recommend Commits Generated',
recommendCommitsSaved = 'Recommend Commits Saved',
recommendCommitsCancelled = 'Recommend Commits Cancelled',
recommendCommitsClicked = 'Recommend Commits Clicked',
mcpClientWorkspaceCreate = 'MCP Client Workspace Created',
mcpClientAdded = 'MCP Client Added',
mcpClientConnected = 'MCP Client Connected',
mcpClientDisconnected = 'MCP Client Disconnected',
mcpToolCalled = 'MCP Tool Called',
mcpResourceRead = 'MCP Resource Read',
mcpPromptCalled = 'MCP Prompt Called',
inviteNotPermitted = 'Invite Not Permitted',
responseToMockClicked = 'Response To Mock Clicked',
gitSyncButtonClicked = 'Git Sync Button Clicked',
preferencesViewed = 'Preferences Viewed',
copyAsCurl = 'Copied As cURL',
themeChanged = 'Theme Changed',
generateCodeClicked = 'Generate Code Clicked',
generateCodeLanguageChanged = 'Generate Code Language Changed',
filterCreatedHomePage = 'Filter Created From Home Page',
filterCreatedProjects = 'Filter Created Projects',
filterCreatedRequests = 'Filter Created Requests',
filterCreatedResponseBody = 'Filter Created Response Body',
aiFeatureEnabled = 'AI Feature Enabled',
aiFeatureDisabled = 'AI Feature Disabled',
installPlugin = 'Plugin Installed',
homepageFiltered = 'homepage-filtered',
quickSearchOpenedByKeyboard = 'quick-search-opened-by-keyboard',
quickSearchOpenedByMouse = 'quick-search-opened-by-mouse',
statusbarLeftbarToggled = 'statusbar-leftbar-toggled',
statusbarTopbarToggled = 'statusbar-topbar-toggled',
statusbarOrphanedProjectsClicked = 'statusbar-orphaned-projects-clicked',
designerGenerateMockClicked = 'designer-generate-mock-clicked',
designerPreviewToggled = 'designer-preview-toggled',
requestEnvironmentClicked = 'request-environment-clicked',
requestAddCookiesClicked = 'request-add-cookies-clicked',
requestAddCertificatesClicked = 'request-add-certificates-clicked',
requestListSortClicked = 'request-list-sort-clicked',
requestListExpandCollapseClicked = 'request-list-expand-collapse-clicked',
requestParamsDescriptionToggled = 'request-params-description-toggled',
requestParamsImportFromURLClicked = 'request-params-import-from-URL-clicked',
requestParamsBulkEditToggled = 'request-params-bulk-edit-toggled',
responsePreviewJSONPathEntered = 'response-preview-jsonpath-entered',
requestBodyBeautifyClicked = 'request-body-beautify-clicked',
requestHeadersDescriptionToggled = 'request-headers-description-toggled',
requestHeadersBulkEditToggled = 'request-headers-bulk-edit-toggled',
requestScriptsPreScriptSnippetAdded = 'request-scripts-prescript-snippet-added',
requestScriptsPostScriptSnippetAdded = 'request-scripts-postscript-snippet-added',
responseHeadersCopyAllClicked = 'response-headers-copy-all-clicked',
responseCookiesManageCookiesClicked = 'response-cookies-manage-cookies-clicked',
requestOpenInNewTabClicked = 'request-open-in-new-tab-clicked',
requestListMenuPinClicked = 'request-list-menu-pin-clicked',
requestListMenuDuplicateClicked = 'request-list-menu-duplicate-clicked',
requestListMenuRenameClicked = 'request-list-menu-rename-clicked',
requestListMenuSettingsClicked = 'request-list-menu-settings-clicked',
requestSendMenuGenerateCodeClicked = 'request-send-menu-generate-code-clicked',
requestSendMenuSendAfterDelayClicked = 'request-send-menu-send-after-delay-clicked',
requestSendMenuRepeatAfterIntervalClicked = 'request-send-menu-repeat-after-interval-clicked',
requestSendMenuDownloadAfterSendClicked = 'request-send-menu-download-after-send-clicked',
requestSendMenuSendAndDownloadClicked = 'request-send-menu-send-and-download-clicked',
mcpListExpandCollapseClicked = 'mcp-list-expand-collapse-clicked',
mcpListFiltered = 'mcp-list-filtered',
mcpRequestParamsBeautifyClicked = 'mcp-request-params-beautify-clicked',
mcpRequestHeadersDescriptionToggled = 'mcp-request-headers-description-toggled',
mcpRequestRootsNotifyClicked = 'mcp-request-roots-notify-clicked',
mcpResponseHeadersCopyAllClicked = 'mcp-response-headers-copy-all-clicked',
kongKonnectPatValidated = 'kong-konnect-pat-validated',
kongKonnectSyncCompleted = 'kong-konnect-sync-completed',
emptyStateSendRequestClicked = 'empty-state-send-request-clicked',
emptyStateCreateDocumentClicked = 'empty-state-create-document-clicked',
}
export enum InsoEvent {
runTest = 'inso_run_test',
runCollection = 'inso_run_collection',
lintSpec = 'inso_lint_spec',
exportSpec = 'inso_export_spec',
script = 'inso_script',
}

View File

@@ -0,0 +1,2 @@
export * from './events';
export * from './analytics';

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2020",
"allowJs": false,
"resolveJsonModule": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"module": "ESNext",
"sourceMap": true,
"baseUrl": ".",
"rootDirs": ["."],
"lib": ["ES2023", "DOM"],
"types": [],
"strict": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true
}
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});

View File

@@ -51,7 +51,6 @@
"shellwords": "^1.0.1"
},
"dependencies": {
"@segment/analytics-node": "^2.2.1",
"@seald-io/nedb": "^4.1.1",
"@stoplight/spectral-core": "^1.22.0",
"@stoplight/spectral-formats": "^1.8.2",

View File

@@ -1,7 +1,7 @@
import os from 'node:os';
import { Analytics } from '@segment/analytics-node';
import { getSegmentWriteKey } from 'insomnia/src/common/constants';
import { InsoEvent, InsomniaAnalytics } from 'insomnia-analytics';
import { v4 as uuidv4 } from 'uuid';
import type { Settings } from '~/insomnia-data';
@@ -10,15 +10,18 @@ import packageJson from '../package.json';
import neDbAdapter from './db/adapters/ne-db-adapter';
import { getAppDataDir, getDefaultProductName } from './util';
export enum InsoEvent {
runTest = 'inso_run_test',
runCollection = 'inso_run_collection',
lintSpec = 'inso_lint_spec',
exportSpec = 'inso_export_spec',
script = 'inso_script',
}
export { InsoEvent };
const analytics = new InsomniaAnalytics({
writeKey: getSegmentWriteKey(),
app: {
appName: 'inso',
appVersion: process.env.VERSION || packageJson.version,
osVersion: () => os.release(),
platform: 'cli',
},
});
const analyticsClient = new Analytics({ writeKey: getSegmentWriteKey() });
let deviceId: string | null = null;
let localSettings: Settings | null = null;
@@ -54,25 +57,16 @@ const getDeviceId = async (): Promise<string> => {
return deviceId;
};
const getOsName = (): string => {
switch (process.platform) {
case 'darwin': {
return 'mac';
}
case 'win32': {
return 'windows';
}
default: {
return process.platform;
}
}
};
export const trackInsoEvent = async (event: InsoEvent, properties?: Record<string, unknown>): Promise<void> => {
if (process.env.NODE_ENV === 'test') {
return;
}
// new for v13 - provide a way to disable analytics
if (process.env.INSO_TELEMETRY_DISABLED) {
return;
}
const settings = await getLocalSettings();
if (settings && !settings.enableAnalytics) {
return;
@@ -80,40 +74,10 @@ export const trackInsoEvent = async (event: InsoEvent, properties?: Record<strin
try {
const anonymousId = await getDeviceId();
const version = process.env.VERSION || packageJson.version;
analyticsClient.track(
{
event,
anonymousId,
properties: {
...properties,
platform: 'cli',
},
context: {
app: {
name: 'inso',
version,
},
os: {
name: getOsName(),
version: os.release(),
},
},
},
() => {
// Silently fail
},
);
} catch {
// Silently fail
}
analytics.track({ event, anonymousId, properties });
} catch {}
};
export const flushAnalytics = async (): Promise<void> => {
try {
await analyticsClient.closeAndFlush({ timeout: 5000 });
} catch {
// Silently fail
}
await analytics.closeAndFlush(5000);
};

View File

Binary file not shown.

View File

@@ -58,7 +58,7 @@ const config: PlaywrightTestConfig = {
sources: true,
},
},
reporter: process.env.CI ? [['github'], ['line']] : [['list']],
reporter: process.env.CI ? [['github'], ['line']] : [['dot']],
timeout: process.env.CI || isWindows ? 60 * 1000 : 20 * 1000,
forbidOnly: !!process.env.CI,
outputDir: 'traces',

View File

@@ -153,6 +153,8 @@ export const test = baseTest.extend<{
await electronApp.close();
},
page: async ({ app }, use) => {
// The plugin window is created after the main window's did-finish-load, so
// firstWindow() always returns the main app window.
const page = await app.firstWindow({ timeout: 60_000 });
await page.waitForLoadState();

View File

@@ -5,8 +5,9 @@ import { test } from '../../playwright/test';
test('can send requests', async ({ page, insomnia }) => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
const statusTag = page.locator('[data-testid="response-status-tag"]:visible');
const statusTag = page.getByTestId('response-pane').getByTestId('response-status-tag');
const responseBody = page.getByTestId('response-pane');
const responsePreviewBody = page.locator('[data-testid="response-pane"] >> [data-testid="CodeEditor"]:visible');
await insomnia.projectPage.importFixture('smoke-test-collection.yaml');
@@ -33,10 +34,10 @@ test('can send requests', async ({ page, insomnia }) => {
.toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect.soft(statusTag).toContainText('200 OK');
await expect.soft(responseBody).toContainText('"id": "1"');
await expect.soft(responsePreviewBody).toContainText('"id": "1"');
await page.getByRole('button', { name: 'Preview' }).click();
await page.getByRole('menuitem', { name: 'Raw Data' }).click();
await expect.soft(responseBody).toContainText('{"id":"1"}');
await expect.soft(responsePreviewBody).toContainText('{"id":"1"}');
await page
.getByLabel('Request Collection')
@@ -47,7 +48,7 @@ test('can send requests', async ({ page, insomnia }) => {
.toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Connect' }).click();
await expect.soft(statusTag).toContainText('200 OK');
await page.getByRole('tab', { name: 'Console' }).click();
await page.getByTestId('response-pane').getByRole('tab', { name: 'Console' }).click();
await expect.soft(responseBody).toContainText('Connected to 127.0.0.1');
await page.getByTestId('request-pane').getByRole('button', { name: 'Disconnect' }).click();
@@ -64,7 +65,7 @@ test('can send requests', async ({ page, insomnia }) => {
await expect.soft(statusTag).toContainText('200 OK');
await page.getByRole('button', { name: 'Preview' }).click();
await page.getByRole('menuitem', { name: 'Raw Data' }).click();
await expect.soft(responseBody).toContainText('a,b,c');
await expect.soft(responsePreviewBody).toContainText('a,b,c');
await page
.getByLabel('Request Collection')
@@ -77,8 +78,8 @@ test('can send requests', async ({ page, insomnia }) => {
.toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect.soft(statusTag).toContainText('200 OK');
await expect.soft(responseBody).toContainText('xml version="1.0"');
await expect.soft(responseBody).toContainText('<LoginResult>');
await expect.soft(responsePreviewBody).toContainText('xml version="1.0"');
await expect.soft(responsePreviewBody).toContainText('<LoginResult>');
await page
.getByLabel('Request Collection')
@@ -91,13 +92,37 @@ test('can send requests', async ({ page, insomnia }) => {
.toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect.soft(statusTag).toContainText('200 OK');
await page.getByRole('tab', { name: 'Console' }).click();
const pdfIframe = page.getByTestId('ResponsePDFView');
await expect.soft(pdfIframe).toBeVisible();
await expect.soft(pdfIframe).toHaveAttribute('src', /^blob:/);
// find Electron/Chromium's built-in PDF viewer extension
await expect
.poll(() => page.frames().some(f => f.url().startsWith('chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai')), {
timeout: 5000,
message: 'Expected Chromium built-in PDF viewer extension frame to mount inside the PDF preview iframe',
})
.toBe(true);
await expect.soft(pdfIframe).toHaveScreenshot('dummy-pdf-preview.png', {
animations: 'disabled',
maxDiffPixelRatio: 0.15, // 15% discrepancy allowed for CI/environment differences
timeout: 5000,
});
await page.getByTestId('response-pane').getByRole('tab', { name: 'Console' }).click();
await page.locator('pre').filter({ hasText: '< Content-Type: application/pdf' }).click();
await page.getByTestId('response-pane').getByRole('tab', { name: 'Preview' }).click();
await page.getByLabel('Request Collection').getByTestId('sends request with basic authentication').press('Enter');
await expect
.soft(page.getByTestId('request-pane').getByTestId('OneLineEditor').getByText('http://127.0.0.1:4010/auth/basic'))
.toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect.soft(statusTag).toContainText('200 OK');
await expect.soft(responseBody).toContainText('basic auth received');
await page.getByTestId('response-pane').getByRole('tab', { name: 'Preview' }).click();
await expect.soft(responsePreviewBody).toContainText('basic auth received');
await page
.getByLabel('Request Collection')
@@ -108,7 +133,7 @@ test('can send requests', async ({ page, insomnia }) => {
.toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect.soft(statusTag).toContainText('200 OK');
await page.getByRole('tab', { name: 'Console' }).click();
await page.getByTestId('response-pane').getByRole('tab', { name: 'Console' }).click();
await expect.soft(responseBody).toContainText('Set-Cookie: insomnia-test-cookie=value123');
await page.getByLabel('Request Collection').getByTestId('delayed request').press('Enter');

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,127 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect } from '@playwright/test';
import { loadFixture } from '../../playwright/paths';
import { test } from '../../playwright/test';
const PLUGIN_NAME = 'insomnia-plugin-bridge-test';
const ACTION_LABEL = 'Bridge Test Action';
test('Plugin bridge routes requestAction execution through hidden BrowserWindow', async ({ page, app, dataPath }) => {
// Write a minimal plugin with a requestAction to the data-path plugins directory.
const pluginDir = path.join(dataPath, 'plugins', PLUGIN_NAME);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'package.json'),
// The 'insomnia' key is required — the loader skips packages that lack it.
JSON.stringify({ name: PLUGIN_NAME, version: '1.0.0', main: 'index.js', insomnia: {} }),
);
fs.writeFileSync(
path.join(pluginDir, 'index.js'),
`module.exports.requestActions = [{ label: '${ACTION_LABEL}', action: async () => {} }];`,
);
// Import a collection so we have a request to target.
const fixture = await loadFixture('simple.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
await page.getByLabel('Import').click();
await page.locator('[data-test-id="import-from-clipboard"]').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
// Reload plugins through the bridge, awaiting completion. This ensures the
// hidden BrowserWindow has started and the test plugin is registered before we
// check the UI. page.evaluate awaits the returned Promise.
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
// Open the request actions dropdown for 'example http'.
// onOpen calls window.main.plugins.getRequestActions() through the bridge.
const requestRow = page.getByLabel('Request Collection').getByRole('row', { name: 'example http' });
await requestRow.click();
await requestRow.getByLabel('Request Actions').click();
// The plugin action must appear in the dropdown, proving end-to-end bridge execution.
await expect.soft(page.getByRole('menuitemradio', { name: ACTION_LABEL })).toBeVisible();
});
test('Plugin bridge surfaces errors from plugins that throw or reject', async ({ page, dataPath }) => {
const pluginName = 'insomnia-plugin-bridge-failure';
const pluginDir = path.join(dataPath, 'plugins', pluginName);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'package.json'),
JSON.stringify({ name: pluginName, version: '1.0.0', main: 'index.js', insomnia: {} }),
);
// Three failure shapes the bridge must normalize: sync throw, async reject with Error, async reject with non-Error.
fs.writeFileSync(
path.join(pluginDir, 'index.js'),
`
module.exports.requestActions = [
{ label: 'Sync Throw', action: () => { throw new Error('sync-boom'); } },
{ label: 'Async Reject Error', action: async () => { throw new Error('async-boom'); } },
{ label: 'Async Reject Non-Error', action: async () => { return Promise.reject('plain-string'); } },
];
`,
);
// Wait until the renderer has settled on the project route — otherwise an
// in-flight navigation destroys the evaluate execution context.
await page.getByLabel('Import').waitFor();
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
const results = await page.evaluate(async () => {
const main = (window as any).main;
const actions = await main.plugins.getRequestActions();
const outcomes: { label: string; ok: boolean; message: string | null }[] = [];
for (const action of actions.filter((a: any) => /Sync Throw|Async Reject/.test(a.label))) {
try {
await main.plugins.executeAction({
type: 'request',
pluginName: action.pluginName,
label: action.label,
projectId: '',
domainData: {},
});
outcomes.push({ label: action.label, ok: true, message: null });
} catch (err: any) {
outcomes.push({ label: action.label, ok: false, message: String(err?.message ?? err) });
}
}
return outcomes;
});
// Every failure shape must surface as a rejection to the renderer — not as a hang and not as a silent ok.
expect.soft(results.find(r => r.label === 'Sync Throw')?.ok).toBe(false);
expect.soft(results.find(r => r.label === 'Async Reject Error')?.ok).toBe(false);
expect.soft(results.find(r => r.label === 'Async Reject Non-Error')?.ok).toBe(false);
const metrics = await page.evaluate(() => (window as any).main.plugins.getBridgeMetrics());
// executeAction must have observed at least the three error outcomes we just produced.
expect.soft(metrics.perMethod.executeAction?.error ?? 0).toBeGreaterThanOrEqual(3);
});
test('Plugin bridge handles concurrent invocations without cross-talk', async ({ page, dataPath }) => {
const pluginName = 'insomnia-plugin-bridge-concurrent';
const pluginDir = path.join(dataPath, 'plugins', pluginName);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'package.json'),
JSON.stringify({ name: pluginName, version: '1.0.0', main: 'index.js', insomnia: {} }),
);
fs.writeFileSync(path.join(pluginDir, 'index.js'), 'module.exports.requestActions = [];');
await page.getByLabel('Import').waitFor();
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
// Fire N concurrent metadata invocations. Each call assigns its own request id,
// and the bridge result handler must route results back to the correct promise.
const completed = await page.evaluate(async () => {
const main = (window as any).main;
const promises = Array.from({ length: 20 }, () => main.plugins.getRequestActions());
const results = await Promise.all(promises);
return results.every(r => Array.isArray(r)) ? results.length : -1;
});
expect.soft(completed).toBe(20);
});

View File

@@ -1,8 +1,18 @@
import { expect } from '@playwright/test';
import { type ElectronApplication, expect } from '@playwright/test';
import { loadFixture } from '../../playwright/paths';
import { test } from '../../playwright/test';
const findWindowByTitle = async (app: ElectronApplication, title: string) => {
for (const window of await app.windows()) {
if ((await window.title().catch(() => '')) === title) {
return window;
}
}
throw new Error(`Window with title "${title}" not found`);
};
test.describe('test hidden window handling', () => {
test('can cancel pre-request script', async ({ app, page }) => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
@@ -64,8 +74,7 @@ test.describe('test hidden window handling', () => {
await page.getByRole('tab', { name: 'Console' }).click();
await page.getByRole('tab', { name: 'Preview' }).click();
const windows = await app.windows();
const hiddenWindow = windows[1];
const hiddenWindow = await findWindowByTitle(app, 'Hidden Browser Window');
hiddenWindow.close();
await page.getByTestId('settings-button').click();

View File

@@ -0,0 +1,922 @@
# Plugin System POC Outline
## Current PR review scope
This branch contains some stacked prerequisite changes alongside the Phase 1a plugin bridge work.
For review, focus primarily on:
- `src/main/plugin-window.ts`
- `src/entry.plugin-window.ts`
- `src/entry.plugin-window-preload.ts`
- `src/entry.preload.ts`
- `src/plugins/*`
- `packages/insomnia-smoke-test/tests/smoke/plugin-bridge.test.ts`
Other changes in the branch are supporting or preparatory work and can be reviewed more lightly in the context of Phase 1a.
## Goal
Design a new plugin system for the Electron app that supports:
- `rendererFunctions` for UI-safe extension points in the renderer
- `mainFunctions` for privileged capabilities that must run in the main process
- a sandbox model that keeps third-party plugins off direct Electron and Node APIs unless explicitly allowed
The migration is split into phases to avoid breaking existing plugin behaviour:
- **Phase 1a:** improve the legacy behaviour test baseline and route all plugin execution through an IPC bridge to a hidden BrowserWindow with `nodeIntegration: true`. No plugin code is moved yet — the renderer still loads plugins, but all invocations cross the bridge. _(current PR)_
- **Phase 1b:** move all plugin code to run exclusively inside the hidden BrowserWindow. Plugin context modules (`plugins/context/`, `plugins/index.ts`) are removed from the main renderer bundle entirely. The renderer becomes a pure client of the bridge.
- **Phase 1c:** disable `nodeIntegration` in the main BrowserWindow. Tackle the remaining renderer-side Node.js dependencies together: direct Electron imports, `fs` operations, `process.env` access, dynamic `require('electron')`, and `node:crypto`/`node:os` usage.
- **Phase 2:** replace the hidden window's `nodeIntegration: true` runtime with a stricter sandbox (`contextIsolation: true`, capability-based permissions). Plugin authors migrate to the new API surface.
## Why now
The app already has:
- plugin discovery and loading in `src/plugins/index.ts`
- preload bridge patterns in `src/entry.preload.ts`
- IPC handler registration in `src/entry.main.ts` and `src/main/ipc/*`
- an ongoing renderer hardening effort in `NODE_INTEGRATION_MIGRATION_PR_PLAN.md`
This makes a capability-based plugin redesign a natural fit for the direction of the architecture, but not for the current runtime shape. Today the plugin system is still heavily renderer-coupled.
## Current state
Today plugins primarily contribute exports like:
- `templateTags`
- `requestHooks`
- `responseHooks`
- `requestActions`
- `requestGroupActions`
- `workspaceActions`
- `documentActions`
There is also an internal-only `unsafePluginMainActions` path for bundled plugins. That proves the app already needs main-process plugin execution, but the current shape is too narrow and too trusted for a general public plugin API.
Just as importantly, current plugin consumption is still renderer-heavy:
- parts of plugin discovery/loading can run in renderer contexts
- themes are queried directly from UI hooks
- action plugins are fetched and executed directly from UI components
- plugin context helpers currently expose renderer-bound APIs like dialogs, clipboard, and prompt flows
This plan is therefore a redesign from the current state, not a small cleanup of an already-main-owned system.
## POC outcome
Define a plugin API and execution model that:
1. keeps plugin lifecycle out of the app UI renderer
2. routes privileged work through preload and IPC
3. allows fine-grained permission checks for `mainFunctions`
4. remains compatible with future `contextIsolation: true`
## Ownership model
### Target state
Plugin discovery, manifest validation, trust checks, and function registration should be owned by the main process.
The app UI renderer should not load plugin packages directly. It should only:
- query which plugin capabilities are available
- invoke approved functions through a narrow bridge
- receive serialized results and metadata
If `rendererFunctions` exist, they should run in a dedicated sandboxed plugin host, not inside the normal app UI runtime.
### Current gap from target state
This is not how the app works today. The current system still allows plugin enumeration and execution in UI code.
#### Phase 1 move
Phase 1 moves the system in three steps.
**Phase 1a** (current PR): adds the bridge and routes execution through it, but plugin code still lives in the renderer bundle:
```text
renderer loads plugins -> renderer calls bridge -> hidden window re-executes via its own copy of plugin code
```
**Phase 1b**: removes plugin code from the renderer bundle entirely so only the hidden window owns it:
```text
hidden plugin window loads plugins -> renderer requests execution via IPC bridge -> hidden window executes and returns result
```
**Phase 1c**: disables `nodeIntegration` in the main window, eliminating residual Node.js usage in the renderer (direct `fs`, `require('electron')`, `process.env`, `node:crypto`, etc.).
Plugin trust level is unchanged across all of Phase 1. The hidden window retains `nodeIntegration: true` throughout.
#### Phase 2 move
Phase 2 then moves to the full target state:
```text
main discovers and registers plugins -> hidden sandboxed window executes via context API -> renderer requests through bridge only
```
## Proposed plugin shape
```ts
export interface InsomniaPlugin {
name: string;
version: string;
rendererFunctions?: RendererFunctionDefinition[];
mainFunctions?: MainFunctionDefinition[];
}
export interface RendererFunctionDefinition<Args = unknown, Result = unknown> {
name: string;
description?: string;
handler: (context: RendererPluginContext, args: Args) => Result | Promise<Result>;
}
export interface MainFunctionDefinition<Args = unknown, Result = unknown> {
name: string;
description?: string;
permissions?: PluginPermission[];
handler: (context: MainPluginContext, args: Args) => Result | Promise<Result>;
}
```
## Execution model
### `rendererFunctions`
- Registered by the main process and executed in a dedicated sandboxed plugin host
- Intended for UI workflows, request shaping, data transforms, and app-level orchestration
- Must not access Electron, Node builtins, or raw IPC directly
- Can call approved bridge APIs exposed through a plugin context
- Must not rely on direct React component state, direct database model mutation, or window-scoped UI helpers
### `mainFunctions`
- Registered in the main process as named plugin capabilities
- Invoked from the renderer through a single preload bridge such as:
```ts
window.plugins.invokeMain(pluginName, functionName, args);
```
- Must pass permission checks before execution
- Return serialized results only
## Recommended routing
### Control plane
```text
plugin package on disk -> main process discovery -> manifest validation -> function registry
```
### Renderer function path
```text
UI -> preload bridge -> IPC -> sandboxed plugin host -> rendererFunction
```
### Main function path
```text
UI -> preload bridge -> IPC -> plugin main registry -> mainFunction
```
This keeps plugin loading and trust decisions out of the UI while still matching the existing preload and IPC pattern in `src/entry.preload.ts`.
## inso CLI and `process.type` guards
Many modules in `src/plugins/` contain branches guarded by `process.type === 'renderer'`. These are **not** general renderer-detection guards — they exist because the inso CLI reuses the same code paths as the Electron renderer but loads plugin implementations directly rather than going through the IPC bridge.
In Electron the check is true and the code reaches IPC-bound paths. In inso (a Node.js process with no Electron renderer) the check is false and the code falls back to direct module imports.
This has two consequences for Phase 1b:
1. **Do not remove these guards.** Stripping them to simplify the hidden window code will break inso silently. The guards must be preserved in any shared module that inso also imports.
2. **The hidden window is itself a renderer (`process.type === 'renderer'` is true).** Any code running there that hits these branches will follow the IPC path — which is correct for the app, but means the guard alone is not a reliable way to distinguish "app renderer" from "hidden plugin window." If Phase 1b needs to distinguish between the two contexts, use a dedicated flag (e.g. a custom `window.__PLUGIN_WINDOW__` set by the hidden window's preload) rather than relying on `process.type`.
## Host decision
The plugin host for `rendererFunctions` is a **dedicated hidden BrowserWindow**.
### Phase 1 configuration
```
nodeIntegration: true
contextIsolation: false
show: false
webPreferences: { backgroundThrottling: false }
```
This is deliberately permissive. It matches the trust level plugins already have today (full renderer access), but moves them out of the app UI window. Existing plugins run unchanged.
### Why hidden BrowserWindow over alternatives for Phase 1
| Option | Phase 1 suitability | Notes |
| -------------------------------------------- | ------------------- | ------------------------------------------------------------------------ |
| Hidden BrowserWindow (nodeIntegration: true) | Best | Full Node/Electron compat, easy IPC, matches current plugin expectations |
| Worker | Poor | No Node builtins, breaks most existing plugins |
| Utility process | Poor | No DOM, breaks renderer-oriented plugin APIs |
| Second full window | Overkill | Hidden window achieves the same isolation with less overhead |
### Phase 2 configuration
Once plugins are fully isolated in the hidden window, Phase 2 tightens the window:
```
nodeIntegration: false
contextIsolation: true
sandbox: true
```
Plugin capabilities are then re-exposed through a controlled preload bridge only.
### Impact on design
- Module loading: Node `require()` in Phase 1; bundled/ESM modules via preload in Phase 2
- Serialization: IPC boundary between hidden window and main process enforces JSON serialization from day one
- UI helpers: dialog/prompt calls in Phase 1 route through IPC to the main renderer; in Phase 2 they become explicit bridge APIs
- Startup cost: hidden window is created eagerly at app startup and kept alive, not spawned per call
## Sandbox model
> Phase 1 does not enforce this model. The hidden window runs with `nodeIntegration: true` and plugins retain full trust. The sandbox model below is the Phase 2 target.
### Default sandbox
Third-party plugins should run with:
- no direct `electron` import
- no direct Node builtin imports
- no access to `ipcRenderer`
- no access to unrestricted `window.main`
- no direct loading by the app UI renderer
Instead, they receive a constrained context object:
```ts
type RendererPluginContext = {
app: {
getInfo(): Promise<AppInfo>;
};
requests: {
getById(id: string): Promise<Request | null>;
};
plugins: {
invokeMain(pluginName: string, functionName: string, args?: unknown): Promise<unknown>;
};
};
```
This context should be intentionally smaller than the current plugin context surface. In particular, renderer-hosted plugins should not assume direct access to:
- prompt and modal helpers
- clipboard helpers
- direct request/workspace model mutation
- unrestricted store or network helpers without bridge review
### Main sandbox
`mainFunctions` should not mean "full trust". They should run behind:
- plugin registration allowlist
- per-function permission metadata
- argument validation
- structured result serialization
- explicit logging for invocation and failure
### Permission examples
```ts
type PluginPermission =
| 'filesystem.read'
| 'filesystem.write'
| 'network.fetch'
| 'shell.openExternal'
| 'secrets.read'
| 'secrets.write';
```
The first POC should likely keep this list small.
## Mutation and command protocol required for legacy action migration
The plan assumes medium-risk legacy action features can migrate onto `rendererFunctions`, but that is only realistic if the new system defines how plugins request side effects.
Today many action plugins effectively rely on direct execution with live model objects and rich helper context. A separate host cannot preserve that model safely.
Before migrating `requestActions`, `requestGroupActions`, `workspaceActions`, or even `documentActions`, the new system needs an explicit protocol for things like:
- request mutations
- workspace mutations
- user-visible commands
- persistence requests
- error and confirmation flows
The likely shape is a DTO / command / patch model, for example:
```ts
type PluginCommand =
| { type: 'update-request'; requestId: string; patch: unknown }
| { type: 'update-workspace'; workspaceId: string; patch: unknown }
| { type: 'show-notification'; level: 'info' | 'warning' | 'error'; message: string };
```
Pass 1 does not need to finalize the full protocol, but it should prove at least one realistic command flow end-to-end.
### Concrete migration example: `documentActions`
One low-risk example for the eventual Phase 2 command model is `documentActions`.
The flow would look like:
1. the UI triggers `window.plugins.invokeRenderer(pluginName, 'documentAction.rename', { documentId })`
2. the hidden plugin host executes the plugin function with a constrained context
3. the plugin returns a structured command such as `{ type: 'update-document', documentId, patch: { name: 'New Name' } }`
4. the host applies the command through the approved bridge and returns success metadata to the caller
This is intentionally narrow, but it demonstrates that action-style plugins can move off direct model mutation without requiring Phase 1 to solve the full mutation protocol.
## How this works with Electron sandboxing
If the app continues toward `contextIsolation: true`, the model becomes:
1. preload exposes a minimal `window.plugins` bridge
2. main owns plugin loading and registration
3. `rendererFunctions` run in a separate isolated plugin host
4. privileged work always crosses the preload boundary
5. main-process plugin handlers remain the only place with privileged Electron access
6. the app UI renderer never imports plugin packages directly
That means the plugin system should be designed so the renderer is a client of the plugin system, not the owner of plugin loading, even if the current app still has `nodeIntegration: true` in places.
## Suggested preload API
```ts
type PluginBridgeAPI = {
invokeMain: (pluginName: string, functionName: string, args?: unknown) => Promise<unknown>;
invokeRenderer: (pluginName: string, functionName: string, args?: unknown) => Promise<unknown>;
listFunctions: () => Promise<
{
pluginName: string;
mainFunctions: string[];
rendererFunctions: string[];
}[]
>;
};
```
This keeps the public renderer surface narrow and auditable.
## Suggested main-process pieces
- `src/plugins/registry.ts`
- normalize plugin exports
- register `rendererFunctions` and `mainFunctions`
- `src/plugins/plugin-host.ts`
- manage the sandboxed host used for `rendererFunctions`
- `src/main/ipc/plugins.ts`
- IPC entry point for plugin invocation
- `src/entry.preload.ts`
- expose `window.plugins`
- `src/global.d.ts`
- type the new preload bridge
## Validation and safety rules
- Function names must be unique per plugin
- Main invocation payloads must be JSON-serializable
- Renderer invocation payloads must be JSON-serializable
- Errors should be normalized before crossing IPC
- Plugin permissions should be visible in settings
- Disabled plugins should not register either renderer or main functions
- The UI renderer must not import or execute plugin packages directly
- The registry must detect mixed legacy/new export shapes and apply explicit coexistence rules
## Design decisions
1. **Should public plugins ever get `mainFunctions`, or should that be opt-in behind a trust prompt?**
Deferred to Phase 2. Phase 1 does not introduce `mainFunctions` for public plugins.
2. **Should `mainFunctions` run in the main process directly, or in a dedicated utility process?**
Main process for now. The utility process option remains open for a later pass if the trust surface warrants it.
3. **Should plugin permissions be granted per plugin, per function, or per capability group?**
Deferred to Phase 2 when the permission model is introduced.
4. **Should bundled first-party plugins keep a separate trusted path?**
No. Bundled plugins are already co-located and implicitly trusted by virtue of being shipped with the app. No separate path is needed.
5. **What is the concrete host for `rendererFunctions`?**
A dedicated hidden BrowserWindow. Phase 1 uses `nodeIntegration: true` to preserve existing behaviour. Phase 2 revisits the configuration to meet sandboxing requirements (see [Host decision](#host-decision)).
6. **What is the minimum viable mutation / command protocol for migrating legacy actions?**
Deferred to Phase 2. In Phase 1, plugins run with `nodeIntegration: true` and can still call back to main via IPC using existing mechanisms, so direct model mutation is preserved. Phase 2 introduces the sandbox that removes direct model access, and at that point a command/patch protocol becomes necessary — plugins will return structured commands (e.g. `{ type: 'update-request', requestId, patch }`) rather than mutating models in place.
## POC phases
### Phase 1a: bridge and baseline (current PR)
**Goal:** establish a legacy behaviour test baseline and route all plugin invocations through an IPC bridge to a hidden BrowserWindow. Plugin code still exists in the renderer bundle — this phase proves the bridge, not the isolation.
#### What changes
- Legacy behaviour baseline tests written for all plugin export types (happy path + error path)
- Hidden BrowserWindow created and managed from main (`nodeIntegration: true`, `show: false`)
- IPC bridge added so all renderer-side plugin invocations cross to the hidden window before executing
- Renderer-side plugin calls redirected through the bridge; the hidden window runs the actual plugin code
#### What does not change
- Plugin code is still bundled with the renderer (duplication, not isolation)
- Plugin export shape (`templateTags`, `requestHooks`, `responseHooks`, etc.) is unchanged
- Plugin authors do not need to update anything
- No permission model enforced
#### Deliverables
1. Legacy behaviour baseline tests green in CI
2. Hidden plugin window created and managed from main
3. IPC bridge routing all renderer plugin invocations to the hidden window
4. Zero behavioural regressions against baseline
5. Bridge observability: per-invocation structured logs (`[plugin-bridge] invoke method=… outcome=… duration_ms=…`), startup timing (`window_ready startup_ms=…`), crash events (`window_crash reason=…`), and a snapshot accessor (`window.main.plugins.getBridgeMetrics()``plugins.getBridgeMetrics` IPC handler) exposing per-method `{ok, error, timeout, avgDurationMs, maxDurationMs}` and window counters
#### What Phase 1a actually proves vs defers
Phase 1a is a transport and hosting proof. Reviewers should read the deliverables narrowly:
**Proven by Phase 1a**
- The IPC bridge can carry every existing plugin capability shape (template tags, request/response hooks, request/group/workspace/document actions, bundled main actions, theme listing) end-to-end with serializable arguments and results
- The hidden BrowserWindow lifecycle (creation deferred until main window loads, ready signalling, reload, teardown) is viable on darwin/win32/linux
- Failure shapes from plugin code (sync throw, async reject with `Error`, async reject with non-`Error`) surface as rejections on the renderer side rather than as hangs or silent successes
- Concurrent invocations are routed back to the correct caller (per-request `id` in `pluginRequests`)
**Not proven, still risky after Phase 1a**
- _Action mutation semantics._ Request/workspace/document actions still mutate models through the renderer-side context object. The bridge serializes inputs and outputs, but no mutation contract is enforced. Side-effect ordering between an action's UI calls (`alert`/`prompt`) and its model writes is unchanged from the legacy runtime — and untested under the new transport.
- _Template tags._ Listing and `runTemplateTagAction` are bridged, but Nunjucks rendering still executes in the existing template worker. Isolation of tag execution is unchanged in 1a.
- _inso CLI compatibility._ inso does not use the bridge. Any divergence between app-side and CLI-side plugin behaviour is unaddressed here and only surfaces in Phase 1b when `process.type` guards are touched.
- _True isolation._ The hidden window runs with `nodeIntegration: true` and `contextIsolation: false`. Plugins are still trusted with full Node access. Sandbox claims belong to Phase 1c (renderer hardening) and Phase 2 (plugin window hardening), not 1a.
- _Final plugin API._ Plugin authors see no API change. The `rendererFunctions`/`mainFunctions`/permission shape from this document is design-only until Phase 2.
- _Crash recovery._ `render-process-gone` increments a counter and rejects in-flight requests, but there is no auto-restart loop. A crashed plugin window will be recreated lazily on the next invocation; held subscriptions and warm caches are lost. Acceptable for 1a but worth validating in production telemetry before relying on it.
#### Phase 1a rollback switch
Phase 1a keeps the legacy renderer plugin execution path available behind a boot-time environment flag:
INSOMNIA_ENABLE_PLUGIN_BRIDGE=false
When unset or set to any other value, plugin calls use the hidden plugin window bridge.
When set to false, window.main.plugins.\* falls back to the legacy in-renderer execution path for the current app session.
This switch is intended as a developer and rollout safety valve during Phase 1a. It is not a user-facing feature and should be removed once the bridge path is fully validated.
### Phase 1b: full plugin isolation in hidden window
**Goal:** remove plugin code from the main renderer bundle entirely. The hidden window is the sole owner of plugin discovery, loading, and execution.
#### What changes
- `src/plugins/index.ts` and all plugin context modules (`plugins/context/`) removed from the renderer bundle
- Renderer has no direct import of plugin packages; it communicates only through the IPC bridge
- Plugin context modules that have `process.type === 'renderer'` guards must be audited carefully — see [inso CLI and `process.type` guards](#inso-cli-and-processtype-guards). Guards must be preserved for inso compatibility; any disambiguation between "app renderer" and "hidden plugin window" should use a dedicated flag, not `process.type`
#### What does not change
- Hidden window still runs with `nodeIntegration: true`
- Plugin export shape and author-visible behaviour unchanged
- inso CLI plugin paths unchanged
#### Deliverables
1. Renderer bundle contains no plugin module imports
2. All baseline tests still pass
3. inso CLI smoke-tested to confirm no regressions from `process.type` guard changes
### Phase 1c: disable `nodeIntegration` in the main window
**Goal:** harden the main BrowserWindow by removing its reliance on Node.js integration. This requires eliminating residual Node.js API usage in the renderer.
#### What changes (grouped by effort)
| Area | Files | Fix |
| ------------------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Direct `import electron` in renderer | `routes/auth.clear-vault-key.tsx` | Replace `ipcRenderer.emit` with `window.main` equivalent |
| `process.env` in renderer | `common/constants.ts`, `settings/plugins.tsx` | Expose `INSOMNIA_DATA_PATH` and `PORTABLE_EXECUTABLE_DIR` via preload |
| `fs` in response/network/scripts | `models/helpers/response-operations.ts`, `script-executor.ts`, `network/grpc/write-proto-file.ts` | New IPC handlers in `src/main/ipc/`, exposed via preload |
| Dynamic `require('electron')` | `network/network.ts` | Replace with static imports or `window.main` |
| `node:crypto` / `node:os` | `sync/delta/diff.ts`, `sync/git/providers/gitlab.ts`, `templating/base-extension.ts` | Replace with Web Crypto API (`globalThis.crypto.subtle`) where possible; IPC bridge for remainder |
These changes should land together in one PR where practical, since they all share the same prerequisite (Phase 1b complete) and the same goal (nodeIntegration: false on the main window).
#### What does not change
- Hidden window retains `nodeIntegration: true` — Phase 2 tightens that
- Plugin author behaviour unchanged
- inso CLI unaffected (Node.js process, no Electron renderer)
### Phase 2: sandbox hardening
**Goal:** replace the hidden window's `nodeIntegration: true` runtime with a strict sandbox. Introduce capability-based permissions, the new `rendererFunctions`/`mainFunctions` API shape, and `contextIsolation: true`.
#### What changes
- Hidden plugin window rebuilt with `nodeIntegration: false`, `contextIsolation: true`, `sandbox: true`
- Plugin API context object replaces direct Node/Electron access
- Permission metadata and enforcement added for `mainFunctions`
- Legacy plugin exports mapped onto new API shape or deprecated with explicit warnings
- Settings UI for trust and permissions
#### Relationship to Phase 1
Phase 1 proves the IPC boundary and host lifecycle. Phase 2 tightens what crosses that boundary. Because networking is already in main after Phase 1, the remaining surface to lock down is constrained to the plugin context object.
## Recommendation
Start with a narrow POC:
- keep plugin discovery and loading in main
- run `rendererFunctions` in a separate host, not in the app UI renderer
- allow `mainFunctions` only for bundled plugins or explicitly trusted plugins
- expose one new preload bridge instead of many plugin-specific bridges
That keeps the first iteration aligned with the app's existing preload and IPC architecture while leaving room for a more isolated runtime later.
## Explicit deprecation stance
This plan assumes explicit deprecations are acceptable.
That means the new plugin system does not need to preserve all current plugin features in the first release. It should instead:
1. ship a clean new architecture for the low-risk features first
2. mark high-risk legacy features as deprecated early
3. provide a migration path for medium-risk legacy features in a second pass
4. leave high-risk legacy features on the old runtime until there is a dedicated replacement or a formal removal plan
## Migration difficulty summary
### Low-risk features
- `themes`
### Low-medium-risk features
- `documentActions`
### Medium-risk features
- `requestActions`
- `requestGroupActions`
- `workspaceActions`
- `unsafePluginMainActions`
- plugin data store APIs
### High-risk features
- `requestHooks`
- `responseHooks`
- `templateTags`
- renderer/worker dialog helpers
- network and response-body helpers tied to the current request pipeline
## Proposed delivery model
### Phase 1: lift and shift (1a → 1b → 1c)
The first phase moves plugin execution out of the app UI renderer without changing any plugin-visible behaviour. It is the prerequisite for all sandbox hardening work. It is delivered in three sub-phases.
#### Phase 1a goals (current PR)
- legacy behaviour baseline tests green in CI for all plugin export types
- all plugin invocations cross the IPC bridge to the hidden window
- zero behavioural regressions
#### Phase 1b goals
- plugin code removed from the main renderer bundle entirely
- hidden BrowserWindow is sole owner of plugin discovery, loading, and execution
- `process.type` guards in shared modules preserved for inso CLI compatibility (see [inso CLI and `process.type` guards](#inso-cli-and-processtype-guards))
#### Phase 1c goals
- `nodeIntegration: false` set on the main BrowserWindow
- all residual renderer-side Node.js API usage eliminated (direct Electron imports, `fs`, `process.env`, dynamic `require`, `node:crypto`/`node:os`)
- delivered as a single PR where practical, since all items share the same prerequisite (1b) and goal
#### Out of scope for all of Phase 1
- new plugin API surface or export shapes
- permission model or trust gates
- sandbox hardening on the hidden window
- deprecation warnings
#### Success criteria (Phase 1 complete)
- all existing plugins work without modification
- app UI renderer contains no direct `require()` or import of plugin packages
- all plugin invocations cross the IPC bridge
- main BrowserWindow runs with `nodeIntegration: false`
- inso CLI plugin behaviour unchanged
### Phase 2: sandbox hardening and new API surface
The second phase tightens the hidden window, introduces the new API shape, and migrates legacy features.
#### Goals
- hidden window runs with `contextIsolation: true` and `nodeIntegration: false`
- plugins access capabilities through a controlled context object only
- new `rendererFunctions` / `mainFunctions` export shape is live
- low-risk and medium-risk legacy features are migrated or deprecated
#### In scope
- hidden window rebuilt with strict sandbox settings
- plugin context API (`RendererPluginContext`, `MainPluginContext`)
- permission metadata and enforcement for `mainFunctions`
- `themes`
- `documentActions`
- `requestActions`, `requestGroupActions`, `workspaceActions`
- `unsafePluginMainActions``mainFunctions`
- plugin data store bridge
- settings UI for trust and permissions
- migration docs for plugin authors
- initial mutation / command protocol for action-style features
#### Still out of scope
- `requestHooks`
- `responseHooks`
- `templateTags`
- full Nunjucks sandbox convergence
#### Recommended compatibility strategy
- support legacy and new plugin exports side-by-side for one transition window
- map action-style exports onto new `rendererFunctions` internally where practical
- convert bundled `unsafePluginMainActions` first as the lowest-risk privileged migration
- add warnings that legacy exports are deprecated and will move to new API shape
- do not migrate hook- or templating-driven features until a dedicated replacement design exists
## Coexistence rules
Legacy and new exports may need to coexist during migration, but the registry should make that behavior explicit.
Recommended rules:
1. A plugin may export legacy-only or new-only APIs with no warning.
2. A plugin exporting both legacy and new APIs should load, but should receive a migration warning.
3. New APIs should not silently shadow legacy APIs with the same user-facing purpose.
4. The registry should log exactly which exports were accepted, deprecated, or ignored.
5. The settings UI and docs should expose the plugin's current mode: legacy, mixed, or new.
## Legacy feature mapping
| Current feature | Phase 1 treatment | Phase 2 treatment | Notes |
| ------------------------- | ----------------------------- | --------------------------- | ----------------------------------------------------- |
| `themes` | moved to hidden window, works | declarative plugin metadata | Keep data-only |
| `documentActions` | moved to hidden window, works | `rendererFunctions` | Lower risk than other actions |
| `requestActions` | moved to hidden window, works | `rendererFunctions` | Needs DTO or mutation-patch wrapper in Phase 2 |
| `requestGroupActions` | moved to hidden window, works | `rendererFunctions` | Same as request actions |
| `workspaceActions` | moved to hidden window, works | `rendererFunctions` | Same as request actions |
| `unsafePluginMainActions` | moved to hidden window, works | `mainFunctions` | Best first migration candidate for privileged actions |
| plugin store APIs | moved to hidden window, works | plugin bridge/context APIs | Good fit for explicit capability APIs |
| `requestHooks` | moved to hidden window, works | deprecated / later redesign | Do not force into Phase 2 |
| `responseHooks` | moved to hidden window, works | deprecated / later redesign | Do not force into Phase 2 |
| `templateTags` | moved to hidden window, works | separate redesign track | Keep separate from first two phases |
## Deprecation plan
### Long-tail deprecations, not immediate removals
These features should be marked deprecated when the new architecture lands, but should remain on a separate legacy track until a replacement exists:
- `requestHooks`
- `responseHooks`
- `templateTags`
- any plugin feature that relies on unrestricted renderer Node access
The plan should not assume these features can be removed in the first two passes.
### Supported in transition
These can continue to work while the new system is introduced, but should gain migration guidance:
- `themes`
- `documentActions`
- `requestActions`
- `requestGroupActions`
- `workspaceActions`
- bundled `unsafePluginMainActions`
### Runtime behavior
- unsupported legacy exports in the new runtime should log a clear warning
- deprecated exports on the old runtime should log a migration warning
- docs should include a feature matrix: supported, deprecated, unsupported, planned
- hook and templating features should remain explicitly "legacy-supported" until a replacement plan is approved
## Pre-Phase 1: legacy behaviour baseline
Phase 1 must not break existing plugin behaviour. Before any structural changes are made, a test baseline must exist that covers how legacy plugin functions are invoked today and how errors are handled. Phase 1 does not begin until this baseline is in place and passing.
### What to capture
For each plugin export type, the baseline must cover:
| Export type | Invocation shape | Return value shape | Error behaviour |
| ------------------------- | ------------------------------------------------ | ---------------------- | ---------------------------------------------------------------- |
| `templateTags` | `render(context)` called with a mock tag context | rendered string | thrown errors propagate to the template engine as a render error |
| `requestHooks` | `hook(context)` called before request dispatch | void / mutates context | thrown errors abort the request with an error message |
| `responseHooks` | `hook(context)` called after response received | void / mutates context | thrown errors are logged; response is still returned |
| `requestActions` | menu item triggers `action(context)` | void | thrown errors shown as a notification |
| `requestGroupActions` | same as requestActions | void | same |
| `workspaceActions` | same as requestActions | void | same |
| `documentActions` | same as requestActions | void | same |
| `unsafePluginMainActions` | invoked by name with args | serializable result | thrown errors returned as structured error to caller |
| `themes` | queried by name for CSS vars | theme object | missing theme falls back to default |
### What to write
1. **Unit tests for each export type** — test the current invocation path in isolation. Use a minimal fixture plugin (inline object, not a real package). Assert the return value and that a thrown error produces the expected downstream behaviour (abort, notification, fallback, etc.).
2. **Error propagation tests** — explicitly test the error path for each export type:
- synchronous throw
- rejected promise
- non-Error thrown value (e.g. a plain string)
Assert the error reaches the right handler and does not crash the app.
3. **IPC contract snapshot** — once the baseline tests pass, document the exact IPC message shapes that Phase 1 will introduce for each export type. These become the acceptance criteria for the Phase 1 IPC bridge: if a message shape changes, the test must be updated intentionally, not silently.
### Success criteria for baseline
- All export types have at least one happy-path test and one error-path test
- Tests run in CI without requiring a live Electron renderer (use unit test mocks for IPC/context)
- The test suite passes on the current `develop` branch before any Phase 1 work begins
- Any Phase 1 change that causes a baseline test to fail is treated as a regression, not an acceptable trade-off
### Where to put the tests
Co-locate unit tests with the plugin execution code in `packages/insomnia/src/plugins/`. Name them `*.test.ts` following the existing Vitest convention. The baseline tests are not a one-off — they remain in the suite permanently as the regression guard for the hidden window migration and for Phase 2 sandbox hardening.
## Concrete implementation slices
### Phase 1 slices
#### Phase 1a slices (current PR)
1. **Baseline tests**
- write unit tests for each plugin export type covering happy path and error path
- tests must pass on `develop` before any structural changes
2. **Hidden plugin window**
- create and manage a hidden BrowserWindow from main (`nodeIntegration: true`, `show: false`)
- define window lifecycle: created eagerly at app startup, kept alive until app exit
- add IPC channel for plugin invocation and result return
3. **Bridge routing**
- add IPC handler in hidden window for each current plugin capability type
- redirect all app UI renderer plugin invocations through the bridge
- plugin code still bundled with renderer at this stage (duplication, not isolation)
4. **Verification**
- run baseline tests against the new routing; confirm zero regressions
#### Phase 1b slices
1. **Bundle separation**
- remove `src/plugins/index.ts` and `plugins/context/` from the renderer bundle
- audit all `process.type === 'renderer'` guards in shared modules — preserve them for inso; use `window.__PLUGIN_WINDOW__` or equivalent to distinguish hidden window from app renderer if needed
- confirm renderer has zero direct plugin imports
2. **inso validation**
- run inso CLI smoke tests to confirm `process.type` guard changes introduced no regressions
#### Phase 1c slices
1. **Remove direct Electron imports**`routes/auth.clear-vault-key.tsx`: replace `ipcRenderer.emit` with `window.main`
2. **Expose env vars via preload**`common/constants.ts`, `settings/plugins.tsx`: add `INSOMNIA_DATA_PATH` and `PORTABLE_EXECUTABLE_DIR` to preload bridge
3. **Bridge `fs` operations**`models/helpers/response-operations.ts`, `script-executor.ts`, `network/grpc/write-proto-file.ts`: new IPC handlers in `src/main/ipc/`
4. **Remove dynamic `require('electron')`**`network/network.ts`: replace with static imports or `window.main`
5. **Replace Node crypto/os**`sync/delta/diff.ts`, `sync/git/providers/gitlab.ts`, `templating/base-extension.ts`: use `globalThis.crypto.subtle`; IPC bridge for remainder
6. **Flip the flag** — set `nodeIntegration: false` on the main BrowserWindow and run full test suite
### Phase 2 slices
1. **Window hardening**
- rebuild hidden window with `nodeIntegration: false`, `contextIsolation: true`, `sandbox: true`
- add plugin preload that exposes the context object only
2. **Registry layer**
- add new plugin type definitions for `rendererFunctions` and `mainFunctions`
- normalize legacy plugin exports onto new shape where possible
- detect mixed-mode plugins and emit coexistence warnings
3. **Renderer host**
- expose minimal `RendererPluginContext` to plugin code
- route context API calls through IPC to main
4. **Main runtime**
- create `mainFunctions` registry
- add IPC invocation path with permission enforcement
- enforce trust gate
5. **Legacy migration**
- migrate `themes`, `documentActions`, action-style exports
- move `unsafePluginMainActions` onto `mainFunctions`
- define mutation/command protocol for action-style side effects
6. **Operationalization**
- settings UI for trust and permissions
- migration guide for plugin authors
- runtime deprecation warnings for legacy exports
## Recommended sequencing
### Phase 1
**Phase 1a (current PR)** 0. Write and pass legacy behaviour baseline tests. Do not proceed until green in CI.
1. Create hidden plugin window in main; verify it can load a plugin module.
2. Add IPC bridge; redirect all renderer plugin invocations through it.
3. Run full test suite; confirm zero regressions.
**Phase 1b** 4. Remove plugin code from the renderer bundle entirely. 5. Audit and preserve all `process.type === 'renderer'` guards for inso; add `window.__PLUGIN_WINDOW__` flag if disambiguation is needed. 6. Run inso CLI smoke tests to confirm no regressions.
**Phase 1c** 7. Remove direct `import electron` and `require('electron')` from renderer. 8. Expose required `process.env` vars via preload. 9. Bridge `fs` operations in response/network/scripts via new IPC handlers. 10. Replace `node:crypto`/`node:os` with Web Crypto or IPC bridges. 11. Set `nodeIntegration: false` on the main BrowserWindow; run full test suite.
### Phase 2
6. Rebuild hidden window with `contextIsolation: true`, `nodeIntegration: false`.
7. Introduce plugin context API; prove one `rendererFunctions` call end-to-end.
8. Add `mainFunctions` registry and IPC invocation; prove one privileged call end-to-end.
9. Convert one bundled `unsafePluginMainActions` to `mainFunctions`.
10. Add `themes` and `documentActions` on the new API.
11. Define and prototype the mutation / command protocol.
12. Migrate `requestActions`, `requestGroupActions`, `workspaceActions`.
13. Publish deprecation guidance for `requestHooks`, `responseHooks`, `templateTags`.
## Key decisions for this plan
**Phase 1 must not break existing plugins.** The hidden window with `nodeIntegration: true` is an intentional trade-off: it buys the structural separation needed to later apply the sandbox, without requiring plugin authors to change anything first. Any breakage in Phase 1 is a regression, not an accepted trade-off.
**Phase 1 is three sub-phases, not one.** 1a proves the bridge; 1b achieves true isolation; 1c hardens the main window. Each is a separate PR. 1c items should land together where practical since they share the same prerequisite and goal.
**`process.type === 'renderer'` guards are for inso, not just renderer detection.** The inso CLI reuses renderer code paths but loads implementations directly rather than via IPC. These guards must be preserved in shared modules during Phase 1b. See [inso CLI and `process.type` guards](#inso-cli-and-processtype-guards).
**Phase 2 is where the new API surface lands.** The `rendererFunctions` / `mainFunctions` shapes, permission model, and settings UI belong in Phase 2. They should not block Phase 1 delivery.
**Hook and templating features stay on the legacy path.** `requestHooks`, `responseHooks`, and `templateTags` move to the hidden window in Phase 1 (preserved, not redesigned), and remain on a separate redesign track after Phase 2 with explicit deprecation messaging.
## What Remains to Disable nodeIntegration in the Renderer (Phase 2)
## Blockers (must fix before flipping the switch)
1. createPlugin uses Node.js fs/path directly in the renderer
packages/insomnia/src/plugins/create.ts imports fs and path from Node and is called directly from two renderer entry points: the create-plugin modal and root.tsx (theme installation). This is the most straightforward fix — move the filesystem writes to an IPC handler
in the main process and call it via window.main.
2. Template tag extensions still run inside the renderer's Web Worker
This is the largest remaining piece. Nunjucks rendering runs in a Web Worker (ui/worker/templating-handler.ts), but the plugin template tag extensions (base-extension-worker.ts) are instantiated and executed inside that worker, which lives inside the renderer process.
The worker already has nodeIntegrationInWorker: false, so the web worker is sandboxed — but the template tag plugin code still lives on the renderer side of the fence. For nodeIntegration: false on the renderer, all plugin code (including template tags) needs to move
out.
The cleanest solution — and the one you're already thinking about — is to move the entire templating pipeline into the plugin window. Template tags and request/action plugins would then share the same Node.js process and DB proxy. The custom
insomnia-templating-worker-database:// protocol (currently used by the web worker to reach the main process for DB calls, network requests, file reads, etc.) could be replaced entirely with the existing IPC database proxy. The renderer side becomes a thin caller:
serialize the render context, send it over IPC, get back a rendered string.
3. webviewTag: true on the main window
response-web-view.tsx uses Electron's <webview> tag to render HTML response previews. The webviewTag: true setting in window-utils.ts:204 must remain until this is replaced. With contextIsolation: true the webview still functions, but it's a meaningful attack surface —
a malicious API response could attempt to exploit the webview. The right long-term replacement is a sandboxed <iframe srcdoc> (no src, no allow attributes), which achieves the same preview without a privileged Electron component.
---
## Minor Cleanup (not blockers, but needed for correctness)
- packages/insomnia/src/network/cancellation.ts:52 — the process.type === 'renderer' branch guard can be deleted once the renderer no longer has process in scope; the non-renderer branch there is unreachable from the renderer anyway.
---
## Suggested Phase 2 Work Order
┌─────┬───────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ # │ Task │ Approach │
├─────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 1 │ Move createPlugin to main process │ Add IPC handler, replace fs/path calls with window.main.createPlugin(...) │
├─────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 2 │ Move templating pipeline to plugin window │ The plugin window replaces the web worker; renderer calls window.main.plugins.renderTemplate(context) over IPC; drop the insomnia-templating-worker-database:// protocol │
├─────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 3 │ Replace <webview> with sandboxed <iframe> │ Removes the last reason for webviewTag: true │
├─────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 4 │ Flip the switch │ Set nodeIntegration: false, contextIsolation: true on the main renderer window │
└─────┴───────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
The templating migration (item 2) is the most work but also the most architecturally coherent outcome — all plugin code (actions, hooks, template tags) runs in one place with one shared DB proxy, one Node.js context, and one IPC boundary back to the renderer.

View File

@@ -73,6 +73,31 @@ export default async function build(options: Options) {
},
};
const pluginWindowBuildOptions: BuildOptions = {
entryPoints: ['./src/entry.plugin-window.ts'],
outfile: path.join(outdir, 'entry.plugin-window.min.js'),
target: 'esnext',
bundle: true,
platform: 'node',
sourcemap: true,
format: 'cjs',
external: ['electron'],
loader: {
'.node': 'copy',
},
};
const pluginWindowPreloadBuildOptions: BuildOptions = {
entryPoints: ['./src/entry.plugin-window-preload.ts'],
outfile: path.join(outdir, 'entry.plugin-window-preload.min.js'),
target: 'esnext',
bundle: true,
platform: 'node',
sourcemap: true,
format: 'cjs',
external: ['electron'],
};
const mainBuildOptions: BuildOptions = {
entryPoints: ['./src/entry.main.ts'],
outfile: path.join(outdir, 'entry.main.min.js'),
@@ -132,11 +157,11 @@ export default async function build(options: Options) {
});
build.onEnd(() => {
buildCount++;
// first build after main/preload/hiddenWindows is built
if (buildCount === 3) {
// first build after main/preload/hiddenWindows/pluginWindows is built
if (buildCount === 6) {
console.log('[Dev Build] Build complete, start Electron');
startElectron();
} else if (buildCount > 3) {
} else if (buildCount > 6) {
console.log(`[Dev Build] Finish rebuilding ${scriptName}, restarting Electron`);
restartElectronProcess();
} else {
@@ -161,6 +186,14 @@ export default async function build(options: Options) {
...hiddenBrowserWindowPreloadBuildOptions,
plugins: [restartElectronPlugin('hidden-browser-window-preload')],
});
const pluginWindowContext = await esbuild.context({
...pluginWindowBuildOptions,
plugins: [restartElectronPlugin('plugin-window')],
});
const pluginWindowPreloadContext = await esbuild.context({
...pluginWindowPreloadBuildOptions,
plugins: [restartElectronPlugin('plugin-window-preload')],
});
const restartElectronProcess = () => {
console.log('[Dev Build] Start restarting Electron');
@@ -180,13 +213,17 @@ export default async function build(options: Options) {
const hiddenWindowWatch = await hiddenBrowserWindowContext.watch();
const mainWatch = await mainContext.watch();
const hiddenWindowPreloadWatch = await hiddenPreloadContext.watch();
return Promise.all([preloadWatch, hiddenWindowPreloadWatch, mainWatch, hiddenWindowWatch]);
const pluginWindowWatch = await pluginWindowContext.watch();
const pluginWindowPreloadWatch = await pluginWindowPreloadContext.watch();
return Promise.all([preloadWatch, hiddenWindowPreloadWatch, mainWatch, hiddenWindowWatch, pluginWindowWatch, pluginWindowPreloadWatch]);
}
const preload = esbuild.build(preloadBuildOptions);
const hiddenBrowserWindow = esbuild.build(hiddenBrowserWindowBuildOptions);
const hiddenBrowserWindowPreload = esbuild.build(hiddenBrowserWindowPreloadBuildOptions);
const pluginWindow = esbuild.build(pluginWindowBuildOptions);
const pluginWindowPreload = esbuild.build(pluginWindowPreloadBuildOptions);
const main = esbuild.build(mainBuildOptions);
return Promise.all([main, preload, hiddenBrowserWindow, hiddenBrowserWindowPreload]).catch(err => {
return Promise.all([main, preload, hiddenBrowserWindow, hiddenBrowserWindowPreload, pluginWindow, pluginWindowPreload]).catch(err => {
console.error('[Build] Build failed:', err);
});
}

View File

@@ -64,7 +64,6 @@
"@rjsf/utils": "6.0.0-beta.15",
"@rjsf/validator-ajv8": "6.0.0-beta.15",
"@seald-io/nedb": "^4.1.1",
"@segment/analytics-node": "2.2.1",
"@sentry/electron": "^6.5.0",
"@stoplight/spectral-core": "^1.22.0",
"@stoplight/spectral-formats": "^1.8.2",

View File

@@ -58,6 +58,7 @@ export const start = async () => {
path.join(buildFolder, 'main/mcp-generate-sampling-response.mjs'),
);
await copyFiles('../src/hidden-window.html', path.join(buildFolder, 'hidden-window.html'));
await copyFiles('../src/plugin-window.html', path.join(buildFolder, 'plugin-window.html'));
console.log('[build] Complete!');
};

View File

@@ -66,8 +66,8 @@ vi.mock('../main/install-plugin', async () => {
});
vi.mock('../main/analytics', () => ({
trackSegmentEvent: vi.fn(),
SegmentEvent: {
trackAnalyticsEvent: vi.fn(),
AnalyticsEvent: {
installPlugin: 'Plugin Installed',
},
}));

View File

@@ -26,11 +26,7 @@ export async function absorbKey(sessionId: string, key: string) {
getUserProfile({ sessionId: sessionIdResolved }),
getEncryptionKeys({ sessionId: sessionIdResolved }),
]);
const {
public_key: publicKey,
enc_private_key: encPrivateKey,
enc_symmetric_key: encSymmetricKey,
} = keys;
const { public_key: publicKey, enc_private_key: encPrivateKey, enc_symmetric_key: encSymmetricKey } = keys;
const { email, id: accountId, first_name: firstName, last_name: lastName } = profile;
const symmetricKeyStr = crypt.decryptAES(key, JSON.parse(encSymmetricKey));
@@ -67,7 +63,7 @@ export async function getPrivateKey() {
}
export async function getCurrentSessionId() {
const { id } = await services.userSession.getOrCreate();
const { id } = await services.userSession.get();
return id;
}
@@ -122,16 +118,14 @@ export async function setSessionData(
lastName,
};
const userData = await services.userSession.getOrCreate();
await services.userSession.update(userData, sessionData);
await services.userSession.update(sessionData);
return sessionData;
}
/** Update the session data with vault salt and vault key */
export async function setVaultSessionData(vaultSalt: string, vaultKey: string) {
const userData = await services.userSession.getOrCreate();
await services.userSession.update(userData, { vaultSalt, vaultKey });
await services.userSession.update({ vaultSalt, vaultKey });
}
// ~~~~~~~~~~~~~~~~ //
@@ -139,25 +133,13 @@ export async function setVaultSessionData(vaultSalt: string, vaultKey: string) {
// ~~~~~~~~~~~~~~~~ //
export async function getUserSession(): Promise<SessionData> {
const userData = await services.userSession.getOrCreate();
const userData = await services.userSession.get();
return userData;
}
async function _unsetSessionData() {
await services.userSession.getOrCreate();
await services.userSession.update(await services.userSession.getOrCreate(), {
id: '',
accountId: '',
email: '',
firstName: '',
lastName: '',
symmetricKey: {} as JsonWebKey,
publicKey: {} as JsonWebKey,
encPrivateKey: {} as crypt.AESMessage,
vaultSalt: '',
vaultKey: '',
});
await services.userSession.remove();
}
/**
@@ -272,12 +254,12 @@ export async function migrateFromLocalStorage() {
try {
const sessionData = JSON.parse(session) as SessionData;
const currentUserSession = await services.userSession.getOrCreate();
const currentUserSession = await services.userSession.get();
if (currentUserSession.id) {
console.warn('Session already exists, skipping migration');
} else {
await services.userSession.update(currentUserSession, sessionData);
await services.userSession.update(sessionData);
}
} catch (e) {
console.error('Failed to parse session data', e);

View File

@@ -12,7 +12,7 @@ import { getRenderedRequestAndContext } from '../render';
describe('export', () => {
beforeEach(async () => {
await db.init({ inMemoryOnly: true }, true);
await services.project.all();
await services.project.list();
await services.settings.getOrCreate();
});

View File

@@ -12,7 +12,6 @@ const reqGroupBuilder = createBuilder(requestGroupModelSchema);
describe('render tests', () => {
beforeEach(async () => {
await services.project.all();
await services.settings.getOrCreate();
envBuilder.reset();
reqGroupBuilder.reset();

View File

@@ -592,7 +592,7 @@ export const importResourcesToNewWorkspace = async ({
}): Promise<Workspace> => {
invariant(resourceCacheItem, 'No resources to import');
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
const resources = resourceCacheItem.resources;
@@ -766,8 +766,7 @@ export async function findExistingImportedSpec(
}
| undefined
> {
const allProjects = await services.project.all();
const filteredProjects = organizationId ? allProjects.filter(p => p.parentId === organizationId) : allProjects;
const filteredProjects = await services.project.list({ organizationId });
// match active project first, then look in rest
const projectIds = new Set<string>();

View File

@@ -34,7 +34,7 @@ export async function fetchAndCacheOrganizationStorageRule(
}
}
const { id: sessionId } = await services.userSession.getOrCreate();
const { id: sessionId } = await services.userSession.get();
return await getOrganizationStorageRule({
organizationId,

View File

@@ -21,7 +21,7 @@ import { registerLLMConfigServiceAPI } from '~/main/llm-config-service';
import { userDataFolder } from '../config/config.json';
import { getAppVersion, getProductName, isDevelopment } from './common/constants';
import { isMac } from './common/platform';
import { SegmentEvent, trackSegmentEvent } from './main/analytics';
import { AnalyticsEvent, trackAnalyticsEvent } from './main/analytics';
import { registerInsomniaProtocols } from './main/api.protocol';
import { backupIfNewerVersionAvailable } from './main/backup';
import { registerSyncHandlers } from './main/cloud-sync/ipc';
@@ -289,7 +289,7 @@ async function _createModelInstances() {
await services.stats.get();
await services.settings.getOrCreate();
try {
const scratchpadProject = await services.project.getById(models.project.SCRATCHPAD_PROJECT_ID);
const scratchpadProject = await services.project.get(models.project.SCRATCHPAD_PROJECT_ID);
const scratchPad = await services.workspace.getById(models.workspace.SCRATCHPAD_WORKSPACE_ID);
if (!scratchpadProject) {
console.log('[main] Initializing Scratch Pad Project');
@@ -365,7 +365,7 @@ async function _trackStats() {
const settings = await services.settings.get();
trackSegmentEvent(SegmentEvent.appStarted, {
trackAnalyticsEvent(AnalyticsEvent.appStarted, {
localProjects,
remoteProjects,
createdRequests: stats.createdRequests,

View File

@@ -0,0 +1,40 @@
import { ipcRenderer } from 'electron';
// Provide window.app so plugin-loading code (which checks process.type === 'renderer')
// can resolve the userData path without needing the main renderer's full preload.
window.app = {
getPath: (name: string) => ipcRenderer.sendSync('getPath', name) as string,
getAppPath: () => ipcRenderer.sendSync('getAppPath') as string,
process: { platform: process.platform as NodeJS.Platform },
};
// Bridge plugin UI calls to the main renderer window via IPC.
// The plugin window has no visible DOM; these methods forward to the main renderer.
window.showAlert = (options?: Record<string, any>) => {
ipcRenderer.send('plugin-ui-alert', options ?? {});
};
window.showWrapper = (options?: Record<string, any>) => {
ipcRenderer.send('plugin-ui-dialog', options ?? {});
};
window.showPrompt = (options?: Record<string, any>) => {
const { onComplete, onHide, ...serializableOptions } = options ?? {};
ipcRenderer.invoke('plugin-ui-prompt', serializableOptions).then((value: string | null) => {
if (value !== null && value !== undefined) {
onComplete?.(value);
}
onHide?.();
});
};
window.dialog = {
showSaveDialog: (opts: any) => ipcRenderer.invoke('showSaveDialog', opts),
showOpenDialog: (opts: any) => ipcRenderer.invoke('showOpenDialog', opts),
};
window.clipboard = {
readText: () => ipcRenderer.sendSync('readText') as string,
writeText: (text: string) => { ipcRenderer.send('writeText', text); },
clear: () => { ipcRenderer.send('clear'); },
};

View File

@@ -0,0 +1,36 @@
import { ipcRenderer } from 'electron';
import { initDatabase, initServices } from '~/insomnia-data';
import { servicesNodeImpl } from '~/insomnia-data/node';
import { pluginWindowDatabase } from './main/database.plugin-window';
import { invokePluginMethod } from './plugins/invoke-method';
interface PluginInvokeMessage {
id: string;
method: Parameters<typeof invokePluginMethod>[0];
args: unknown;
}
ipcRenderer.on('plugin-invoke', async (_event, { id, method, args }: PluginInvokeMessage) => {
try {
const result = await invokePluginMethod(method, args);
ipcRenderer.send('plugin-invoke-result', { id, result });
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
console.error(`[plugin-window] Error in ${(error as any)?.method ?? method}: ${errMsg}`);
ipcRenderer.send('plugin-invoke-result', { id, error: errMsg });
}
});
// Initialize database (via IPC proxy) and services before signalling readiness.
// getPlugins() calls services.settings.get(), which requires this to be done first.
(async () => {
try {
await initDatabase(pluginWindowDatabase);
initServices(servicesNodeImpl);
ipcRenderer.send('plugin-window-ready');
} catch (err) {
console.error('[plugin-window] Initialization failed:', err);
}
})();

View File

@@ -17,10 +17,27 @@ import type { CurlBridgeAPI } from './main/network/curl';
import type { McpBridgeAPI } from './main/network/mcp';
import type { SocketIOBridgeAPI } from './main/network/socket-io';
import type { WebSocketBridgeAPI } from './main/network/websocket';
import type {
ApplyRequestHooksArgs,
ApplyResponseHooksArgs,
ExecutePluginActionArgs,
ExecutePluginMainActionArgs,
PluginsBridgeAPI,
RunTemplateTagActionArgs,
} from './plugins/bridge-types';
import type { PluginInvokeMethod } from './plugins/invoke-method';
import type { RenderedRequest } from './templating/types';
import { invariant } from './utils/invariant';
const ports = new Map<'hiddenWindowPort', MessagePort>();
type PluginMethodResult<T extends PluginInvokeMethod> = T extends keyof PluginsBridgeAPI
? Awaited<ReturnType<PluginsBridgeAPI[T]>>
: never;
const invokePluginBridgeMethod = <T extends PluginInvokeMethod>(method: T, args?: unknown): Promise<PluginMethodResult<T>> => {
return invokeWithNormalizedError(`plugins.${method}`, args) as Promise<PluginMethodResult<T>>;
};
const webSocket: WebSocketBridgeAPI = {
open: options => invokeWithNormalizedError('webSocket.open', options),
close: options => ipcRenderer.send('webSocket.close', options),
@@ -291,7 +308,7 @@ const main: Window['main'] = {
secretStorage,
electronStorage,
sync,
trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options),
trackAnalyticsEvent: options => ipcRenderer.send('trackAnalyticsEvent', options),
trackPageView: options => ipcRenderer.send('trackPageView', options),
setCurrentOrganizationId: organizationId => ipcRenderer.send('analytics.setOrganizationId', organizationId),
showNunjucksContextMenu: options => ipcRenderer.send('show-nunjucks-context-menu', options),
@@ -346,6 +363,28 @@ const main: Window['main'] = {
invokeWithNormalizedError('generateCommitsFromDiff', input),
generateMcpSamplingResponse: (parameters: Parameters<GenerateMcpSamplingResponseFunction>[0]) =>
invokeWithNormalizedError('generateMcpSamplingResponse', parameters),
plugins: {
getThemes: () => invokePluginBridgeMethod('getThemes'),
getPlugins: () => invokePluginBridgeMethod('getPlugins'),
getActivePlugins: () => invokePluginBridgeMethod('getActivePlugins'),
reloadPlugins: () => invokePluginBridgeMethod('reloadPlugins'),
getRequestActions: () => invokePluginBridgeMethod('getRequestActions'),
getRequestGroupActions: () => invokePluginBridgeMethod('getRequestGroupActions'),
getWorkspaceActions: () => invokePluginBridgeMethod('getWorkspaceActions'),
getDocumentActions: () => invokePluginBridgeMethod('getDocumentActions'),
executeAction: (args: ExecutePluginActionArgs) => invokePluginBridgeMethod('executeAction', args),
getTemplateTags: () => invokePluginBridgeMethod('getTemplateTags'),
runTemplateTagAction: (args: RunTemplateTagActionArgs) => invokePluginBridgeMethod('runTemplateTagAction', args),
getBundlePlugins: () => invokePluginBridgeMethod('getBundlePlugins'),
executePluginMainAction: (args: ExecutePluginMainActionArgs) => invokePluginBridgeMethod('executePluginMainAction', args),
hasRequestHooks: () => invokePluginBridgeMethod('hasRequestHooks'),
hasResponseHooks: () => invokePluginBridgeMethod('hasResponseHooks'),
applyRequestHooks: (args: ApplyRequestHooksArgs) => invokePluginBridgeMethod('applyRequestHooks', args),
applyResponseHooks: (args: ApplyResponseHooksArgs) => invokePluginBridgeMethod('applyResponseHooks', args),
getBridgeMetrics: () => invokeWithNormalizedError('plugins.getBridgeMetrics'),
},
notifyPluginPromptResult: (id: string, value: string | null) =>
ipcRenderer.send('plugin-ui-prompt-result', { id, value }),
};
ipcRenderer.on('hidden-browser-window-response-listener', event => {

View File

@@ -30,7 +30,6 @@ import { services } from '~/insomnia-data';
// @vitest-environment jsdom
describe('Request Model - Comprehensive Tests', () => {
beforeEach(async () => {
await services.project.all();
await services.settings.getOrCreate();
// Create test project for all tests

View File

@@ -15,6 +15,7 @@ import * as mcpResponseService from './mcp-response';
import * as mockRouteService from './mock-route';
import * as mockServerService from './mock-server';
import * as oAuth2TokenService from './o-auth-2-token';
import * as organizationService from './organization';
import * as pluginDataService from './plugin-data';
import * as projectService from './project';
import * as protoDirectoryService from './proto-directory';
@@ -59,6 +60,7 @@ export const servicesNodeImpl = {
mcpRequest: mcpRequestService,
mcpResponse: mcpResponseService,
oAuth2Token: oAuth2TokenService,
organization: organizationService,
pluginData: pluginDataService,
protoDirectory: protoDirectoryService,
protoFile: protoFileService,

View File

@@ -0,0 +1,60 @@
import { getOrganizations, type Organization } from 'insomnia-api';
import { models } from '~/insomnia-data';
import * as userSessionService from './user-session';
function sortOrganizations(accountId: string, organizations: Organization[]): Organization[] {
const home = organizations.find(
organization =>
models.organization.isPersonalOrganization(organization) &&
models.organization.isOwnerOfOrganization({
organization,
accountId,
}),
);
const myOrgs = organizations
.filter(
organization =>
!models.organization.isPersonalOrganization(organization) &&
models.organization.isOwnerOfOrganization({
organization,
accountId,
}),
)
.sort((a, b) => a.name.localeCompare(b.name));
const notMyOrgs = organizations
.filter(
organization =>
!models.organization.isOwnerOfOrganization({
organization,
accountId,
}),
)
.sort((a, b) => a.name.localeCompare(b.name));
return [...(home ? [home] : []), ...myOrgs, ...notMyOrgs];
}
/**
* List organizations from the Insomnia cloud API.
*/
export async function list(): Promise<Organization[]> {
const { id: sessionId, accountId } = await userSessionService.get();
if (!sessionId || !accountId) {
return [];
}
const result = await getOrganizations({ sessionId });
const organizations = result?.organizations ?? [];
return sortOrganizations(accountId, organizations);
}
/**
* Get a single organization by ID.
*/
export async function get(id: string): Promise<Organization | undefined> {
const all = await list();
return all.find(org => org.id === id);
}

View File

@@ -1,4 +1,4 @@
import type { Project } from '~/insomnia-data';
import type { Project, Query } from '~/insomnia-data';
import { database as db, models } from '~/insomnia-data';
const { type } = models.project;
@@ -7,30 +7,42 @@ export function create(patch: Partial<Project> = {}) {
return db.docCreate<Project>(type, patch);
}
export function getById(_id: string) {
return db.findOne<Project>(type, { _id });
export function list(options?: { gitRepositoryIds?: string[]; organizationId?: string }) {
const query: Query<Project> = {};
if (options?.organizationId) {
query.parentId = options.organizationId;
}
if (options?.gitRepositoryIds) {
const queryIds = options.gitRepositoryIds.flatMap(id => models.project.getQueryableGitRepositoryIds(id));
query.gitRepositoryId = { $in: queryIds };
}
return db.find<Project>(type, query);
}
export function get(id: string) {
return db.findOne<Project>(type, { _id: id });
}
export function getByRemoteId(remoteId: string) {
return db.findOne<Project>(type, { remoteId });
}
export function getAllByGitRepositoryIds(gitRepositoryIds: string[]) {
const queryIds = gitRepositoryIds.flatMap(id => models.project.getQueryableGitRepositoryIds(id));
return db.find<Project>(type, {
gitRepositoryId: { $in: queryIds },
});
}
const getProjectByIdOrProject = async (idOrProject: string | Project) => {
const project = typeof idOrProject === 'string' ? await get(idOrProject) : idOrProject;
if (!project) {
throw new Error(
`Project not found: ${typeof idOrProject === 'string' ? idOrProject : `_id=${idOrProject._id}, name=${idOrProject.name}`}`,
);
}
return project;
};
export function remove(project: Project) {
return db.remove(project);
}
export function update(project: Project, patch: Partial<Project>) {
export async function update(idOrProject: string | Project, patch: Partial<Project>) {
const project = await getProjectByIdOrProject(idOrProject);
return db.docUpdate(project, patch);
}
export async function all() {
const projects = await db.find<Project>(type);
return projects;
export async function remove(idOrProject: string | Project) {
const project = await getProjectByIdOrProject(idOrProject);
return db.remove(project);
}

View File

@@ -3,41 +3,23 @@ import { database as db, models } from '~/insomnia-data';
const { type } = models.userSession;
export async function all() {
let userList = await db.find<UserSession>(type);
if (userList?.length === 0) {
userList = [await getOrCreate()];
}
return userList;
}
async function create() {
const user = await db.docCreate<UserSession>(type);
return user;
}
export async function update(user: UserSession, patch: Partial<UserSession>) {
const updatedUser = await db.docUpdate<UserSession>(user, patch);
return updatedUser;
}
export async function patch(patch: Partial<UserSession>) {
const user = await getOrCreate();
const updatedUser = await db.docUpdate<UserSession>(user, patch);
return updatedUser;
}
export async function getOrCreate() {
export async function get() {
const result = await db.findOne<UserSession>(type);
if (!result) {
return await create();
const user = await db.docCreate<UserSession>(type);
return user;
}
return result;
}
export async function get() {
return getOrCreate();
export async function update(patch: Partial<UserSession>) {
const user = await get();
const updatedUser = await db.docUpdate<UserSession>(user, patch);
return updatedUser;
}
export async function remove() {
const user = await get();
await db.remove(user);
}

View File

@@ -1,7 +1,7 @@
/**
* Tests run against the in-memory NeDB initialized by setup-vitest.ts.
* fetch is mocked per-test to return shaped Konnect API responses.
* window.main is stubbed globally so trackSegmentEvent calls don't throw.
* window.main is stubbed globally so trackAnalyticsEvent calls don't throw.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -101,13 +101,13 @@ const konnectRequests = (docs: any[]) => docs.filter((r: any) => r.konnectRouteK
const konnectWorkspaces = (docs: any[]) => docs.filter((w: any) => w.konnectServiceId != null);
const konnectProjects = (docs: any[]) => docs.filter((p: any) => p.konnectControlPlaneId != null);
const trackSegmentEvent = vi.fn();
const trackAnalyticsEvent = vi.fn();
beforeEach(async () => {
// Re-init with fresh in-memory NeDB buckets — clean slate for every test.
await initDatabase(mainDatabase, { inMemoryOnly: true }, true);
resetV4Counter();
vi.stubGlobal('window', { main: { trackSegmentEvent } });
vi.stubGlobal('window', { main: { trackAnalyticsEvent } });
});
afterEach(() => {

View File

@@ -20,10 +20,10 @@ vi.mock('~/insomnia-data', () => ({
getById: vi.fn(),
},
project: {
getById: vi.fn(),
get: vi.fn(),
},
userSession: {
getOrCreate: vi.fn(),
get: vi.fn(),
},
workspaceMeta: {
getOrCreateByParentId: vi.fn(),
@@ -68,8 +68,8 @@ describe('sync-initialization', () => {
vi.clearAllMocks();
vi.mocked(services.workspace.getById).mockResolvedValue(workspace);
vi.mocked(services.project.getById).mockResolvedValue(project);
vi.mocked(services.userSession.getOrCreate).mockResolvedValue({ id: 'sess_123' } as any);
vi.mocked(services.project.get).mockResolvedValue(project);
vi.mocked(services.userSession.get).mockResolvedValue({ id: 'sess_123' } as any);
vi.mocked(services.workspaceMeta.getOrCreateByParentId).mockResolvedValue({ gitRepositoryId: null } as any);
vi.mocked(services.environment.getOrCreateForParentId).mockResolvedValue({} as any);
vi.mocked(services.cookieJar.getOrCreateForParentId).mockResolvedValue({} as any);
@@ -83,7 +83,7 @@ describe('sync-initialization', () => {
});
it('returns early when initializing a workspace backend project without a session', async () => {
vi.mocked(services.userSession.getOrCreate).mockResolvedValue({ id: null } as any);
vi.mocked(services.userSession.get).mockResolvedValue({ id: null } as any);
await initializeWorkspaceBackendProject({ workspaceId: workspace._id });

View File

@@ -1,8 +1,8 @@
import crypto from 'node:crypto';
import { Analytics } from '@segment/analytics-node';
import * as Sentry from '@sentry/electron/main';
import { net } from 'electron';
import { AnalyticsEvent, InsomniaAnalytics } from 'insomnia-analytics';
import { v4 as uuidv4 } from 'uuid';
import { services } from '~/insomnia-data';
@@ -15,7 +15,8 @@ import {
getSegmentWriteKey,
PLAYWRIGHT_TEST,
} from '../common/constants';
import { platform } from '../common/platform';
export { AnalyticsEvent };
let _currentOrganizationId: string | undefined;
@@ -23,18 +24,29 @@ export function setCurrentOrganizationId(id: string | undefined): void {
_currentOrganizationId = id;
}
const analytics = new Analytics({
const analytics = new InsomniaAnalytics({
writeKey: getSegmentWriteKey(),
httpClient: {
makeRequest(_options) {
return net.fetch(_options.url, {
method: _options.method,
headers: _options.headers,
body: _options.body,
signal: AbortSignal.timeout(_options.httpRequestTimeout),
});
app: {
appName: getProductName(),
appVersion: getAppVersion(),
osVersion: () => process.getSystemVersion(),
platform: 'app',
},
settings: {
httpClient: {
makeRequest(_options) {
return net.fetch(_options.url, {
method: _options.method,
headers: _options.headers,
body: _options.body,
signal: AbortSignal.timeout(_options.httpRequestTimeout),
});
},
},
},
onError: error => {
console.warn('[analytics] Error sending analytics event', error);
},
});
const getDeviceId = async () => {
@@ -42,97 +54,48 @@ const getDeviceId = async () => {
return settings.deviceId || (await services.settings.update(settings, { deviceId: uuidv4() })).deviceId;
};
export enum SegmentEvent {
appStarted = 'App Started',
collectionCreate = 'Collection Created',
dataExport = 'Data Exported',
dataImport = 'Data Imported',
loginSuccess = 'Login Success',
documentCreate = 'Document Created',
kongConnected = 'Kong Connected',
kongSync = 'Kong Synced',
requestBodyTypeSelect = 'Request Body Type Selected',
requestCreated = 'Request Created',
requestExecuted = 'Request Executed',
collectionRunExecute = 'Collection Run Executed',
projectLocalCreate = 'Local Project Created',
projectLocalDelete = 'Local Project Deleted',
testSuiteCreate = 'Test Suite Created',
testSuiteDelete = 'Test Suite Deleted',
unitTestCreate = 'Unit Test Created',
unitTestDelete = 'Unit Test Deleted',
unitTestRun = 'Ran Individual Unit Test',
unitTestRunAll = 'Ran All Unit Tests',
vcsSyncStart = 'VCS Sync Started',
vcsSyncComplete = 'VCS Sync Completed',
vcsAction = 'VCS Action Executed',
gitAuthenticationCompleted = 'Git Authentication Completed',
gitAuthenticationUpdated = 'Git Authentication Updated',
buttonClick = 'Button Clicked',
aiFeatureEnabled = 'AI Feature Enabled',
aiFeatureDisabled = 'AI Feature Disabled',
mcpClientConnected = 'MCP Client Connected',
mcpClientDisconnected = 'MCP Client Disconnected',
mcpToolCalled = 'MCP Tool Called',
mcpResourceRead = 'MCP Resource Read',
mcpPromptCalled = 'MCP Prompt Called',
installPlugin = 'Plugin Installed',
}
function hashString(input: string) {
return crypto.createHash('sha256').update(input).digest('hex');
}
export async function trackSegmentEvent(event: SegmentEvent, properties?: Record<string, any>) {
export async function trackAnalyticsEvent(event: AnalyticsEvent, properties?: Record<string, any>) {
if (PLAYWRIGHT_TEST) {
return;
}
const settings = await services.settings.getOrCreate();
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
if (!userSession?.hashedAccountId) {
userSession.hashedAccountId = userSession?.accountId ? hashString(userSession.accountId) : '';
}
const allowAnalytics = settings.enableAnalytics || userSession?.hashedAccountId;
if (allowAnalytics) {
try {
const anonymousId = (await getDeviceId()) ?? '';
const context = {
app: { name: getProductName(), version: getAppVersion() },
os: { name: _getOsName(), version: process.getSystemVersion() },
};
if (!allowAnalytics) {
return;
}
analytics.track(
{
event,
properties: {
...(_currentOrganizationId && { organization_id: _currentOrganizationId }),
...properties,
platform: 'app',
},
context,
anonymousId,
userId: userSession?.hashedAccountId || '',
try {
const anonymousId = (await getDeviceId()) ?? '';
analytics.track({
event,
properties: {
...(_currentOrganizationId && { organization_id: _currentOrganizationId }),
...properties,
},
anonymousId,
userId: userSession?.hashedAccountId || '',
});
} catch (error: unknown) {
console.warn('[analytics] Unexpected error while sending analytics event', error);
} finally {
if (!userSession?.hashedAccountId && [AnalyticsEvent.unitTestRun, AnalyticsEvent.unitTestRunAll].includes(event)) {
Sentry.captureException(`Run tests by anonymous`, {
tags: {
source: 'main/analytics',
},
error => {
if (error) {
console.warn('[analytics] Error sending segment event', error);
}
extra: {
organizationId: properties?.organizationId || '',
projectId: properties?.projectId || '',
},
);
} catch (error: unknown) {
console.warn('[analytics] Unexpected error while sending segment event', error);
} finally {
if (!userSession?.hashedAccountId && [SegmentEvent.unitTestRun, SegmentEvent.unitTestRunAll].includes(event)) {
Sentry.captureException(`Run tests by anonymous`, {
tags: {
source: 'main/analytics',
},
extra: {
organizationId: properties?.organizationId || '',
projectId: properties?.projectId || '',
},
});
}
});
}
}
}
@@ -142,54 +105,30 @@ export async function trackPageView(name: string) {
return;
}
const settings = await services.settings.getOrCreate();
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
if (!userSession?.hashedAccountId) {
userSession.hashedAccountId = userSession?.accountId ? hashString(userSession.accountId) : '';
}
const allowAnalytics = settings.enableAnalytics || userSession?.hashedAccountId;
if (allowAnalytics) {
try {
const anonymousId = (await getDeviceId()) ?? '';
const context = {
app: { name: getProductName(), version: getAppVersion() },
os: { name: _getOsName(), version: process.getSystemVersion() },
};
if (!allowAnalytics) {
return;
}
analytics.page({ name, context, anonymousId, userId: userSession?.hashedAccountId }, error => {
if (error) {
console.warn('[analytics] Error sending segment event', error);
}
try {
const anonymousId = (await getDeviceId()) ?? '';
analytics.page({ name, anonymousId, userId: userSession?.hashedAccountId });
if (userSession?.id) {
net.fetch(getApiBaseURL() + '/v1/telemetry/', {
method: 'POST',
headers: new Headers({
'X-Session-Id': userSession?.id,
'X-Insomnia-Client': getClientString(),
}),
});
if (userSession?.id) {
net.fetch(getApiBaseURL() + '/v1/telemetry/', {
method: 'POST',
headers: new Headers({
'X-Session-Id': userSession?.id,
'X-Insomnia-Client': getClientString(),
}),
});
}
} catch (error: unknown) {
console.warn('[analytics] Unexpected error while sending segment event', error);
}
}
}
// ~~~~~~~~~~~~~~~~~ //
// Private Functions //
// ~~~~~~~~~~~~~~~~~ //
function _getOsName() {
switch (platform) {
case 'darwin': {
return 'mac';
}
case 'win32': {
return 'windows';
}
default: {
return platform;
}
} catch (error: unknown) {
console.warn('[analytics] Unexpected error while sending analytics event', error);
}
}

View File

@@ -11,7 +11,7 @@ export const initializeWorkspaceBackendProject = async ({ workspaceId }: { works
const workspace = await services.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
const { id } = await services.userSession.getOrCreate();
const { id } = await services.userSession.get();
if (!id) {
return;
}
@@ -32,10 +32,10 @@ export const syncNewWorkspaceIfNeeded = async ({ workspaceId }: { workspaceId: s
const workspace = await services.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
const project = await services.project.getById(workspace.parentId);
const project = await services.project.get(workspace.parentId);
invariant(project, 'Project not found');
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
if (!userSession.id || !models.project.isRemoteProject(project)) {
return;
}

View File

@@ -0,0 +1,23 @@
import { ipcRenderer } from 'electron';
import type { IDatabase } from '~/insomnia-data';
// Routes all database calls to the main process via the 'database.invoke' IPC handler
// that mainDatabase registers on startup. The plugin window must not open a second
// NeDB connection to the same files, so this proxy is the correct approach.
export const pluginWindowDatabase: IDatabase = new Proxy({} as IDatabase, {
get(_target, fnName) {
if (typeof fnName === 'symbol') {
return;
}
if (fnName === 'init') {
// Main process already initialised the database.
return async () => {};
}
if (fnName === 'flushChanges') {
// The plugin window never needs to broadcast db.changes to other windows.
return async () => ({ changes: [], deletedIds: [] });
}
return (...args: unknown[]) => ipcRenderer.invoke('database.invoke', fnName, ...args);
},
});

View File

@@ -66,7 +66,7 @@ import { routableFSClient } from '../sync/git/routable-fs-client';
import { shallowClone } from '../sync/git/shallow-clone';
import type { AutoResolvedConflict, MergeConflict } from '../sync/types';
import { invariant } from '../utils/invariant';
import { SegmentEvent, trackSegmentEvent } from './analytics';
import { AnalyticsEvent, trackAnalyticsEvent } from './analytics';
import { ipcMainHandle } from './ipc/electron';
// Initialize Git Remote Providers on module load
@@ -153,7 +153,7 @@ export function getErrorMessage(error: unknown): string {
// Non-Error objects
return 'Unknown Error';
}
export function vcsSegmentEventProperties(type: 'git', action: VCSAction, error?: string) {
export function vcsEventProperties(type: 'git', action: VCSAction, error?: string) {
return { type, action, error };
}
@@ -216,7 +216,7 @@ async function getGitRepository({ projectId, workspaceId }: { projectId: string;
}
invariant(projectId, 'Project ID is required');
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
invariant(models.project.isConnectedGitProject(project), 'Project is not linked to a git repository');
const repoId = models.project.getEffectiveRepoId(project);
@@ -290,7 +290,7 @@ export async function getProjectGitFileIssues({
workspaceId,
gitRepositoryId,
}: GetProjectGitFileIssuesOptions): Promise<WorkspaceFileIssue[]> {
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
if (!project || !models.project.isConnectedGitProject(project)) {
return [];
}
@@ -1097,8 +1097,8 @@ export const cloneGitRepoAction = async ({
}
if (!projectId) {
trackSegmentEvent(SegmentEvent.vcsSyncStart, {
...vcsSegmentEventProperties('git', 'clone'),
trackAnalyticsEvent(AnalyticsEvent.vcsSyncStart, {
...vcsEventProperties('git', 'clone'),
provider,
repoId: repoSettingsPatch._id,
});
@@ -1157,7 +1157,7 @@ export const cloneGitRepoAction = async ({
async function getProject() {
if (cloneIntoProjectId) {
const project = await services.project.getById(cloneIntoProjectId);
const project = await services.project.get(cloneIntoProjectId);
invariant(project, 'Project not found');
await services.project.update(project, {
@@ -1230,8 +1230,8 @@ export const cloneGitRepoAction = async ({
});
await database.flushChanges(bufferId);
trackSegmentEvent(SegmentEvent.vcsSyncComplete, {
...vcsSegmentEventProperties('git', 'clone'),
trackAnalyticsEvent(AnalyticsEvent.vcsSyncComplete, {
...vcsEventProperties('git', 'clone'),
providerName,
repoId: repoSettingsPatch._id,
});
@@ -1242,11 +1242,11 @@ export const cloneGitRepoAction = async ({
};
}
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
trackSegmentEvent(SegmentEvent.vcsSyncStart, {
...vcsSegmentEventProperties('git', 'clone'),
trackAnalyticsEvent(AnalyticsEvent.vcsSyncStart, {
...vcsEventProperties('git', 'clone'),
provider,
repoId: repoSettingsPatch._id,
});
@@ -1304,8 +1304,8 @@ export const cloneGitRepoAction = async ({
});
await services.apiSpec.getOrCreateForParentId(workspace._id);
trackSegmentEvent(SegmentEvent.vcsSyncComplete, {
...vcsSegmentEventProperties('git', 'clone', 'no directory found'),
trackAnalyticsEvent(AnalyticsEvent.vcsSyncComplete, {
...vcsEventProperties('git', 'clone', 'no directory found'),
providerName: provider,
repoId: repoSettingsPatch._id,
});
@@ -1323,8 +1323,8 @@ export const cloneGitRepoAction = async ({
const workspaces = await inMemoryFsClient.promises.readdir(workspaceBase);
if (workspaces.length === 0) {
trackSegmentEvent(SegmentEvent.vcsSyncComplete, {
...vcsSegmentEventProperties('git', 'clone', 'no workspaces found'),
trackAnalyticsEvent(AnalyticsEvent.vcsSyncComplete, {
...vcsEventProperties('git', 'clone', 'no workspaces found'),
providerName: provider,
repoId: repoSettingsPatch._id,
});
@@ -1335,8 +1335,8 @@ export const cloneGitRepoAction = async ({
}
if (workspaces.length > 1) {
trackSegmentEvent(SegmentEvent.vcsSyncComplete, {
...vcsSegmentEventProperties('git', 'clone', 'multiple workspaces found'),
trackAnalyticsEvent(AnalyticsEvent.vcsSyncComplete, {
...vcsEventProperties('git', 'clone', 'multiple workspaces found'),
providerName: provider,
repoId: repoSettingsPatch._id,
});
@@ -1359,7 +1359,7 @@ export const cloneGitRepoAction = async ({
const existingWorkspace = await services.workspace.getById(workspace._id);
if (existingWorkspace) {
const project = await services.project.getById(existingWorkspace.parentId);
const project = await services.project.get(existingWorkspace.parentId);
if (!project) {
return {
errors: [
@@ -1424,8 +1424,8 @@ export const cloneGitRepoAction = async ({
// Flush DB changes
await database.flushChanges(bufferId);
trackSegmentEvent(SegmentEvent.vcsSyncComplete, {
...vcsSegmentEventProperties('git', 'clone'),
trackAnalyticsEvent(AnalyticsEvent.vcsSyncComplete, {
...vcsEventProperties('git', 'clone'),
providerName: provider,
repoId: repoSettingsPatch._id,
});
@@ -1469,7 +1469,7 @@ export const updateGitRepoAction = async ({
const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId);
gitRepositoryId = workspaceMeta?.gitRepositoryId;
} else if (projectId) {
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
gitRepositoryId = project.gitRepositoryId;
}
@@ -1497,7 +1497,7 @@ export const updateGitRepoAction = async ({
gitRepositoryId: gitRepository._id,
});
} else if (projectId) {
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
await services.project.update(project, {
gitRepositoryId: models.project.toProtectedRepoId(gitRepository._id),
@@ -1557,7 +1557,7 @@ export const resetGitRepoAction = async ({ projectId, workspaceId }: { projectId
gitRepositoryId: null,
});
} else if (projectId) {
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
await services.project.update(project, {
gitRepositoryId: models.project.EMPTY_GIT_PROJECT_ID,
@@ -1601,8 +1601,8 @@ export const commitToGitRepoAction = async ({
providerName = credentials.provider;
}
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'commit'),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'commit'),
providerName,
repoId: gitRepository._id,
});
@@ -1730,8 +1730,8 @@ export const commitAndPushToGitRepoAction = async ({
providerName = credentials.provider;
}
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'commit'),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'commit'),
providerName,
repoId: repo._id,
});
@@ -1770,8 +1770,8 @@ export const commitAndPushToGitRepoAction = async ({
try {
await GitVCS.push(repo.credentialsId);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'push'),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'push'),
providerName,
repoId: repo._id,
});
@@ -1804,8 +1804,8 @@ export const commitAndPushToGitRepoAction = async ({
}
const errorMessage = getErrorMessage(err);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'push', errorMessage),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'push', errorMessage),
providerName,
repoId: repo._id,
});
@@ -1846,8 +1846,8 @@ export const createNewGitBranchAction = async ({
providerName = credentials.provider;
}
await GitVCS.checkout(branch);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'create_branch'),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'create_branch'),
providerName,
repoId: gitRepository._id,
});
@@ -2021,8 +2021,8 @@ export const mergeGitBranch = async ({
await repoFileWatcherRegistry.importAllFiles(gitRepoId);
clearConflictSuppression(gitRepository._id);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'merge_branch'),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'merge_branch'),
providerName,
repoId: gitRepository._id,
});
@@ -2050,8 +2050,8 @@ export const mergeGitBranch = async ({
errorMessage = `${err.message}, ${err.data.response}`;
}
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'merge_branch', errorMessage),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'merge_branch', errorMessage),
providerName,
repoId: gitRepository._id,
});
@@ -2086,8 +2086,8 @@ export const deleteGitBranchAction = async ({
providerName = credentials.provider;
}
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'delete_branch'),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'delete_branch'),
providerName,
repoId: repo._id,
});
@@ -2163,8 +2163,8 @@ export const pushToGitRemoteAction = async ({
const bufferId = await database.bufferChanges();
await GitVCS.push(gitRepository.credentialsId);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', force ? 'force_push' : 'push'),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', force ? 'force_push' : 'push'),
providerName,
repoId: gitRepository._id,
});
@@ -2208,8 +2208,8 @@ export const pushToGitRemoteAction = async ({
}
const errorMessage = getErrorMessage(err);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'push', errorMessage),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'push', errorMessage),
providerName,
repoId: gitRepository._id,
});
@@ -2277,8 +2277,8 @@ export async function pullFromGitRemote({ projectId, workspaceId }: { projectId:
await repoFileWatcherRegistry.importAllFiles(gitRepository._id);
clearConflictSuppression(repoId);
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'pull'),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'pull'),
providerName: credentials.provider,
repoId: gitRepository._id,
});
@@ -2330,8 +2330,8 @@ export async function pullFromGitRemote({ projectId, workspaceId }: { projectId:
providerName = credentials.provider;
}
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'pull', errorMessage),
trackAnalyticsEvent(AnalyticsEvent.vcsAction, {
...vcsEventProperties('git', 'pull', errorMessage),
providerName,
repoId: gitRepository._id,
});
@@ -2679,7 +2679,7 @@ const getRepositoryDirectoryTree = async ({
repositoryTree: FileTree;
folderList: Record<string, string[]>;
}> => {
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
if (project && models.project.isEmptyGitProject(project)) {
return {
@@ -2788,9 +2788,9 @@ async function completeSignInToGitProvider({
}
if (isEditing) {
trackSegmentEvent(SegmentEvent.gitAuthenticationUpdated, { provider });
trackAnalyticsEvent(AnalyticsEvent.gitAuthenticationUpdated, { provider });
} else {
trackSegmentEvent(SegmentEvent.gitAuthenticationCompleted, { provider });
trackAnalyticsEvent(AnalyticsEvent.gitAuthenticationCompleted, { provider });
}
return {};
@@ -2893,7 +2893,7 @@ export async function runAllGitRepoMigrations(): Promise<MigrationSummary> {
const logs: string[] = [];
const failedProjects: { id: string; name: string }[] = [];
const allProjects = await services.project.all();
const allProjects = await services.project.list();
const gitProjects = allProjects.filter((p): p is GitProject => models.project.isConnectedGitProject(p));
if (gitProjects.length === 0) return { logs, failedProjects, totalProjects: 0 };
@@ -2947,7 +2947,7 @@ export async function runAllGitRepoMigrations(): Promise<MigrationSummary> {
failedProjects.map(async ({ id, name }) => {
logs.push(`${ts()} [INFO] ["${name}"] Converting to local project`);
try {
const project = await services.project.getById(id);
const project = await services.project.get(id);
if (!project || !models.project.isConnectedGitProject(project)) {
logs.push(`${ts()} [WARN] ["${name}"] Project not found or already local — skipping`);
return;

View File

@@ -7,7 +7,7 @@ import { promisify } from 'node:util';
import { app, net } from 'electron';
import { services } from '~/insomnia-data';
import { SegmentEvent, trackSegmentEvent } from '~/main/analytics';
import { AnalyticsEvent, trackAnalyticsEvent } from '~/main/analytics';
import { isDevelopment } from '../common/constants';
import { validatePluginName } from '../utils/plugin';
@@ -160,7 +160,7 @@ export default async function installPlugin(pluginName: string, allowScopedPacka
}),
);
trackSegmentEvent(SegmentEvent.installPlugin, {
trackAnalyticsEvent(AnalyticsEvent.installPlugin, {
pluginName: moduleName,
pluginVersion: info.version,
});

View File

@@ -14,7 +14,6 @@ import { fnOrString } from '../../common/misc';
import {
type NunjucksParsedTagArg,
type NunjucksTagContextMenuAction,
type PluginTemplateTag,
} from '../../templating/types';
import type { extractNunjucksTagFromCoords } from '../../templating/utils';
import { invariant } from '../../utils/invariant';
@@ -121,6 +120,20 @@ export type HandleChannels =
| 'multipartBufferToArray'
| 'onDefaultBrowserOAuthRedirect'
| 'open-channel-to-hidden-browser-window'
| 'plugins.executeAction'
| 'plugins.getBundlePlugins'
| 'plugins.executePluginMainAction'
| 'plugins.getTemplateTags'
| 'plugins.runTemplateTagAction'
| 'plugins.getActivePlugins'
| 'plugins.getDocumentActions'
| 'plugins.getPlugins'
| 'plugins.getRequestActions'
| 'plugins.getRequestGroupActions'
| 'plugins.getThemes'
| 'plugins.getWorkspaceActions'
| 'plugins.reloadPlugins'
| 'plugin-ui-prompt'
| 'openPath'
| 'parseImport'
| 'readCurlResponse'
@@ -181,6 +194,8 @@ export type MainOnChannels =
| 'path.resolve'
| 'readText'
| 'restart'
| 'plugin-invoke-result'
| 'plugin-window-ready'
| 'set-hidden-window-busy-status'
| 'setMenuBarVisibility'
| 'show-nunjucks-context-menu'
@@ -194,7 +209,7 @@ export type MainOnChannels =
| 'socketIO.event.on'
| 'startExecution'
| 'trackPageView'
| 'trackSegmentEvent'
| 'trackAnalyticsEvent'
| 'updateLatestStepName'
| 'webSocket.close'
| 'webSocket.closeAll'
@@ -204,11 +219,15 @@ export type MainOnChannels =
| 'sync.cancelConflict'
| 'sync.resolveConflict'
| 'mcp.sendMCPRequest'
| 'plugin-ui-prompt-result'
| 'writeText';
export type RendererOnChannels =
| 'contextMenuCommand'
| 'db.changes'
| 'plugin-ui-alert'
| 'plugin-ui-dialog'
| 'plugin-ui-prompt'
| 'grpc.data'
| 'grpc.end'
| 'grpc.error'
@@ -242,6 +261,16 @@ export const ipcMainOnce = (
listener: (event: IpcMainEvent, ...args: any[]) => Promise<void> | any,
) => ipcMain.once(channel, listener);
interface ContextMenuTag {
templateTag: {
name: string;
displayName: string | (() => string);
args?: NunjucksParsedTagArg[];
needsEnterprisePlan?: boolean;
};
}
const getTemplateValue = (arg: NunjucksParsedTagArg) => {
if (arg.defaultValue === undefined) {
return "''";
@@ -260,7 +289,7 @@ export function registerElectronHandlers() {
options: {
key: string;
nunjucksTag: ReturnType<typeof extractNunjucksTagFromCoords>;
pluginTemplateTags?: { templateTag: PluginTemplateTag }[];
pluginTemplateTags?: { templateTag: Record<string, unknown> }[];
},
) => {
const { key, nunjucksTag, pluginTemplateTags = [] } = options;
@@ -305,7 +334,7 @@ export function registerElectronHandlers() {
},
{ type: 'separator' },
];
const localTemplate: MenuItemConstructorOptions[] = [...localTemplateTags, ...pluginTemplateTags]
const localTemplate: MenuItemConstructorOptions[] = ([...localTemplateTags, ...pluginTemplateTags] as ContextMenuTag[])
// sort alphabetically
.sort((a, b) => fnOrString(a.templateTag.displayName).localeCompare(fnOrString(b.templateTag.displayName)))
.map(l => {
@@ -332,7 +361,7 @@ export function registerElectronHandlers() {
submenu: actions?.options?.map(action => ({
label: fnOrString(action.displayName),
click: () => {
const additionalTagFields = additionalArgs.length
const additionalTagFields = additionalArgs?.length
? ', ' + additionalArgs.map(getTemplateValue).join(', ')
: '';
const displayName = action.displayName;

View File

@@ -38,9 +38,10 @@ import type {
} from '~/plugins/types';
import type { HiddenBrowserWindowBridgeAPI } from '../../entry.hidden-window';
import type { PluginTemplateTag, RenderedRequest } from '../../templating/types';
import type { SegmentEvent } from '../analytics';
import { setCurrentOrganizationId, trackPageView, trackSegmentEvent } from '../analytics';
import type { PluginsBridgeAPI } from '../../plugins/bridge-types';
import type { RenderedRequest } from '../../templating/types';
import type { AnalyticsEvent } from '../analytics';
import { setCurrentOrganizationId, trackAnalyticsEvent, trackPageView } from '../analytics';
import {
authorizeUserInDefaultBrowser,
cancelAuthorizationInDefaultBrowser,
@@ -65,6 +66,7 @@ import {
} from '../network/request-timing';
import type { SocketIOBridgeAPI } from '../network/socket-io';
import type { WebSocketBridgeAPI } from '../network/websocket';
import { registerPluginIpcHandlers } from '../plugin-window';
import { ipcMainHandle, ipcMainOn, type RendererOnChannels } from './electron';
import type { electronStorageBridgeAPI } from './electron-storage';
import extractPostmanDataDumpHandler from './extract-postman-data-dump';
@@ -189,13 +191,13 @@ export interface RendererToMainBridgeAPI {
secretStorage: secretStorageBridgeAPI;
electronStorage: electronStorageBridgeAPI;
sync: SyncBridgeAPI;
trackSegmentEvent: (options: { event: string; properties?: Record<string, unknown> }) => void;
trackAnalyticsEvent: (options: { event: string; properties?: Record<string, unknown> }) => void;
trackPageView: (options: { name: string }) => void;
setCurrentOrganizationId: (organizationId: string | undefined) => void;
showNunjucksContextMenu: (options: {
key: string;
nunjucksTag?: { template: string; range: MarkerRange };
pluginTemplateTags?: { templateTag: PluginTemplateTag }[];
pluginTemplateTags?: { templateTag: Record<string, unknown> }[];
}) => void;
showContextMenu: (options: {
key: string;
@@ -241,6 +243,8 @@ export interface RendererToMainBridgeAPI {
| { response: undefined; error: string }
>;
syncNewWorkspaceIfNeeded: typeof syncNewWorkspaceIfNeeded;
plugins: PluginsBridgeAPI;
notifyPluginPromptResult: (id: string, value: string | null) => void;
}
export function registerMainHandlers() {
@@ -456,10 +460,13 @@ export function registerMainHandlers() {
ipcMainHandle('readDir', readDir);
ipcMainHandle('readOrCreateDataDir', async (_, options: { folder: string }) => {
const dataPath = app.getPath('userData');
const folderPath = path.join(dataPath, options.folder);
const folderPath = path.join(app.getPath('userData'), options.folder);
mkdirSync(folderPath, { recursive: true });
return readDir(_, { path: folderPath });
try {
return await readDir(_, { path: folderPath });
} catch {
return [];
}
});
ipcMainHandle('curlRequest', (_, options: Parameters<typeof curlRequest>[0]) => {
@@ -470,8 +477,8 @@ export function registerMainHandlers() {
cancelCurlRequest(requestId);
});
ipcMainOn('trackSegmentEvent', (_, options: { event: SegmentEvent; properties?: Record<string, unknown> }): void => {
trackSegmentEvent(options.event, options.properties);
ipcMainOn('trackAnalyticsEvent', (_, options: { event: AnalyticsEvent; properties?: Record<string, unknown> }): void => {
trackAnalyticsEvent(options.event, options.properties);
});
ipcMainOn('trackPageView', (_, options: { name: string }): void => {
trackPageView(options.name);
@@ -718,4 +725,6 @@ export function registerMainHandlers() {
});
});
});
registerPluginIpcHandlers();
}

View File

@@ -4,7 +4,7 @@ import { app } from 'electron';
import { LLM_BACKENDS } from '~/common/constants';
import { services } from '~/insomnia-data';
import { SegmentEvent, trackSegmentEvent } from '~/main/analytics';
import { AnalyticsEvent, trackAnalyticsEvent } from '~/main/analytics';
import { ipcMainHandle } from '~/main/ipc/electron';
const LLM_PLUGIN_NAME = 'insomnia-llm';
@@ -123,7 +123,7 @@ export const getAIFeatureEnabled = async (feature: AIFeatureNames): Promise<bool
export const setAIFeatureEnabled = async (feature: AIFeatureNames, enabled: boolean): Promise<void> => {
await services.pluginData.upsertByKey(LLM_PLUGIN_NAME, `feature.${feature}`, String(enabled));
trackSegmentEvent(enabled ? SegmentEvent.aiFeatureEnabled : SegmentEvent.aiFeatureDisabled, {
trackAnalyticsEvent(enabled ? AnalyticsEvent.aiFeatureEnabled : AnalyticsEvent.aiFeatureDisabled, {
feature: feature,
set_for: 'user',
});

View File

@@ -11,7 +11,7 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { METHOD_SUBSCRIBE_RESOURCE, METHOD_UNSUBSCRIBE_RESOURCE } from '~/common/mcp-utils';
import { SegmentEvent, trackSegmentEvent } from '~/main/analytics';
import { AnalyticsEvent, trackAnalyticsEvent } from '~/main/analytics';
import { getActiveMcpClient, getReadyActiveMcpConnectionContext, writeEventLogAndNotify } from '~/main/mcp/common';
import type { CommonMcpOptions, McpMessageEventWithoutBase } from '~/main/mcp/types';
@@ -29,7 +29,7 @@ export const callTool = async (options: CommonMcpOptions & CallToolRequest['para
const mcpClient = getActiveMcpClient(requestId);
if (mcpClient) {
const response = await mcpClient.callTool(params, CompatibilityCallToolResultSchema);
trackSegmentEvent(SegmentEvent.mcpToolCalled);
trackAnalyticsEvent(AnalyticsEvent.mcpToolCalled);
return response.content;
}
return null;
@@ -49,7 +49,7 @@ export const getPrompt = async (options: CommonMcpOptions & GetPromptRequest['pa
const mcpClient = getActiveMcpClient(options.requestId);
if (mcpClient) {
const prompt = await mcpClient.getPrompt(params);
trackSegmentEvent(SegmentEvent.mcpPromptCalled);
trackAnalyticsEvent(AnalyticsEvent.mcpPromptCalled);
return prompt;
}
return null;
@@ -80,7 +80,7 @@ export const readResource = async (options: CommonMcpOptions & ReadResourceReque
const mcpClient = getActiveMcpClient(requestId);
if (mcpClient) {
const resource = await mcpClient.readResource(params);
trackSegmentEvent(SegmentEvent.mcpResourceRead);
trackAnalyticsEvent(AnalyticsEvent.mcpResourceRead);
return resource;
}
return null;

View File

@@ -21,7 +21,7 @@ import electron from 'electron';
import { getAppVersion, getProductName, REALTIME_EVENTS_CHANNELS } from '~/common/constants';
import { getMcpMethodFromMessage, METHOD_NOTIFICATION_CANCELLED } from '~/common/mcp-utils';
import { models, services } from '~/insomnia-data';
import { SegmentEvent, trackSegmentEvent } from '~/main/analytics';
import { AnalyticsEvent, trackAnalyticsEvent } from '~/main/analytics';
import {
callTool,
getPrompt,
@@ -257,7 +257,7 @@ const createTransportAndConnect = async (context: ConnectionContext, mcpClient:
}
const authDisabled = 'disabled' in mcpRequest.authentication && mcpRequest.authentication.disabled;
const isFirstConnection = !mcpRequest.connected;
trackSegmentEvent(SegmentEvent.mcpClientConnected, {
trackAnalyticsEvent(AnalyticsEvent.mcpClientConnected, {
transportType: connectionOptions.transportType,
firstTime: isFirstConnection,
...(connectionOptions.transportType === models.mcpRequest.TRANSPORT_TYPES.HTTP
@@ -455,7 +455,7 @@ const closeMcpConnection = async (options: CommonMcpOptions) => {
// Execute clear resource subscription in main process rather than UI to make sure closeAllMcpConnections method will clear subscriptions
await services.mcpRequest.clearResourceSubscriptions(requestId);
}
trackSegmentEvent(SegmentEvent.mcpClientDisconnected);
trackAnalyticsEvent(AnalyticsEvent.mcpClientDisconnected);
};
const closeAllMcpConnections = () => {

View File

@@ -95,6 +95,15 @@ const protocolName = 'socketIO';
const getEventNotificationChannel = (responseId: string) =>
`${protocolName}.${responseId}.${REALTIME_EVENTS_CHANNELS.NEW_EVENT}`;
const sendToOpenWindows = (channel: string, ...args: unknown[]) => {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed() || window.webContents.isDestroyed()) {
continue;
}
window.webContents.send(channel, ...args);
}
};
const writeEventLogAndNotify = ({
requestId,
data,
@@ -105,17 +114,17 @@ const writeEventLogAndNotify = ({
clearRequestIdMap?: boolean;
}) => {
eventLogFileStreams.get(requestId)?.write(data, () => {
const resId = requestIdToResponseIdMap.get(requestId);
if (!resId) {
return;
}
// notify all renderers of new event has been received
for (const window of BrowserWindow.getAllWindows()) {
const resId = requestIdToResponseIdMap.get(requestId);
if (resId) {
const notifyChannel = getEventNotificationChannel(resId);
notifyChannel && window.webContents.send(notifyChannel);
if (clearRequestIdMap) {
// clean up maps after last event has been written to file
requestIdToResponseIdMap.delete(requestId);
}
}
const notifyChannel = getEventNotificationChannel(resId);
sendToOpenWindows(notifyChannel);
if (clearRequestIdMap) {
// clean up maps after last event has been written to file
requestIdToResponseIdMap.delete(requestId);
}
});
};
@@ -329,9 +338,7 @@ const openSocketIOConnection = async (
const openedEvents = request.eventListeners.filter(event => event.isOpen && event.eventName);
socket.on('connect', async () => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(readyStateChannel, socket.connected);
}
sendToOpenWindows(readyStateChannel, socket.connected);
const openEvent: SocketIOpenEvent = {
_id: uuidV4(),
@@ -385,9 +392,7 @@ const openSocketIOConnection = async (
timestamp: Date.now(),
};
deleteRequestMaps(request._id, reason, closeEvent);
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(readyStateChannel, socket.connected);
}
sendToOpenWindows(readyStateChannel, socket.connected);
});
socket.on('connect_error', error => {

View File

@@ -84,6 +84,15 @@ const timelineFileStreams = new Map<string, fs.WriteStream>();
const getEventNotificationChannel = (responseId: string) =>
`${protocolName}.${responseId}.${REALTIME_EVENTS_CHANNELS.NEW_EVENT}`;
const sendToOpenWindows = (channel: string, ...args: unknown[]) => {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed() || window.webContents.isDestroyed()) {
continue;
}
window.webContents.send(channel, ...args);
}
};
const writeEventLogAndNotify = ({
requestId,
data,
@@ -94,17 +103,17 @@ const writeEventLogAndNotify = ({
clearRequestIdMap?: boolean;
}) => {
eventLogFileStreams.get(requestId)?.write(data, () => {
const resId = requestIdToResponseIdMap.get(requestId);
if (!resId) {
return;
}
// notify all renderers of new event has been received
for (const window of BrowserWindow.getAllWindows()) {
const resId = requestIdToResponseIdMap.get(requestId);
if (resId) {
const notifyChannel = getEventNotificationChannel(resId);
notifyChannel && window.webContents.send(notifyChannel);
if (clearRequestIdMap) {
// clean up maps after last event has been written to file
requestIdToResponseIdMap.delete(requestId);
}
}
const notifyChannel = getEventNotificationChannel(resId);
sendToOpenWindows(notifyChannel);
if (clearRequestIdMap) {
// clean up maps after last event has been written to file
requestIdToResponseIdMap.delete(requestId);
}
});
};
@@ -397,9 +406,7 @@ const openWebSocketConnection = async (
?.write(
JSON.stringify({ value: 'WebSocket connection established', name: 'Text', timestamp: Date.now() }) + '\n',
);
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(readyStateChannel, ws.readyState === WebSocket.OPEN);
}
sendToOpenWindows(readyStateChannel, ws.readyState === WebSocket.OPEN);
if (options.initialPayload) {
sendPayload(ws, { requestId: options.requestId, payload: options.initialPayload });
@@ -435,9 +442,7 @@ const openWebSocketConnection = async (
const message = `Closing connection with code ${code}`;
deleteRequestMaps(request._id, message, closeEvent);
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(readyStateChannel, ws.readyState === WebSocket.OPEN);
}
sendToOpenWindows(readyStateChannel, ws.readyState === WebSocket.OPEN);
});
ws.addEventListener('error', async ({ error, message }: ErrorEvent) => {
@@ -453,9 +458,7 @@ const openWebSocketConnection = async (
};
deleteRequestMaps(request._id, message, errorEvent);
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(readyStateChannel, ws.readyState === WebSocket.OPEN);
}
sendToOpenWindows(readyStateChannel, ws.readyState === WebSocket.OPEN);
if (error.code) {
createErrorResponse(
responseId,

View File

@@ -0,0 +1,316 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { app, BrowserWindow, ipcMain } from 'electron';
let pluginWindow: BrowserWindow | null = null;
let windowReady = false;
const pendingRequests = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; method: string; startedAt: number }>();
let cachedHasRequestHooks: boolean | null = null;
let cachedHasResponseHooks: boolean | null = null;
const promptPendingRequests = new Map<string, (value: string | null) => void>();
// Bridge observability counters. Kept in-memory and exposed via the
// `plugins.getBridgeMetrics` IPC handler so devs / smoke tests / support
// dumps can read the live state without scraping logs.
interface BridgeMethodStats {
ok: number;
error: number;
timeout: number;
totalDurationMs: number;
maxDurationMs: number;
}
const bridgeMetrics = {
windowStartups: 0,
windowCrashes: 0,
windowStartupMsLast: 0 as number | null,
// wall-clock time of the most recent createPluginWindow() call
lastStartupAt: 0 as number | null,
perMethod: new Map<string, BridgeMethodStats>(),
};
function recordInvocation(method: string, outcome: 'ok' | 'error' | 'timeout', durationMs: number) {
let stats = bridgeMetrics.perMethod.get(method);
if (!stats) {
stats = { ok: 0, error: 0, timeout: 0, totalDurationMs: 0, maxDurationMs: 0 };
bridgeMetrics.perMethod.set(method, stats);
}
stats[outcome] += 1;
stats.totalDurationMs += durationMs;
if (durationMs > stats.maxDurationMs) {
stats.maxDurationMs = durationMs;
}
// Single structured log line per invocation; cheap to grep, easy to ship to analytics later.
console.log(`[plugin-bridge] invoke method=${method} outcome=${outcome} duration_ms=${durationMs}`);
}
export function getBridgeMetricsSnapshot() {
const perMethod: Record<string, BridgeMethodStats & { avgDurationMs: number }> = {};
for (const [method, stats] of bridgeMetrics.perMethod) {
const calls = stats.ok + stats.error + stats.timeout;
perMethod[method] = {
...stats,
avgDurationMs: calls > 0 ? Math.round(stats.totalDurationMs / calls) : 0,
};
}
return {
windowStartups: bridgeMetrics.windowStartups,
windowCrashes: bridgeMetrics.windowCrashes,
windowStartupMsLast: bridgeMetrics.windowStartupMsLast,
windowReady,
pendingInvocations: pendingRequests.size,
perMethod,
};
}
function getMainWindow() {
return BrowserWindow.getAllWindows().find(w => !w.isDestroyed() && w.getTitle() === 'Insomnia');
}
// Registered once so that persistent `ipcMain.on` handlers don't accumulate across window recreations.
let ipcListenersRegistered = false;
function ensureIpcListeners() {
if (ipcListenersRegistered) {
return;
}
ipcListenersRegistered = true;
ipcMain.on('plugin-window-ready', event => {
if (event.sender !== pluginWindow?.webContents) {
return;
}
windowReady = true;
const startedAt = bridgeMetrics.lastStartupAt;
const startupMs = startedAt ? Date.now() - startedAt : 0;
bridgeMetrics.windowStartupMsLast = startupMs;
console.log(`[plugin-bridge] window_ready startup_ms=${startupMs}`);
});
ipcMain.on('plugin-ui-alert', (event, options: Record<string, unknown>) => {
if (event.sender !== pluginWindow?.webContents) {
return;
}
getMainWindow()?.webContents.send('plugin-ui-alert', options);
});
ipcMain.on('plugin-ui-dialog', (event, options: Record<string, unknown>) => {
if (event.sender !== pluginWindow?.webContents) {
return;
}
getMainWindow()?.webContents.send('plugin-ui-dialog', options);
});
ipcMain.handle('plugin-ui-prompt', async (event, options: Record<string, unknown>) => {
if (event.sender !== pluginWindow?.webContents) {
return null;
}
const mainWindow = getMainWindow();
if (!mainWindow) {
return null;
}
const id = randomUUID();
return new Promise<string | null>(resolve => {
const timeout = setTimeout(() => {
promptPendingRequests.delete(id);
resolve(null);
}, 60_000);
promptPendingRequests.set(id, value => {
clearTimeout(timeout);
resolve(value);
});
mainWindow.webContents.send('plugin-ui-prompt', id, options);
});
});
ipcMain.on('plugin-ui-prompt-result', (_event, { id, value }: { id: string; value: string | null }) => {
const resolve = promptPendingRequests.get(id);
if (!resolve) {
return;
}
promptPendingRequests.delete(id);
resolve(value);
});
ipcMain.on('plugin-invoke-result', (event, { id, result, error }: { id: string; result?: unknown; error?: string }) => {
if (event.sender !== pluginWindow?.webContents) {
return;
}
const pending = pendingRequests.get(id);
if (!pending) {
return;
}
pendingRequests.delete(id);
const duration = Date.now() - pending.startedAt;
if (error) {
recordInvocation(pending.method, 'error', duration);
pending.reject(new Error(error));
} else {
recordInvocation(pending.method, 'ok', duration);
pending.resolve(result);
}
});
}
export function getPluginWindow() {
return pluginWindow;
}
export function createPluginWindow() {
if (pluginWindow && !pluginWindow.isDestroyed()) {
return;
}
ensureIpcListeners();
pluginWindow = new BrowserWindow({
show: false,
title: 'PluginWindow',
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
preload: path.join(__dirname, 'entry.plugin-window-preload.min.js'),
backgroundThrottling: false,
devTools: process.env.NODE_ENV === 'development',
},
});
pluginWindow.on('closed', () => {
pluginWindow = null;
windowReady = false;
for (const [id, { reject }] of pendingRequests) {
pendingRequests.delete(id);
reject(new Error('[plugin-window] window closed'));
}
});
pluginWindow.webContents.on('render-process-gone', (_event, details) => {
bridgeMetrics.windowCrashes += 1;
console.log(`[plugin-bridge] window_crash reason=${details.reason} exit_code=${details.exitCode}`);
});
bridgeMetrics.windowStartups += 1;
bridgeMetrics.lastStartupAt = Date.now();
const pluginWindowPath = path.resolve(__dirname, 'plugin-window.html');
pluginWindow.loadFile(pluginWindowPath);
console.log(`[plugin-bridge] window_loading path=${pluginWindowPath} startups_total=${bridgeMetrics.windowStartups}`);
}
function waitForReady(timeoutMs = 10_000): Promise<void> {
if (windowReady) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const onFailLoad = () => {
clearInterval(check);
clearTimeout(timer);
reject(new Error('[plugin-window] failed to load'));
};
pluginWindow?.webContents.once('did-fail-load', onFailLoad);
const timer = setTimeout(() => {
clearInterval(check);
pluginWindow?.webContents.off('did-fail-load', onFailLoad);
reject(new Error('[plugin-window] timed out waiting for ready'));
}, timeoutMs);
const check = setInterval(() => {
if (windowReady) {
clearInterval(check);
clearTimeout(timer);
pluginWindow?.webContents.off('did-fail-load', onFailLoad);
resolve();
}
}, 50);
});
}
export async function invokeInPluginWindow(method: string, args?: unknown): Promise<unknown> {
if (!pluginWindow || pluginWindow.isDestroyed()) {
createPluginWindow();
}
await waitForReady();
return new Promise((resolve, reject) => {
const id = randomUUID();
const startedAt = Date.now();
const timeout = setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
recordInvocation(method, 'timeout', Date.now() - startedAt);
reject(new Error(`[plugin-window] timeout invoking ${method}`));
}
}, 30_000);
pendingRequests.set(id, {
method,
startedAt,
resolve: v => {
clearTimeout(timeout);
resolve(v);
},
reject: e => {
clearTimeout(timeout);
reject(e);
},
});
pluginWindow!.webContents.send('plugin-invoke', { id, method, args });
});
}
export function destroyPluginWindow() {
pluginWindow?.destroy();
pluginWindow = null;
windowReady = false;
}
export function reloadPluginsInWindow() {
if (pluginWindow && !pluginWindow.isDestroyed()) {
pluginWindow.reload();
windowReady = false;
}
}
export function registerPluginIpcHandlers() {
ipcMain.handle('plugins.getThemes', () => invokeInPluginWindow('getThemes'));
ipcMain.handle('plugins.getPlugins', () => invokeInPluginWindow('getPlugins'));
ipcMain.handle('plugins.getActivePlugins', () => invokeInPluginWindow('getActivePlugins'));
ipcMain.handle('plugins.reloadPlugins', async () => {
cachedHasRequestHooks = null;
cachedHasResponseHooks = null;
await invokeInPluginWindow('reloadPlugins');
});
ipcMain.handle('plugins.getRequestActions', () => invokeInPluginWindow('getRequestActions'));
ipcMain.handle('plugins.getRequestGroupActions', () => invokeInPluginWindow('getRequestGroupActions'));
ipcMain.handle('plugins.getWorkspaceActions', () => invokeInPluginWindow('getWorkspaceActions'));
ipcMain.handle('plugins.getDocumentActions', () => invokeInPluginWindow('getDocumentActions'));
ipcMain.handle('plugins.executeAction', (_event, args) => invokeInPluginWindow('executeAction', args));
ipcMain.handle('plugins.getTemplateTags', () => invokeInPluginWindow('getTemplateTags'));
ipcMain.handle('plugins.runTemplateTagAction', (_event, args) => invokeInPluginWindow('runTemplateTagAction', args));
ipcMain.handle('plugins.getBundlePlugins', () => invokeInPluginWindow('getBundlePlugins'));
ipcMain.handle('plugins.executePluginMainAction', (_event, args) => invokeInPluginWindow('executePluginMainAction', args));
ipcMain.handle('plugins.hasRequestHooks', async () => {
if (cachedHasRequestHooks === null) {
cachedHasRequestHooks = await invokeInPluginWindow('hasRequestHooks') as boolean;
}
return cachedHasRequestHooks;
});
ipcMain.handle('plugins.hasResponseHooks', async () => {
if (cachedHasResponseHooks === null) {
cachedHasResponseHooks = await invokeInPluginWindow('hasResponseHooks') as boolean;
}
return cachedHasResponseHooks;
});
ipcMain.handle('plugins.applyRequestHooks', (_event, args) => invokeInPluginWindow('applyRequestHooks', args));
ipcMain.handle('plugins.applyResponseHooks', (_event, args) => invokeInPluginWindow('applyResponseHooks', args));
ipcMain.handle('plugins.getBridgeMetrics', () => getBridgeMetricsSnapshot());
}
export function getAppUserDataPath() {
return app.getPath('userData');
}

View File

@@ -23,6 +23,7 @@ import { invariant } from '../utils/invariant';
import { getElectronStorage } from './electron-storage';
import { ipcMainOn } from './ipc/electron';
import { getLogDirectory } from './log';
import { createPluginWindow, destroyPluginWindow } from './plugin-window';
const DEFAULT_WIDTH = 1280;
const DEFAULT_HEIGHT = 720;
@@ -785,5 +786,12 @@ export function createWindowsAndReturnMain() {
if (!browserWindows.get('HiddenBrowserWindow')) {
createHiddenBrowserWindow();
}
// Create the plugin window after the main window finishes its initial load so
// that Playwright's firstWindow() always returns the main app window. Creating
// it on did-finish-load still parses the 12 MB bundle well before any user
// plugin call would occur.
mainWindow.webContents.once('did-finish-load', () => createPluginWindow());
return mainWindow;
}
export { destroyPluginWindow };

View File

@@ -35,10 +35,6 @@ describe('getAuthQueryParams', () => {
});
});
describe('sendCurlAndWriteTimeline()', () => {
beforeEach(async () => {
await services.project.all();
});
it('sends a generic request', async () => {
const workspace = await services.workspace.create();
const settings = await services.settings.getOrCreate();

View File

@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockRequestCtx = vi.hoisted(() => ({
getEnvironmentVariable: vi.fn().mockReturnValue(null),
hasHeader: vi.fn().mockReturnValue(false),
removeHeader: vi.fn(),
setHeader: vi.fn(),
}));
const mockPlugins = vi.hoisted(() => ({
hasRequestHooks: vi.fn(),
hasResponseHooks: vi.fn(),
applyRequestHooks: vi.fn(),
applyResponseHooks: vi.fn(),
}));
vi.mock('../../plugins/context/request', () => ({
init: vi.fn().mockReturnValue({ request: mockRequestCtx }),
}));
Object.defineProperty(globalThis, 'window', {
value: { main: { plugins: mockPlugins } },
writable: true,
configurable: true,
});
Object.defineProperty(process, 'type', {
value: 'renderer',
writable: true,
configurable: true,
});
import { _applyRequestPluginHooks, _applyResponsePluginHooks } from '../network';
const mockRenderedRequest = {
url: 'http://example.com',
headers: [],
settingSendCookies: true,
settingStoreCookies: true,
} as any;
const mockRenderedContext = {
getProjectId: () => 'test-project',
} as any;
const mockResponse = {
url: 'http://example.com',
status: 200,
} as any;
beforeEach(() => {
vi.clearAllMocks();
mockRequestCtx.getEnvironmentVariable.mockReturnValue(null);
mockPlugins.hasRequestHooks.mockResolvedValue(false);
mockPlugins.hasResponseHooks.mockResolvedValue(false);
mockPlugins.applyRequestHooks.mockResolvedValue(mockRenderedRequest);
mockPlugins.applyResponseHooks.mockResolvedValue(mockResponse);
});
describe('_applyRequestPluginHooks', () => {
it('skips applyRequestHooks when hasRequestHooks returns false', async () => {
await _applyRequestPluginHooks(mockRenderedRequest, mockRenderedContext);
expect(mockPlugins.applyRequestHooks).not.toHaveBeenCalled();
});
it('calls applyRequestHooks when hasRequestHooks returns true', async () => {
mockPlugins.hasRequestHooks.mockResolvedValue(true);
await _applyRequestPluginHooks(mockRenderedRequest, mockRenderedContext);
expect(mockPlugins.applyRequestHooks).toHaveBeenCalledOnce();
});
it('passes projectId and environment to applyRequestHooks', async () => {
mockPlugins.hasRequestHooks.mockResolvedValue(true);
await _applyRequestPluginHooks(mockRenderedRequest, mockRenderedContext);
expect(mockPlugins.applyRequestHooks).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'test-project' }),
);
});
it('propagates errors from applyRequestHooks', async () => {
mockPlugins.hasRequestHooks.mockResolvedValue(true);
mockPlugins.applyRequestHooks.mockRejectedValue(new Error('[plugin=test-plugin] sync failure'));
await expect(_applyRequestPluginHooks(mockRenderedRequest, mockRenderedContext)).rejects.toThrow('sync failure');
});
it('applies DEFAULT_HEADERS from the environment without invoking the plugin window', async () => {
mockRequestCtx.getEnvironmentVariable.mockReturnValue({ 'X-Custom': 'value' });
mockRequestCtx.hasHeader.mockReturnValue(false);
await _applyRequestPluginHooks(mockRenderedRequest, mockRenderedContext);
expect(mockRequestCtx.setHeader).toHaveBeenCalledWith('X-Custom', 'value');
expect(mockPlugins.applyRequestHooks).not.toHaveBeenCalled();
});
it('skips DEFAULT_HEADERS that already exist on the request', async () => {
mockRequestCtx.getEnvironmentVariable.mockReturnValue({ 'X-Custom': 'value' });
mockRequestCtx.hasHeader.mockReturnValue(true);
await _applyRequestPluginHooks(mockRenderedRequest, mockRenderedContext);
expect(mockRequestCtx.setHeader).not.toHaveBeenCalled();
});
it('removes a DEFAULT_HEADER when its value is "null"', async () => {
mockRequestCtx.getEnvironmentVariable.mockReturnValue({ 'X-Remove': 'null' });
mockRequestCtx.hasHeader.mockReturnValue(false);
await _applyRequestPluginHooks(mockRenderedRequest, mockRenderedContext);
expect(mockRequestCtx.removeHeader).toHaveBeenCalledWith('X-Remove');
});
});
describe('_applyResponsePluginHooks', () => {
it('returns the original response when hasResponseHooks returns false', async () => {
const result = await _applyResponsePluginHooks(mockResponse, mockRenderedRequest, mockRenderedContext);
expect(result).toBe(mockResponse);
expect(mockPlugins.applyResponseHooks).not.toHaveBeenCalled();
});
it('calls applyResponseHooks when hasResponseHooks returns true', async () => {
mockPlugins.hasResponseHooks.mockResolvedValue(true);
await _applyResponsePluginHooks(mockResponse, mockRenderedRequest, mockRenderedContext);
expect(mockPlugins.applyResponseHooks).toHaveBeenCalledOnce();
});
it('returns an error ResponsePatch instead of throwing on hook failure', async () => {
mockPlugins.hasResponseHooks.mockResolvedValue(true);
mockPlugins.applyResponseHooks.mockRejectedValue(new Error('[plugin=test-plugin] hook exploded'));
const result = await _applyResponsePluginHooks(mockResponse, mockRenderedRequest, mockRenderedContext);
expect(result).toHaveProperty('error');
expect(result.statusMessage).toBe('Error');
});
it('includes the error message in the error response', async () => {
mockPlugins.hasResponseHooks.mockResolvedValue(true);
mockPlugins.applyResponseHooks.mockRejectedValue(new Error('[plugin=test-plugin] detailed failure reason'));
const result = await _applyResponsePluginHooks(mockResponse, mockRenderedRequest, mockRenderedContext);
expect(result.error).toContain('detailed failure reason');
});
it('handles non-Error rejections without producing undefined in the error message', async () => {
mockPlugins.hasResponseHooks.mockResolvedValue(true);
mockPlugins.applyResponseHooks.mockRejectedValue('string rejection');
const result = await _applyResponsePluginHooks(mockResponse, mockRenderedRequest, mockRenderedContext);
expect(result.error).toContain('string rejection');
expect(result.error).not.toContain('undefined');
});
it('returns an error ResponsePatch for async hook rejections', async () => {
mockPlugins.hasResponseHooks.mockResolvedValue(true);
mockPlugins.applyResponseHooks.mockRejectedValue(new Error('[plugin=test-plugin] async boom'));
const result = await _applyResponsePluginHooks(mockResponse, mockRenderedRequest, mockRenderedContext);
expect(result).toHaveProperty('error');
expect(result.error).toContain('async boom');
});
it('preserves the request URL in the error response', async () => {
mockPlugins.hasResponseHooks.mockResolvedValue(true);
mockPlugins.applyResponseHooks.mockRejectedValue(new Error('[plugin=test-plugin] fail'));
const result = await _applyResponsePluginHooks(mockResponse, mockRenderedRequest, mockRenderedContext);
expect(result.url).toBe('http://example.com');
});
});

View File

@@ -25,6 +25,7 @@ import type {
Workspace,
} from '~/insomnia-data';
import { EnvironmentType, models, services } from '~/insomnia-data';
import { plugins as pluginsBridge } from '~/plugins/renderer-bridge';
import { getKVPairFromData } from '~/utils/environment-utils';
import type {
@@ -1068,62 +1069,102 @@ export const getCurrentUrl = ({ headerResults, finalUrl }: { headerResults: any;
}
};
async function _applyRequestPluginHooks(renderedRequest: RenderedRequest, renderedContext: Record<string, any>) {
export async function _applyRequestPluginHooks(renderedRequest: RenderedRequest, renderedContext: Record<string, any>) {
const newRenderedRequest = clone(renderedRequest);
for (const { plugin, hook } of await plugins.getRequestHooks()) {
const context = {
...(pluginApp.init() as Record<string, any>),
...pluginData.init(renderedContext.getProjectId()),
...(pluginStore.init(plugin) as Record<string, any>),
...(pluginRequest.init(newRenderedRequest, renderedContext) as Record<string, any>),
...(pluginNetwork.init() as Record<string, any>),
};
try {
await hook(context);
} catch (err) {
err.plugin = plugin;
throw err;
// Apply built-in default-headers hook in the renderer (no IPC needed)
const { request: reqCtx } = pluginRequest.init(newRenderedRequest, renderedContext);
const defaultHeaders = reqCtx.getEnvironmentVariable('DEFAULT_HEADERS');
if (defaultHeaders && typeof defaultHeaders === 'object' && !Array.isArray(defaultHeaders)) {
for (const name of Object.keys(defaultHeaders)) {
const value = (defaultHeaders as Record<string, any>)[name];
if (reqCtx.hasHeader(name)) {
console.log(`[header] Skip setting default header ${name}. Already set to ${value}`);
} else if (value === 'null') {
reqCtx.removeHeader(name);
console.log(`[header] Remove default header ${name}`);
} else {
reqCtx.setHeader(name, value);
console.log(`[header] Set default header ${name}: ${value}`);
}
}
}
return newRenderedRequest;
if (process.type !== 'renderer') {
for (const { plugin, hook } of await plugins.getRequestHooks()) {
const context = {
...(pluginApp.init() as Record<string, any>),
...pluginData.init(renderedContext.getProjectId()),
...(pluginStore.init(plugin) as Record<string, any>),
...(pluginRequest.init(newRenderedRequest, renderedContext) as Record<string, any>),
...(pluginNetwork.init() as Record<string, any>),
};
try {
await hook(context);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
(error as any).plugin = plugin;
throw error;
}
}
return newRenderedRequest;
}
if (!await pluginsBridge.hasRequestHooks()) {
return newRenderedRequest;
}
return pluginsBridge.applyRequestHooks({
renderedRequest: newRenderedRequest,
projectId: renderedContext.getProjectId(),
environment: renderedContext,
});
}
async function _applyResponsePluginHooks(
export async function _applyResponsePluginHooks(
response: ResponsePatch,
renderedRequest: RenderedRequest,
renderedContext: Record<string, any>,
): Promise<ResponsePatch> {
try {
const newResponse = clone(response);
const newRequest = clone(renderedRequest);
for (const { plugin, hook } of await plugins.getResponseHooks()) {
const context = {
...(pluginApp.init() as Record<string, any>),
...pluginData.init(renderedContext.getProjectId()),
...(pluginStore.init(plugin) as Record<string, any>),
...(pluginResponse.init(newResponse) as Record<string, any>),
...(pluginRequest.init(newRequest, renderedContext, true) as Record<string, any>),
...(pluginNetwork.init() as Record<string, any>),
};
try {
await hook(context);
} catch (err) {
err.plugin = plugin;
throw err;
if (process.type !== 'renderer') {
const newResponse = clone(response);
const newRequest = clone(renderedRequest);
for (const { plugin, hook } of await plugins.getResponseHooks()) {
const context = {
...(pluginApp.init() as Record<string, any>),
...pluginData.init(renderedContext.getProjectId()),
...(pluginStore.init(plugin) as Record<string, any>),
...(pluginResponse.init(newResponse) as Record<string, any>),
...(pluginRequest.init(newRequest, renderedContext, true) as Record<string, any>),
...(pluginNetwork.init() as Record<string, any>),
};
try {
await hook(context);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
(error as any).plugin = plugin;
throw error;
}
}
return newResponse;
}
return newResponse;
if (!await pluginsBridge.hasResponseHooks()) {
return response;
}
return await pluginsBridge.applyResponseHooks({
response,
renderedRequest,
projectId: renderedContext.getProjectId(),
environment: renderedContext,
});
} catch (err) {
console.log('[plugin] Response hook failed', err, response);
return {
url: renderedRequest.url,
error: `[plugin] Response hook failed plugin=${err.plugin?.name} err=${err.message}`,
error: `[plugin] Response hook failed err=${err instanceof Error ? err.message : String(err)}`,
elapsedTime: 0, // 0 because this path is hit during plugin calls
statusMessage: 'Error',
settingSendCookies: renderedRequest.settingSendCookies,

View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title>Plugin Window</title>
</head>
<body>
<script src="./entry.plugin-window.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,359 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../themes', () => ({ default: [] }));
vi.mock('../context/app', () => ({ init: vi.fn().mockReturnValue({ app: {} }) }));
vi.mock('../context/store', () => ({ init: vi.fn().mockReturnValue({ store: {} }) }));
vi.mock('../context/network', () => ({ init: vi.fn().mockReturnValue({ network: {} }) }));
vi.mock('~/insomnia-data', () => ({
services: {
settings: { get: vi.fn() },
request: { getById: vi.fn() },
cloudCredential: { getById: vi.fn(), update: vi.fn() },
workspace: { getById: vi.fn() },
oAuth2Token: { getByParentId: vi.fn() },
cookieJar: { getOrCreateForParentId: vi.fn() },
response: { getLatestForRequestId: vi.fn() },
helpers: { getResponseBodyBuffer: vi.fn() },
},
}));
import { services } from '~/insomnia-data';
import type { Plugin } from '../index';
import {
_testOnlySetPlugins,
executePluginMainAction,
getDocumentActions,
getPluginCommonContext,
getRequestActions,
getRequestGroupActions,
getRequestHooks,
getResponseHooks,
getTemplateTags,
getThemes,
getWorkspaceActions,
} from '../index';
const makePlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
name: 'test-plugin',
description: 'A test plugin',
version: '1.0.0',
directory: '/plugins/test-plugin',
config: { disabled: false },
module: {},
...overrides,
});
afterEach(() => {
_testOnlySetPlugins(null);
});
describe('getRequestHooks', () => {
it('returns empty array when no plugins are active', async () => {
_testOnlySetPlugins([]);
expect(await getRequestHooks()).toHaveLength(0);
});
it('includes plugin hooks with plugin metadata', async () => {
const hook = vi.fn();
_testOnlySetPlugins([makePlugin({ module: { requestHooks: [hook] } })]);
const hooks = await getRequestHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].hook).toBe(hook);
expect(hooks[0].plugin.name).toBe('test-plugin');
});
it('excludes hooks from disabled plugins', async () => {
_testOnlySetPlugins([makePlugin({ config: { disabled: true }, module: { requestHooks: [vi.fn()] } })]);
const hooks = await getRequestHooks();
expect(hooks).toHaveLength(0);
});
});
describe('getResponseHooks', () => {
it('returns empty array when no plugins are active', async () => {
_testOnlySetPlugins([]);
expect(await getResponseHooks()).toHaveLength(0);
});
it('includes plugin hooks with plugin metadata', async () => {
const hook = vi.fn();
_testOnlySetPlugins([makePlugin({ module: { responseHooks: [hook] } })]);
const hooks = await getResponseHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].hook).toBe(hook);
expect(hooks[0].plugin.name).toBe('test-plugin');
});
it('excludes hooks from disabled plugins', async () => {
_testOnlySetPlugins([makePlugin({ config: { disabled: true }, module: { responseHooks: [vi.fn()] } })]);
expect(await getResponseHooks()).toHaveLength(0);
});
});
describe('getRequestActions', () => {
it('returns actions with plugin metadata', async () => {
const action = vi.fn();
_testOnlySetPlugins([makePlugin({ module: { requestActions: [{ label: 'Run', action }] } })]);
const actions = await getRequestActions();
expect(actions).toHaveLength(1);
expect(actions[0].label).toBe('Run');
expect(actions[0].plugin.name).toBe('test-plugin');
});
it('excludes actions from disabled plugins', async () => {
_testOnlySetPlugins([
makePlugin({ config: { disabled: true }, module: { requestActions: [{ label: 'Run', action: vi.fn() }] } }),
]);
expect(await getRequestActions()).toHaveLength(0);
});
it('action is callable and receives the args passed by the caller', async () => {
const action = vi.fn().mockResolvedValue();
_testOnlySetPlugins([makePlugin({ module: { requestActions: [{ label: 'Run', action }] } })]);
const [{ action: retrieved }] = await getRequestActions();
const context = { app: {} };
const models = { request: { _id: 'req_1' } };
await retrieved(context as any, models as any);
expect(action).toHaveBeenCalledWith(context, models);
});
it('action errors propagate to the caller', async () => {
const action = vi.fn().mockRejectedValue(new Error('request action failed'));
_testOnlySetPlugins([makePlugin({ module: { requestActions: [{ label: 'Run', action }] } })]);
const [{ action: retrieved }] = await getRequestActions();
await expect(retrieved({} as any, {} as any)).rejects.toThrow('request action failed');
});
});
describe('getWorkspaceActions', () => {
it('returns actions with plugin metadata', async () => {
const action = vi.fn();
_testOnlySetPlugins([makePlugin({ module: { workspaceActions: [{ label: 'Export', action }] } })]);
const actions = await getWorkspaceActions();
expect(actions).toHaveLength(1);
expect(actions[0].label).toBe('Export');
expect(actions[0].plugin.name).toBe('test-plugin');
});
it('excludes actions from disabled plugins', async () => {
_testOnlySetPlugins([
makePlugin({ config: { disabled: true }, module: { workspaceActions: [{ label: 'Export', action: vi.fn() }] } }),
]);
expect(await getWorkspaceActions()).toHaveLength(0);
});
it('action is callable and receives the args passed by the caller', async () => {
const action = vi.fn().mockResolvedValue();
_testOnlySetPlugins([makePlugin({ module: { workspaceActions: [{ label: 'Export', action }] } })]);
const [{ action: retrieved }] = await getWorkspaceActions();
const context = { app: {} };
const models = { workspace: { _id: 'wrk_1' }, requestGroups: [], requests: [] };
await retrieved(context as any, models as any);
expect(action).toHaveBeenCalledWith(context, models);
});
it('action errors propagate to the caller', async () => {
const action = vi.fn().mockRejectedValue(new Error('workspace action failed'));
_testOnlySetPlugins([makePlugin({ module: { workspaceActions: [{ label: 'Export', action }] } })]);
const [{ action: retrieved }] = await getWorkspaceActions();
await expect(retrieved({} as any, {} as any)).rejects.toThrow('workspace action failed');
});
});
describe('getRequestGroupActions', () => {
it('returns actions with plugin metadata', async () => {
const action = vi.fn();
_testOnlySetPlugins([makePlugin({ module: { requestGroupActions: [{ label: 'Run All', action }] } })]);
const actions = await getRequestGroupActions();
expect(actions).toHaveLength(1);
expect(actions[0].label).toBe('Run All');
expect(actions[0].plugin.name).toBe('test-plugin');
});
it('excludes actions from disabled plugins', async () => {
_testOnlySetPlugins([
makePlugin({
config: { disabled: true },
module: { requestGroupActions: [{ label: 'Run All', action: vi.fn() }] },
}),
]);
expect(await getRequestGroupActions()).toHaveLength(0);
});
it('action is callable and receives the args passed by the caller', async () => {
const action = vi.fn().mockResolvedValue();
_testOnlySetPlugins([makePlugin({ module: { requestGroupActions: [{ label: 'Run All', action }] } })]);
const [{ action: retrieved }] = await getRequestGroupActions();
const context = { app: {} };
const models = { requestGroup: { _id: 'grp_1' }, requests: [] };
await retrieved(context as any, models as any);
expect(action).toHaveBeenCalledWith(context, models);
});
it('action errors propagate to the caller', async () => {
const action = vi.fn().mockRejectedValue(new Error('group action failed'));
_testOnlySetPlugins([makePlugin({ module: { requestGroupActions: [{ label: 'Run All', action }] } })]);
const [{ action: retrieved }] = await getRequestGroupActions();
await expect(retrieved({} as any, {} as any)).rejects.toThrow('group action failed');
});
});
describe('getDocumentActions', () => {
it('returns actions with plugin metadata', async () => {
const action = vi.fn();
_testOnlySetPlugins([makePlugin({ module: { documentActions: [{ label: 'Lint', action }] } })]);
const actions = await getDocumentActions();
expect(actions).toHaveLength(1);
expect(actions[0].label).toBe('Lint');
expect(actions[0].plugin.name).toBe('test-plugin');
});
it('excludes actions from disabled plugins', async () => {
_testOnlySetPlugins([
makePlugin({ config: { disabled: true }, module: { documentActions: [{ label: 'Lint', action: vi.fn() }] } }),
]);
expect(await getDocumentActions()).toHaveLength(0);
});
it('action is callable and receives the args passed by the caller', async () => {
const action = vi.fn().mockResolvedValue();
_testOnlySetPlugins([makePlugin({ module: { documentActions: [{ label: 'Lint', action }] } })]);
const [{ action: retrieved }] = await getDocumentActions();
const context = { app: {} };
const spec = { contents: 'openapi: 3.0.0' };
await retrieved(context as any, spec as any);
expect(action).toHaveBeenCalledWith(context, spec);
});
it('action errors propagate to the caller — the plugin layer does not swallow them', async () => {
const action = vi.fn().mockRejectedValue(new Error('lint failed'));
_testOnlySetPlugins([makePlugin({ module: { documentActions: [{ label: 'Lint', action }] } })]);
const [{ action: retrieved }] = await getDocumentActions();
await expect(retrieved({} as any, {} as any)).rejects.toThrow('lint failed');
});
it('supports hideAfterClick flag', async () => {
_testOnlySetPlugins([
makePlugin({ module: { documentActions: [{ label: 'Lint', action: vi.fn(), hideAfterClick: true }] } }),
]);
const [item] = await getDocumentActions();
expect(item.hideAfterClick).toBe(true);
});
});
describe('getTemplateTags', () => {
it('returns template tags with plugin metadata', async () => {
const tag = { name: 'env', displayName: 'Environment', run: vi.fn() };
_testOnlySetPlugins([makePlugin({ module: { templateTags: [tag] } })]);
const tags = await getTemplateTags();
expect(tags).toHaveLength(1);
expect(tags[0].templateTag).toBe(tag);
expect(tags[0].plugin.name).toBe('test-plugin');
});
it('excludes tags from disabled plugins', async () => {
_testOnlySetPlugins([
makePlugin({ config: { disabled: true }, module: { templateTags: [{ name: 'env', run: vi.fn() }] } }),
]);
expect(await getTemplateTags()).toHaveLength(0);
});
it('run() is callable and errors propagate — the plugin layer does not catch them', async () => {
const run = vi.fn().mockRejectedValue(new Error('tag run failed'));
_testOnlySetPlugins([makePlugin({ module: { templateTags: [{ name: 'env', run }] } })]);
const [{ templateTag }] = await getTemplateTags();
await expect(templateTag.run({} as any, [])).rejects.toThrow('tag run failed');
});
});
describe('getThemes', () => {
it('returns plugin themes with plugin metadata', async () => {
const theme = { name: 'dracula', displayName: 'Dracula', theme: { background: { default: '#282a36' } } };
_testOnlySetPlugins([makePlugin({ module: { themes: [theme] } })]);
const themes = await getThemes();
expect(themes).toHaveLength(1);
expect(themes[0].theme).toBe(theme);
expect(themes[0].plugin.name).toBe('test-plugin');
});
it('excludes themes from disabled plugins', async () => {
_testOnlySetPlugins([
makePlugin({ config: { disabled: true }, module: { themes: [{ name: 'dracula', theme: {} }] } }),
]);
expect(await getThemes()).toHaveLength(0);
});
});
describe('getPluginCommonContext', () => {
it('returns an object with the expected top-level keys', () => {
const ctx = getPluginCommonContext({ plugin: { name: 'test-plugin' } });
expect(ctx).toHaveProperty('app');
expect(ctx).toHaveProperty('store');
expect(ctx).toHaveProperty('network');
expect(ctx).toHaveProperty('util');
});
});
describe('executePluginMainAction', () => {
// @kong/insomnia-plugin-external-vault is a real bundlePlugin name from config.json
const bundlePluginName = '@kong/insomnia-plugin-external-vault';
beforeEach(() => {
vi.mocked(services.settings.get).mockResolvedValue({ pluginsAllowElevatedAccess: true } as any);
});
afterEach(() => {
vi.clearAllMocks();
});
it('executes the matching action and returns its result', async () => {
const action = vi.fn().mockResolvedValue('action-result');
_testOnlySetPlugins([
makePlugin({
name: bundlePluginName,
directory: '',
module: { unsafePluginMainActions: [{ name: 'doThing', action }] },
}),
]);
const result = await executePluginMainAction({ pluginName: bundlePluginName, actionName: 'doThing' });
expect(result).toBe('action-result');
expect(action).toHaveBeenCalledOnce();
});
it('throws when the plugin is not found', async () => {
_testOnlySetPlugins([]);
await expect(executePluginMainAction({ pluginName: bundlePluginName, actionName: 'doThing' })).rejects.toThrow(
`Plugin ${bundlePluginName} not found`,
);
});
it('throws when the action name is not found in the plugin', async () => {
_testOnlySetPlugins([
makePlugin({
name: bundlePluginName,
directory: '',
module: { unsafePluginMainActions: [{ name: 'otherAction', action: vi.fn() }] },
}),
]);
await expect(executePluginMainAction({ pluginName: bundlePluginName, actionName: 'doThing' })).rejects.toThrow(
'Action doThing not found',
);
});
});

View File

@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../themes', () => ({ default: [] }));
vi.mock('../context/app', () => ({ init: vi.fn().mockReturnValue({ app: {} }) }));
vi.mock('../context/data', () => ({ init: vi.fn().mockReturnValue({ data: {} }) }));
vi.mock('../context/store', () => ({ init: vi.fn().mockReturnValue({ store: {} }) }));
vi.mock('../context/network', () => ({ init: vi.fn().mockReturnValue({ network: {} }) }));
vi.mock('../context/request', () => ({ init: vi.fn().mockReturnValue({ request: {} }) }));
vi.mock('../context/response', () => ({ init: vi.fn().mockReturnValue({ response: {} }) }));
vi.mock('~/insomnia-data', () => ({
services: {
settings: { get: vi.fn() },
request: { getById: vi.fn() },
cloudCredential: { getById: vi.fn(), update: vi.fn() },
workspace: { getById: vi.fn() },
oAuth2Token: { getByParentId: vi.fn() },
cookieJar: { getOrCreateForParentId: vi.fn() },
response: { getLatestForRequestId: vi.fn() },
helpers: { getResponseBodyBuffer: vi.fn() },
},
}));
import type { Plugin } from '../index';
import { _testOnlySetPlugins } from '../index';
import { invokePluginMethod } from '../invoke-method';
const makePlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
name: 'test-plugin',
description: 'A test plugin',
version: '1.0.0',
directory: '/plugins/test-plugin',
config: { disabled: false },
module: {},
...overrides,
});
afterEach(() => {
_testOnlySetPlugins(null);
vi.clearAllMocks();
});
describe('invokePluginMethod', () => {
it('serializes request action metadata', async () => {
_testOnlySetPlugins([makePlugin({ module: { requestActions: [{ label: 'Run', icon: 'bolt', action: vi.fn() }] } })]);
await expect(invokePluginMethod('getRequestActions')).resolves.toEqual([
{ label: 'Run', icon: 'bolt', pluginName: 'test-plugin' },
]);
});
it('executes the matching action locally', async () => {
const action = vi.fn().mockResolvedValue(null);
_testOnlySetPlugins([makePlugin({ module: { requestActions: [{ label: 'Run', action }] } })]);
await expect(
invokePluginMethod('executeAction', {
type: 'request',
pluginName: 'test-plugin',
label: 'Run',
projectId: 'proj_1',
domainData: { request: { _id: 'req_1' } },
}),
).resolves.toBeNull();
expect(action).toHaveBeenCalledWith(expect.objectContaining({ app: {}, data: {}, store: {}, network: {} }), {
request: { _id: 'req_1' },
});
});
it('preserves plugin-prefixed request hook errors', async () => {
const hook = vi.fn().mockRejectedValue(new Error('boom'));
_testOnlySetPlugins([makePlugin({ module: { requestHooks: [hook] } })]);
await expect(
invokePluginMethod('applyRequestHooks', {
renderedRequest: { url: 'https://example.com' } as any,
projectId: 'proj_1',
environment: {},
}),
).rejects.toThrow('[plugin=test-plugin] boom');
});
});

View File

@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { Plugin } from '../index';
// No mock of '../themes' here — this file tests the built-in theme baseline.
import { _testOnlySetPlugins, getThemes } from '../index';
const makePlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
name: 'test-plugin',
description: 'A test plugin',
version: '1.0.0',
directory: '/plugins/test-plugin',
config: { disabled: false },
module: {},
...overrides,
});
afterEach(() => {
_testOnlySetPlugins(null);
});
describe('getThemes — built-in themes', () => {
it('returns all 18 built-in themes when no plugins are active', async () => {
_testOnlySetPlugins([]);
const themes = await getThemes();
expect(themes).toHaveLength(18);
});
it('every built-in theme has a name and displayName', async () => {
_testOnlySetPlugins([]);
const themes = await getThemes();
for (const { theme } of themes) {
expect(theme).toHaveProperty('name');
expect(theme).toHaveProperty('displayName');
expect(typeof theme.name).toBe('string');
expect(theme.name.length).toBeGreaterThan(0);
}
});
it('built-in theme entries carry the correct plugin metadata', async () => {
_testOnlySetPlugins([]);
const themes = await getThemes();
for (const entry of themes) {
expect(entry.plugin.description).toBe('Built-in themes');
expect(entry.plugin.name).toBe(entry.theme.name);
}
});
});
describe('getThemes — merge with plugin themes', () => {
it('plugin themes appear after built-in themes', async () => {
const pluginTheme = { name: 'dracula', displayName: 'Dracula', theme: {} };
_testOnlySetPlugins([makePlugin({ module: { themes: [pluginTheme] } })]);
const themes = await getThemes();
expect(themes).toHaveLength(19); // 18 built-in + 1 plugin
expect(themes[18].theme).toBe(pluginTheme);
expect(themes[18].plugin.name).toBe('test-plugin');
});
it('multiple plugins contribute themes independently', async () => {
const themeA = { name: 'theme-a', displayName: 'Theme A', theme: {} };
const themeB = { name: 'theme-b', displayName: 'Theme B', theme: {} };
_testOnlySetPlugins([
makePlugin({ name: 'plugin-a', module: { themes: [themeA] } }),
makePlugin({ name: 'plugin-b', module: { themes: [themeB] } }),
]);
const themes = await getThemes();
expect(themes).toHaveLength(20);
expect(themes.find(t => t.theme === themeA)?.plugin.name).toBe('plugin-a');
expect(themes.find(t => t.theme === themeB)?.plugin.name).toBe('plugin-b');
});
it('disabled plugin themes do not appear in the list', async () => {
_testOnlySetPlugins([
makePlugin({
config: { disabled: true },
module: { themes: [{ name: 'hidden', displayName: 'Hidden', theme: {} }] },
}),
]);
const themes = await getThemes();
expect(themes).toHaveLength(18); // only built-in
});
});

View File

@@ -0,0 +1,108 @@
import type { ResponsePatch } from '../main/network/libcurl-promise';
import type { RenderedRequest } from '../templating/types';
import type { PluginTheme } from './misc';
export interface SerializablePlugin {
name: string;
description: string;
version: string;
directory: string;
config: { disabled: boolean };
}
export interface SerializableTheme {
plugin: SerializablePlugin;
theme: PluginTheme;
}
export interface SerializableActionMeta {
label: string;
icon?: string;
pluginName: string;
}
export interface SerializableDocumentActionMeta {
label: string;
pluginName: string;
hideAfterClick?: boolean;
}
export interface SerializableTemplateTagMeta {
pluginName: string;
templateTag: Record<string, unknown>;
}
export interface RunTemplateTagActionArgs {
pluginName: string;
tagName: string;
actionName: string;
}
export type PluginActionType = 'request' | 'requestGroup' | 'workspace' | 'document';
export interface ExecutePluginActionArgs {
type: PluginActionType;
pluginName: string;
label: string;
projectId: string;
domainData: unknown;
}
export interface ApplyRequestHooksArgs {
renderedRequest: RenderedRequest;
projectId: string;
environment: Record<string, any>;
}
export interface ApplyResponseHooksArgs {
response: ResponsePatch;
renderedRequest: RenderedRequest;
projectId: string;
environment: Record<string, any>;
}
export interface ExecutePluginMainActionArgs {
pluginName: string;
actionName: string;
context?: Record<string, any>;
params?: Record<string, any>;
}
export interface PluginsBridgeAPI {
getThemes: () => Promise<SerializableTheme[]>;
getPlugins: () => Promise<SerializablePlugin[]>;
getActivePlugins: () => Promise<SerializablePlugin[]>;
reloadPlugins: () => Promise<void>;
getRequestActions: () => Promise<SerializableActionMeta[]>;
getRequestGroupActions: () => Promise<SerializableActionMeta[]>;
getWorkspaceActions: () => Promise<SerializableActionMeta[]>;
getDocumentActions: () => Promise<SerializableDocumentActionMeta[]>;
executeAction: (args: ExecutePluginActionArgs) => Promise<void>;
getTemplateTags: () => Promise<SerializableTemplateTagMeta[]>;
runTemplateTagAction: (args: RunTemplateTagActionArgs) => Promise<void>;
getBundlePlugins: () => Promise<SerializablePlugin[]>;
executePluginMainAction: (args: ExecutePluginMainActionArgs) => Promise<unknown>;
hasRequestHooks: () => Promise<boolean>;
hasResponseHooks: () => Promise<boolean>;
applyRequestHooks: (args: ApplyRequestHooksArgs) => Promise<RenderedRequest>;
applyResponseHooks: (args: ApplyResponseHooksArgs) => Promise<ResponsePatch>;
getBridgeMetrics: () => Promise<PluginBridgeMetrics>;
}
export interface PluginBridgeMethodMetrics {
ok: number;
error: number;
timeout: number;
totalDurationMs: number;
maxDurationMs: number;
avgDurationMs: number;
}
export interface PluginBridgeMetrics {
windowStartups: number;
windowCrashes: number;
windowStartupMsLast: number | null;
windowReady: boolean;
pendingInvocations: number;
perMethod: Record<string, PluginBridgeMethodMetrics>;
}

View File

@@ -111,6 +111,10 @@ export type ColorScheme = 'default' | 'light' | 'dark';
let plugins: Plugin[] | null | undefined = null;
export function _testOnlySetPlugins(p: Plugin[] | null) {
plugins = p;
}
export async function init() {
await reloadPlugins();
}
@@ -458,40 +462,7 @@ export async function executePluginMainAction({
}
export async function getRequestHooks(): Promise<RequestHook[]> {
let functions: RequestHook[] = [
{
plugin: {
name: 'default-headers',
description: 'Set default headers for all requests',
version: '0.0.0',
directory: '',
config: {
disabled: false,
},
module: {},
},
hook: context => {
const headers = context.request.getEnvironmentVariable('DEFAULT_HEADERS');
if (!headers) {
return;
}
for (const name of Object.keys(headers)) {
const value = headers[name];
if (context.request.hasHeader(name)) {
console.log(`[header] Skip setting default header ${name}. Already set to ${value}`);
continue;
}
if (value === 'null') {
context.request.removeHeader(name);
console.log(`[header] Remove default header ${name}`);
} else {
context.request.setHeader(name, value);
console.log(`[header] Set default header ${name}: ${value}`);
}
}
},
},
];
let functions: RequestHook[] = [];
for (const plugin of await getActivePlugins()) {
const moreFunctions = plugin.module.requestHooks || [];

View File

@@ -0,0 +1,241 @@
import type {
ApplyRequestHooksArgs,
ApplyResponseHooksArgs,
ExecutePluginActionArgs,
ExecutePluginMainActionArgs,
RunTemplateTagActionArgs,
} from './bridge-types';
import * as pluginApp from './context/app';
import * as pluginData from './context/data';
import * as pluginNetwork from './context/network';
import * as pluginRequest from './context/request';
import * as pluginResponse from './context/response';
import * as pluginStore from './context/store';
import type { Plugin } from './index';
import {
executePluginMainAction,
getActivePlugins,
getBundlePlugins,
getDocumentActions,
getPlugins,
getRequestActions,
getRequestGroupActions,
getRequestHooks,
getResponseHooks,
getTemplateTags,
getThemes,
getWorkspaceActions,
reloadPlugins,
} from './index';
export type PluginInvokeMethod =
| 'getThemes'
| 'getPlugins'
| 'getActivePlugins'
| 'reloadPlugins'
| 'getRequestActions'
| 'getRequestGroupActions'
| 'getWorkspaceActions'
| 'getDocumentActions'
| 'executeAction'
| 'getTemplateTags'
| 'runTemplateTagAction'
| 'getBundlePlugins'
| 'executePluginMainAction'
| 'hasRequestHooks'
| 'hasResponseHooks'
| 'applyRequestHooks'
| 'applyResponseHooks';
function serializePlugin(p: Plugin) {
return {
name: p.name,
description: p.description,
version: p.version,
directory: p.directory,
config: p.config,
};
}
export async function invokePluginMethod(method: PluginInvokeMethod, args?: unknown): Promise<unknown> {
switch (method) {
case 'getThemes': {
const themes = await getThemes();
return themes.map(({ plugin, theme }) => ({ plugin: serializePlugin(plugin), theme }));
}
case 'getPlugins': {
const plugins = await getPlugins();
return plugins.map(serializePlugin);
}
case 'getActivePlugins': {
const plugins = await getActivePlugins();
return plugins.map(serializePlugin);
}
case 'reloadPlugins': {
await reloadPlugins();
return null;
}
case 'getRequestActions': {
const actions = await getRequestActions();
return actions.map(a => ({ label: a.label, icon: a.icon, pluginName: a.plugin.name }));
}
case 'getRequestGroupActions': {
const actions = await getRequestGroupActions();
return actions.map(a => ({ label: a.label, icon: a.icon, pluginName: a.plugin.name }));
}
case 'getWorkspaceActions': {
const actions = await getWorkspaceActions();
return actions.map(a => ({ label: a.label, icon: a.icon, pluginName: a.plugin.name }));
}
case 'getDocumentActions': {
const actions = await getDocumentActions();
return actions.map(a => ({ label: a.label, hideAfterClick: a.hideAfterClick, pluginName: a.plugin.name }));
}
case 'executeAction': {
const { type, pluginName, label, projectId, domainData } = args as ExecutePluginActionArgs;
let allActions: any[];
switch (type) {
case 'request': {
allActions = await getRequestActions();
break;
}
case 'requestGroup': {
allActions = await getRequestGroupActions();
break;
}
case 'workspace': {
allActions = await getWorkspaceActions();
break;
}
case 'document': {
allActions = await getDocumentActions();
break;
}
default: {
throw new Error(`[plugin-window] Unknown action type: ${type}`);
}
}
const entry = allActions.find(a => a.plugin.name === pluginName && a.label === label);
if (!entry) {
throw new Error(`[plugin-window] Action not found: ${pluginName}/${label}`);
}
const context = {
...pluginApp.init(),
...pluginData.init(projectId),
...(pluginStore.init(entry.plugin) as Record<string, any>),
...(pluginNetwork.init() as Record<string, any>),
};
await entry.action(context, domainData);
return null;
}
case 'getBundlePlugins': {
const plugins = await getBundlePlugins();
return plugins.map(serializePlugin);
}
case 'executePluginMainAction': {
return executePluginMainAction(args as ExecutePluginMainActionArgs);
}
case 'getTemplateTags': {
const tags = await getTemplateTags();
return tags.map(({ plugin, templateTag }) => ({
pluginName: plugin.name,
// eslint-disable-next-line unicorn/prefer-structured-clone
templateTag: JSON.parse(JSON.stringify(templateTag)),
}));
}
case 'runTemplateTagAction': {
const { pluginName, tagName, actionName } = args as RunTemplateTagActionArgs;
const tags = await getTemplateTags();
const tag = tags.find(t => t.plugin.name === pluginName && t.templateTag.name === tagName);
if (!tag) {
throw new Error(`[plugin-window] Template tag not found: ${pluginName}/${tagName}`);
}
const action = tag.templateTag.actions?.find((a: any) => a.name === actionName);
if (!action) {
throw new Error(`[plugin-window] Tag action not found: ${actionName}`);
}
await action.run(pluginStore.init(tag.plugin));
return null;
}
case 'hasRequestHooks': {
const hooks = await getRequestHooks();
return hooks.length > 0;
}
case 'hasResponseHooks': {
const hooks = await getResponseHooks();
return hooks.length > 0;
}
case 'applyRequestHooks': {
const { renderedRequest, projectId, environment } = args as ApplyRequestHooksArgs;
const newRenderedRequest = { ...renderedRequest };
const renderedContext = { ...environment, getProjectId: () => projectId };
for (const { plugin, hook } of await getRequestHooks()) {
const context = {
...pluginApp.init(),
...pluginData.init(projectId),
...(pluginStore.init(plugin) as Record<string, any>),
...(pluginRequest.init(newRenderedRequest as any, renderedContext) as Record<string, any>),
...(pluginNetwork.init() as Record<string, any>),
};
try {
await hook(context);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
throw new Error(`[plugin=${plugin.name}] ${error.message}`);
}
}
return newRenderedRequest;
}
case 'applyResponseHooks': {
const { response, renderedRequest, projectId, environment } = args as ApplyResponseHooksArgs;
const newResponse = { ...response };
const newRequest = { ...renderedRequest };
const renderedContext = { ...environment, getProjectId: () => projectId };
for (const { plugin, hook } of await getResponseHooks()) {
const context = {
...pluginApp.init(),
...pluginData.init(projectId),
...(pluginStore.init(plugin) as Record<string, any>),
...(pluginResponse.init(newResponse) as Record<string, any>),
...(pluginRequest.init(newRequest as any, renderedContext, true) as Record<string, any>),
...(pluginNetwork.init() as Record<string, any>),
};
try {
await hook(context);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
throw new Error(`[plugin=${plugin.name}] ${error.message}`);
}
}
return newResponse;
}
default: {
throw new Error(`[plugin-window] Unknown method: ${method}`);
}
}
}

View File

@@ -0,0 +1,52 @@
import type { PluginBridgeMetrics, PluginsBridgeAPI } from './bridge-types';
import { invokePluginMethod } from './invoke-method';
// Phase 1a rollback switch: set INSOMNIA_ENABLE_PLUGIN_BRIDGE=false to fall
// back to running plugins directly in the renderer (legacy behaviour).
// This module lives in the renderer bundle (not the preload) so the heavy
// plugin-system deps it pulls in don't inflate the preload.
const bridgeEnabled = process.env.INSOMNIA_ENABLE_PLUGIN_BRIDGE !== 'false';
function call<M extends keyof Omit<PluginsBridgeAPI, 'getBridgeMetrics'>>(
method: M,
args?: Parameters<PluginsBridgeAPI[M]>[0],
): ReturnType<PluginsBridgeAPI[M]> {
if (bridgeEnabled) {
const fn = (window.main.plugins[method] as (...a: any[]) => any);
return fn(args) as ReturnType<PluginsBridgeAPI[M]>;
}
return invokePluginMethod(method as any, args) as ReturnType<PluginsBridgeAPI[M]>;
}
const emptyBridgeMetrics: PluginBridgeMetrics = {
windowStartups: 0,
windowCrashes: 0,
windowStartupMsLast: null,
windowReady: false,
pendingInvocations: 0,
perMethod: {},
};
export const plugins: PluginsBridgeAPI = {
getThemes: () => call('getThemes'),
getPlugins: () => call('getPlugins'),
getActivePlugins: () => call('getActivePlugins'),
reloadPlugins: () => call('reloadPlugins'),
getRequestActions: () => call('getRequestActions'),
getRequestGroupActions: () => call('getRequestGroupActions'),
getWorkspaceActions: () => call('getWorkspaceActions'),
getDocumentActions: () => call('getDocumentActions'),
executeAction: args => call('executeAction', args),
getTemplateTags: () => call('getTemplateTags'),
runTemplateTagAction: args => call('runTemplateTagAction', args),
getBundlePlugins: () => call('getBundlePlugins'),
executePluginMainAction: args => call('executePluginMainAction', args),
hasRequestHooks: () => call('hasRequestHooks'),
hasResponseHooks: () => call('hasResponseHooks'),
applyRequestHooks: args => call('applyRequestHooks', args),
applyResponseHooks: args => call('applyResponseHooks', args),
getBridgeMetrics: () =>
bridgeEnabled
? window.main.plugins.getBridgeMetrics()
: Promise.resolve(emptyBridgeMetrics),
};

View File

@@ -24,9 +24,10 @@ import { useLatest } from 'react-use';
import { EXTERNAL_VAULT_PLUGIN_NAME, isDevelopment } from '~/common/constants';
import type { Settings, UserSession } from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import { executePluginMainAction, reloadPlugins } from '~/plugins';
import { executePluginMainAction } from '~/plugins';
import { createPlugin } from '~/plugins/create';
import { setTheme } from '~/plugins/misc';
import { plugins } from '~/plugins/renderer-bridge';
import { useAuthorizeActionFetcher } from '~/routes/auth.authorize';
import { useDefaultBrowserRedirectActionFetcher } from '~/routes/auth.default-browser-redirect';
import { useLogoutFetcher } from '~/routes/auth.logout';
@@ -35,7 +36,7 @@ import {
GIT_PROVIDER_COMPLETE_SIGN_IN_FETCHER_KEY,
useGitProviderCompleteSignInFetcher,
} from '~/routes/git-credentials.complete-sign-in';
import { PENDING_IMPORT_ATTRIBUTION_KEY, SegmentEvent, trackImportEvent } from '~/ui/analytics';
import { AnalyticsEvent, PENDING_IMPORT_ATTRIBUTION_KEY, trackImportEvent } from '~/ui/analytics';
import { getLoginUrl } from '~/ui/auth-session-provider.client';
import { CopyButton } from '~/ui/components/base/copy-button';
import { Link } from '~/ui/components/base/link';
@@ -161,7 +162,7 @@ export const useRootLoaderData = () => {
export async function clientLoader(_args: Route.ClientLoaderArgs) {
const settings = await services.settings.get();
const workspaceCount = await services.workspace.count();
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
const cloudCredentials = await services.cloudCredential.all();
return {
@@ -203,6 +204,11 @@ export const Layout = ({ children }: { children: React.ReactNode }) => {
*
insomnia://*
;
frame-src
blob:
*
insomnia://*
;
script-src
'self'
'unsafe-eval'
@@ -382,14 +388,14 @@ const Root = () => {
);
}
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
if (!userSession.id) {
window.sessionStorage.setItem('pendingDeepLinkAfterAuthorize', url);
window.localStorage.setItem('logoutMessage', 'Please log in to import this resource.');
trackImportEvent(SegmentEvent.importLoginRequired);
trackImportEvent(AnalyticsEvent.importLoginRequired);
return navigate(href('/auth/login'));
}
trackImportEvent(SegmentEvent.importStarted, { source: 'import-url' });
trackImportEvent(AnalyticsEvent.importStarted, { source: 'import-url' });
if (params.uri) {
return setImportObject({
@@ -477,7 +483,7 @@ const Root = () => {
await services.settings.update(settings, {
theme: parsedTheme.name,
});
await reloadPlugins();
await plugins.reloadPlugins();
await setTheme(parsedTheme.name);
showModal(SettingsModal, { tab: 'themes' });
}
@@ -511,7 +517,7 @@ const Root = () => {
if (urlWithoutParams === 'insomnia://app/open/organization') {
// if user is logged out, navigate to authorize instead
// gracefully handle open org in app from browser
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
if (!userSession.id || userSession.id === '') {
const url = new URL(getLoginUrl());
window.main.openInBrowser(url.toString());
@@ -635,7 +641,7 @@ const Root = () => {
if (pendingDeepLink && organizationId && organizationId !== models.organization.SCRATCHPAD_ORGANIZATION_ID) {
window.sessionStorage.removeItem('pendingDeepLinkAfterAuthorize');
window.sessionStorage.setItem('suppressWelcomeModals', 'true');
trackImportEvent(SegmentEvent.importResumedAfterLogin);
trackImportEvent(AnalyticsEvent.importResumedAfterLogin);
window.main.openDeepLink(pendingDeepLink);
}
}, [organizationId]);

View File

@@ -1,6 +1,6 @@
import { href } from 'react-router';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { showToast } from '~/ui/components/toast-notification';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -38,8 +38,8 @@ export async function clientAction(args: Route.ClientActionArgs) {
.join('\n'),
});
window.main.trackSegmentEvent({
event: SegmentEvent.recommendCommitsGenerated,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.recommendCommitsGenerated,
properties: {
file_count: commits?.map(commit => commit.files?.length || 0)?.reduce((a, b) => a + b, 0),
group_count: commits?.length || 0,

View File

@@ -4,7 +4,7 @@ import { Button, Heading } from 'react-aria-components';
import { href, redirect, useFetchers, useNavigate } from 'react-router';
import { services } from '~/insomnia-data';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { getLoginUrl, submitAuthCode } from '~/ui/auth-session-provider.client';
import { Icon } from '~/ui/components/icon';
import { validateVaultKey } from '~/ui/vault-key.client';
@@ -32,18 +32,18 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
};
}
console.log('Login successful');
window.main.trackSegmentEvent({
event: SegmentEvent.loginSuccess,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.loginSuccess,
});
window.localStorage.setItem('hasUserLoggedInBefore', 'true');
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
const { accountId, id: sessionId } = userSession;
try {
// check vault salt exists in server
const { salt: vaultSalt } = await getVault({ sessionId });
if (vaultSalt) {
// save vault salt to session
await services.userSession.update(userSession, { vaultSalt });
await services.userSession.update({ vaultSalt });
// get vault key saved in local
const localVaultKey = await getVaultKeyFromStorage(accountId);
if (localVaultKey) {
@@ -52,7 +52,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
if (validateResult) {
// Encrypt vault key and save encrypted vault key & raw vault salt to session
const encryptedVaultKey = await window.main.secretStorage.encryptString(localVaultKey);
await services.userSession.update(userSession, { vaultKey: encryptedVaultKey, vaultSalt });
await services.userSession.update({ vaultKey: encryptedVaultKey, vaultSalt });
}
}
}

View File

@@ -10,7 +10,7 @@ import type { Route } from './+types/auth.clear-vault-key';
export async function clientAction({ request }: Route.ClientActionArgs) {
const { organizations = [], sessionId: resetVaultClientSessionId } = await request.json();
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
const { id: sessionId } = userSession;
const { salt: newVaultSalt } =
(await getVault({ sessionId }).catch(error => {
@@ -21,7 +21,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
// remove all secret environment variables
await services.environment.removeAllSecrets(organizations);
// Update vault salt and delete vault key from session
await services.userSession.update(userSession, { vaultSalt: newVaultSalt, vaultKey: '' });
await services.userSession.update({ vaultSalt: newVaultSalt, vaultKey: '' });
// show notification
electron.ipcRenderer.emit('show-toast', null, {
content: {

View File

@@ -3,7 +3,7 @@ import { Button } from 'react-aria-components';
import { href, redirect, useNavigate } from 'react-router';
import { models } from '~/insomnia-data';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { getLoginUrl } from '~/ui/auth-session-provider.client';
import { Icon } from '~/ui/components/icon';
import { Tooltip } from '~/ui/components/tooltip';
@@ -152,8 +152,8 @@ const Component = () => {
<Button
onPress={() => {
window.main.trackSegmentEvent({
event: SegmentEvent.selectScratchpad,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.selectScratchpad,
});
navigate(
href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug', {

View File

@@ -6,11 +6,11 @@ import { createFetcherSubmitHook } from '~/utils/router';
export async function clientAction(_args: ActionFunctionArgs) {
try {
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
const { id: sessionId } = userSession;
const { salt: vaultSalt } = await getVault({ sessionId });
if (vaultSalt) {
await services.userSession.update(userSession, { vaultSalt });
await services.userSession.update({ vaultSalt });
return vaultSalt;
}
} catch (error) {

View File

@@ -6,7 +6,7 @@ import { createFetcherSubmitHook } from '~/utils/router';
export async function clientAction({ request }: ActionFunctionArgs) {
const { vaultKey, saveVaultKey: saveVaultKeyLocally = false } = await request.json();
const userSession = await services.userSession.getOrCreate();
const userSession = await services.userSession.get();
const { vaultSalt, accountId } = userSession;
if (!vaultSalt) {

View File

@@ -3,7 +3,7 @@ import { href } from 'react-router';
import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants';
import type { CloudProviderCredential } from '~/insomnia-data';
import { services } from '~/insomnia-data';
import { executePluginMainAction } from '~/plugins';
import { plugins } from '~/plugins/renderer-bridge';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -17,12 +17,12 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
invariant(name && typeof name === 'string', 'Name is required');
invariant(provider, 'Cloud Provider name is required');
invariant(credentials, 'Credentials are required');
const authenticateResponse = await executePluginMainAction({
const authenticateResponse = await plugins.executePluginMainAction({
pluginName: EXTERNAL_VAULT_PLUGIN_NAME,
actionName: 'authenticate',
params: { provider, credentials },
});
const { success, error, result } = authenticateResponse;
const { success, error, result } = authenticateResponse as any;
if (error) {
return {
error: `${error.errorMessage}`,

View File

@@ -3,7 +3,7 @@ import { href } from 'react-router';
import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants';
import type { CloudProviderCredential } from '~/insomnia-data';
import { services } from '~/insomnia-data';
import { executePluginMainAction } from '~/plugins';
import { plugins } from '~/plugins/renderer-bridge';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -28,12 +28,12 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
: services.cloudCredential.update(existingCredential[0], patch));
return credentials;
}
const authenticateResponse = await executePluginMainAction({
const authenticateResponse = await plugins.executePluginMainAction({
pluginName: EXTERNAL_VAULT_PLUGIN_NAME,
actionName: 'authenticate',
params: { provider, credentials },
});
const { success, error, result } = authenticateResponse!;
const { success, error, result } = authenticateResponse as any;
if (error) {
return {
error: `${error.errorMessage}`,

View File

@@ -38,7 +38,7 @@ export async function clientLoader(args: Route.ClientLoaderArgs) {
);
};
const { accountId } = await services.userSession.getOrCreate();
const { accountId } = await services.userSession.get();
const allOrganizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[];

View File

@@ -12,7 +12,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const gitRepositoryIds = relatedGitRepositories.map(repo => repo._id);
const relatedProjects = await services.project.getAllByGitRepositoryIds(gitRepositoryIds);
const relatedProjects = await services.project.list({ gitRepositoryIds });
return {
projects: relatedProjects,

View File

@@ -7,7 +7,7 @@ import { models, services } from '~/insomnia-data';
import { createFetcherLoadHook } from '~/utils/router';
export async function clientLoader() {
const { accountId } = await services.userSession.getOrCreate();
const { accountId } = await services.userSession.get();
const organizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[];
const allProjects = (
await Promise.all(

View File

@@ -35,7 +35,7 @@ export const importScannedResources = async ({
invariant(organizationId && typeof organizationId === 'string', 'OrganizationId is required.');
invariant(projectId && typeof projectId === 'string', 'ProjectId is required.');
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found.');
return await (typeof workspaceId === 'string' && workspaceId

View File

@@ -9,7 +9,7 @@ import {
scanResources,
} from '~/common/import';
import type { ImportEntry } from '~/main/importers/entities';
import { SegmentEvent, trackImportEvent } from '~/ui/analytics';
import { AnalyticsEvent, trackImportEvent } from '~/ui/analytics';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -27,7 +27,7 @@ export const scanImportResources = async (data: {
invariant(typeof source === 'string', 'Source is required.');
invariant(IMPORT_SOURCE_TYPES.includes(source), 'Unsupported import type');
trackImportEvent(SegmentEvent.importScanned, { source });
trackImportEvent(AnalyticsEvent.importScanned, { source });
const contentList: ImportEntry[] = [];

View File

@@ -10,7 +10,7 @@ export async function clientAction({ params }: Route.ClientActionArgs) {
const { organizationId, invitationId } = params;
try {
const user = await services.userSession.getOrCreate();
const user = await services.userSession.get();
const sessionId = user.id;
const response = await reinvite({

View File

@@ -16,7 +16,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
invariant(typeof roleId === 'string', 'Role ID is required');
try {
const user = await services.userSession.getOrCreate();
const user = await services.userSession.get();
const sessionId = user.id;
const response = await updateInvitationRole({

View File

@@ -16,7 +16,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
invariant(typeof roleId === 'string', 'Role ID is required');
try {
const user = await services.userSession.getOrCreate();
const user = await services.userSession.get();
const sessionId = user.id;
const response = await updateUserRoles({
organizationId,

View File

@@ -26,7 +26,7 @@ export const fallbackBilling = Object.freeze<Billing>({
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const { organizationId } = params;
const { id: sessionId, accountId } = await services.userSession.getOrCreate();
const { id: sessionId, accountId } = await services.userSession.get();
if (models.organization.isScratchpadOrganizationId(organizationId)) {
return {

View File

@@ -51,7 +51,7 @@ import { useOrganizationLoaderData } from '~/routes/organization';
import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file';
import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new';
import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules';
import { SegmentEvent, trackOnceDaily } from '~/ui/analytics';
import { AnalyticsEvent, trackOnceDaily } from '~/ui/analytics';
import { AvatarGroup } from '~/ui/components/avatar';
import { CloudSyncProjectBar } from '~/ui/components/dropdowns/cloud-sync-project-bar';
import { GitProjectSyncDropdown } from '~/ui/components/dropdowns/git-project-sync-dropdown';
@@ -253,7 +253,7 @@ async function getAllLocalFiles({ projectId }: { projectId: string }) {
async function getAllRemoteFiles({ projectId, organizationId }: { projectId: string; organizationId: string }) {
try {
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
const remoteId = project?.remoteId;
if (!remoteId) {
@@ -359,7 +359,7 @@ const CheckAllProjectSyncStatus = async (projects: Project[]) => {
export async function clientLoader({ params }: LoaderFunctionArgs) {
const { organizationId, projectId } = params;
invariant(organizationId, 'Organization ID is required');
const { id: sessionId } = await services.userSession.getOrCreate();
const { id: sessionId } = await services.userSession.get();
const fallbackLearningFeature = {
active: false,
title: '',
@@ -389,7 +389,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
invariant(projectId, 'projectId parameter is required');
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
console.log('[project loader] Loading project:', project?.name, projectId);
const [localFiles, organizationProjects = []] = await Promise.all([
getAllLocalFiles({ projectId }),
@@ -970,7 +970,7 @@ const Component = () => {
onChange={filter => {
setWorkspaceListFilter(filter);
if (filter.trim() !== '') {
trackOnceDaily(SegmentEvent.homepageFiltered);
trackOnceDaily(AnalyticsEvent.homepageFiltered);
}
}}
>
@@ -1066,8 +1066,8 @@ const Component = () => {
<Button
onPress={() => {
window.main.trackSegmentEvent({
event: SegmentEvent.importStarted,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.importStarted,
properties: {
source: 'project',
},

View File

@@ -14,10 +14,10 @@ export async function clientAction({ params }: Route.ClientActionArgs) {
const { organizationId, projectId } = params;
invariant(organizationId, 'Organization ID is required');
invariant(projectId, 'Project ID is required');
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
const user = await services.userSession.getOrCreate();
const user = await services.userSession.get();
const sessionId = user.id;
invariant(sessionId, 'User must be logged in to delete a project');

View File

@@ -113,7 +113,7 @@ async function getAllLocalFiles({ projectId }: { projectId: string }) {
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const { organizationId, projectId } = params;
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, `Project was not found ${projectId}`);
const organizationProjects =
(await database.find<Project>(models.project.type, {

View File

@@ -11,7 +11,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
const projectId = formData.get('projectId');
const workspaceId = formData.get('workspaceId');
invariant(typeof projectId === 'string', 'Project ID is required');
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
invariant(typeof workspaceId === 'string', 'Workspace ID is required');

View File

@@ -14,7 +14,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
invariant(typeof organizationId === 'string', 'Organization ID is required');
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
await services.project.update(project, {

View File

@@ -10,7 +10,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const { organizationId, projectId } = params;
invariant(projectId, 'Project ID is required');
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
if (!project) {
return redirect(href('/organization/:organizationId', { organizationId }));

View File

@@ -6,7 +6,7 @@ import { projectLock } from '~/common/project';
import type { ApiSpec, WorkspaceMeta } from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { showToast } from '~/ui/components/toast-notification';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -65,13 +65,13 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
const { organizationId, projectId } = params;
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found');
const effectiveRepoId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null;
const gitRepository = effectiveRepoId ? await services.gitRepository.getById(effectiveRepoId) : null;
const user = await services.userSession.getOrCreate();
const user = await services.userSession.get();
const sessionId = user.id;
try {
@@ -135,8 +135,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
sessionId,
});
window.main.trackSegmentEvent({
event: SegmentEvent.projectUpdated,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.projectUpdated,
properties: {
storage: 'local',
},
@@ -187,8 +187,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
name,
});
window.main.trackSegmentEvent({
event: SegmentEvent.projectUpdated,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.projectUpdated,
properties: {
storage: 'remote',
},
@@ -254,8 +254,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
sessionId,
});
window.main.trackSegmentEvent({
event: SegmentEvent.projectUpdated,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.projectUpdated,
properties: {
storage: 'git',
},
@@ -411,8 +411,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
// local project rename
await services.project.update(project, { name });
window.main.trackSegmentEvent({
event: SegmentEvent.projectUpdated,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.projectUpdated,
properties: {
storage: 'local',
},

View File

@@ -19,7 +19,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
return;
}
const project = await services.project.getById(projectId);
const project = await services.project.get(projectId);
invariant(project, 'Project not found for request');
if (accessLevel === 'project') {
await services.project.update(project, { mcpStdioAccess: true });

View File

@@ -6,12 +6,14 @@ import { v4 as uuidv4 } from 'uuid';
import { getContentDispositionHeader } from '~/common/misc';
import type {
Environment,
Request,
RequestGroup,
RequestMeta,
ResponseInfo,
RunnerResultPerRequestPerIteration,
UserUploadEnvironment,
} from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import { database as db, models, services } from '~/insomnia-data';
import type { ResponsePatch } from '~/main/network/libcurl-promise';
import type { TimingStep } from '~/main/network/request-timing';
import {
@@ -25,7 +27,7 @@ import {
tryToInterpolateRequest,
tryToTransformRequestWithPlugins,
} from '~/network/network';
import { type ImportAttribution, importAttributionKey, SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent, type ImportAttribution, importAttributionKey } from '~/ui/analytics';
import { parseGraphQLReqeustBody } from '~/utils/graph-ql';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -334,7 +336,7 @@ export const sendActionImplementation = async (options: {
};
export async function clientAction({ request, params }: Route.ClientActionArgs) {
const { requestId } = params;
const { requestId, workspaceId } = params;
const { shouldPromptForPathAfterResponse, ignoreUndefinedEnvVariable } = (await request.json()) as SendActionParams;
try {
@@ -353,8 +355,22 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
const activeRequest = await services.request.getById(requestId);
if (activeRequest) {
window.main.trackSegmentEvent({
event: SegmentEvent.requestExecuted,
const [requestAndAncestors, clientCertificates] = await Promise.all([
db.withAncestors<Request | RequestGroup>(
activeRequest as Request,
[models.request.type, models.requestGroup.type],
),
services.clientCertificate.findByParentId(workspaceId),
]);
const docsWithScripts = requestAndAncestors.filter(
(doc): doc is Request | RequestGroup =>
models.request.isRequest(doc) || models.requestGroup.isRequestGroup(doc),
);
const allPreScripts = docsWithScripts.map(doc => doc.preRequestScript).filter((s): s is string => !!s);
const allPostScripts = docsWithScripts.map(doc => doc.afterResponseScript).filter((s): s is string => !!s);
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestExecuted,
properties: {
preferredHttpVersion: settings.preferredHttpVersion,
// @ts-expect-error -- who cares
@@ -365,6 +381,14 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
count_headers: response.headers.length,
count_cookies: response.headers.find(h => h.name === 'set-cookie')?.value.split(',').length || 0,
count_tests: response.requestTestResults?.length || 0,
has_prescript: allPreScripts.length > 0,
has_postscript: allPostScripts.length > 0,
count_prescript_lines: allPreScripts.reduce((sum, s) => sum + s.split('\n').length, 0),
count_postscript_lines: allPostScripts.reduce((sum, s) => sum + s.split('\n').length, 0),
count_query_parameters: activeRequest.parameters?.length ?? 0,
count_path_parameters: activeRequest.pathParameters?.length ?? 0,
has_docs: !!activeRequest.description,
count_certificates: clientCertificates.length,
},
});
@@ -373,8 +397,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
if (jsonImportAttribution) {
try {
const importAttribution = JSON.parse(jsonImportAttribution) as ImportAttribution;
window.main.trackSegmentEvent({
event: SegmentEvent.importedRequestFirstSend,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.importedRequestFirstSend,
properties: {
...importAttribution,
protocol: activeRequest.type,

View File

@@ -2,7 +2,7 @@ import { href } from 'react-router';
import type { WebSocketRequest } from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { updateMimeType } from '~/ui/components/dropdowns/content-type-dropdown';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -45,8 +45,8 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
await services.helpers.updateRequest(req, patch);
if (req.name !== patch.name) {
window.main.trackSegmentEvent({
event: SegmentEvent.requestRenamed,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestRenamed,
});
}

View File

@@ -1,7 +1,7 @@
import { href, redirect } from 'react-router';
import { services } from '~/insomnia-data';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -19,8 +19,8 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId);
invariant(workspaceMeta, 'Workspace meta not found');
window.main.trackSegmentEvent({
event: SegmentEvent.requestDeleted,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestDeleted,
});
if (workspaceMeta.activeRequestId === id) {

View File

@@ -10,7 +10,7 @@ import {
} from '~/common/constants';
import type { Request, RequestBody, RequestParameter } from '~/insomnia-data';
import { services } from '~/insomnia-data';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import type { CreateRequestType } from '~/ui/hooks/use-request';
import { invariant } from '~/utils/invariant';
import { createFetcherSubmitHook } from '~/utils/router';
@@ -126,8 +126,8 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
const certificates = await services.clientCertificate.findByParentId(workspaceId);
window.main.trackSegmentEvent({
event: SegmentEvent.requestCreated,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestCreated,
properties: {
requestType,
protocol: requestType,

View File

@@ -32,7 +32,7 @@ import { useRootLoaderData } from '~/root';
import { useOrganizationLoaderData } from '~/routes/organization';
import type { CollectionRunnerContext } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send';
import { sendActionImplementation } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { Dropdown, DropdownItem, ItemContent } from '~/ui/components/base/dropdown';
import { ErrorBoundary } from '~/ui/components/error-boundary';
import { HelpTooltip } from '~/ui/components/help-tooltip';
@@ -270,8 +270,8 @@ export const Runner: FC = () => {
}
setIsRunning(true);
window.main.trackSegmentEvent({
event: SegmentEvent.collectionRunExecute,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.collectionRunExecute,
properties: { plan: organizationData?.currentPlan?.type || 'scratchpad', iterations: iterationCount },
});

View File

@@ -72,7 +72,7 @@ import Tutorial, {
scratchPadTutorialList,
} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tutorial.$panel';
import { useToggleExpandAllActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.toggle-expand-all';
import { SegmentEvent } from '~/ui/analytics';
import { AnalyticsEvent } from '~/ui/analytics';
import { DropdownHint } from '~/ui/components/base/dropdown/dropdown-hint';
import { DocumentTab } from '~/ui/components/document-tab';
import { RequestActionsDropdown } from '~/ui/components/dropdowns/request-actions-dropdown';
@@ -158,7 +158,7 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs)
if (!params.requestId && !params.requestGroupId) {
const { projectId, workspaceId, organizationId } = params;
const activeProject = await services.project.getById(projectId);
const activeProject = await services.project.get(projectId);
if (!activeProject) {
showResourceNotFoundToast(`Project not found: ${projectId}`);
throw redirect(href('/organization/:organizationId/project', { organizationId }));
@@ -848,8 +848,8 @@ const Debug = () => {
onOpenChange={isOpen => {
setIsEnvironmentPickerOpen(isOpen);
if (isOpen) {
window.main.trackSegmentEvent({
event: SegmentEvent.requestEnvironmentClicked,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestEnvironmentClicked,
});
}
}}
@@ -858,8 +858,8 @@ const Debug = () => {
</div>
<Button
onPress={() => {
window.main.trackSegmentEvent({
event: SegmentEvent.requestAddCookiesClicked,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestAddCookiesClicked,
});
setIsCookieModalOpen(true);
}}
@@ -873,8 +873,8 @@ const Debug = () => {
</Button>
<Button
onPress={() => {
window.main.trackSegmentEvent({
event: SegmentEvent.requestAddCertificatesClicked,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestAddCertificatesClicked,
});
setCertificatesModalOpen(true);
}}
@@ -901,8 +901,8 @@ const Debug = () => {
setFilter(value);
if (value.trim() !== '') {
window.main.trackSegmentEvent({
event: SegmentEvent.filterCreatedRequests,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.filterCreatedRequests,
});
}
}}
@@ -923,8 +923,8 @@ const Debug = () => {
selectedKey={sortOrder}
onSelectionChange={order => {
if (order) {
window.main.trackSegmentEvent({
event: SegmentEvent.requestListSortClicked,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestListSortClicked,
});
setSearchParams({
...Object.fromEntries(searchParams.entries()),
@@ -976,8 +976,8 @@ const Debug = () => {
defaultSelected={allExpanded}
onChange={() => {
setAllExpanded(!allExpanded);
window.main.trackSegmentEvent({
event: SegmentEvent.requestListExpandCollapseClicked,
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestListExpandCollapseClicked,
});
toggleExpandAllFetcher.submit({
organizationId,

Some files were not shown because too many files have changed in this diff Show More