diff --git a/packages/insomnia/src/main/__tests__/plugin-window-ipc-authorization.test.ts b/packages/insomnia/src/main/__tests__/plugin-window-ipc-authorization.test.ts index 4d09d4ab29..da04b39d64 100644 --- a/packages/insomnia/src/main/__tests__/plugin-window-ipc-authorization.test.ts +++ b/packages/insomnia/src/main/__tests__/plugin-window-ipc-authorization.test.ts @@ -42,6 +42,7 @@ vi.mock('electron', () => ({ vi.mock('../window-utils', () => ({ getMainWindow: vi.fn(() => fakeMainWindow), + registerPluginWindowControls: vi.fn(), })); vi.mock('../prompt-bridge', () => ({ diff --git a/packages/insomnia/src/main/__tests__/print-templating-worker-database-surface.test.ts b/packages/insomnia/src/main/__tests__/print-templating-worker-database-surface.test.ts index 889456d443..b279901db4 100644 --- a/packages/insomnia/src/main/__tests__/print-templating-worker-database-surface.test.ts +++ b/packages/insomnia/src/main/__tests__/print-templating-worker-database-surface.test.ts @@ -26,6 +26,7 @@ vi.mock('~/plugins', () => ({ getPluginCommonContext: vi.fn(), getTemplateTags: vi.fn().mockResolvedValue([]), getPlugins: vi.fn().mockResolvedValue([]), + registerUserPluginExportDiscovery: vi.fn(), })); vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() })); vi.mock('../common/database', () => ({ database: {} })); diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database-bodypath-read.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database-bodypath-read.test.ts index c57b83b584..e2ae29b4f4 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database-bodypath-read.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database-bodypath-read.test.ts @@ -37,6 +37,7 @@ vi.mock('~/plugins', () => ({ getPluginCommonContext: vi.fn(), getTemplateTags: vi.fn().mockResolvedValue([]), getPlugins: vi.fn().mockResolvedValue([]), + registerUserPluginExportDiscovery: vi.fn(), })); vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() })); vi.mock('../common/database', () => ({ database: {} })); diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database-inventory.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database-inventory.test.ts index 3e0793fdc7..162a58548c 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database-inventory.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database-inventory.test.ts @@ -30,6 +30,7 @@ vi.mock('~/plugins', () => ({ getPluginCommonContext: vi.fn(), getTemplateTags: vi.fn().mockResolvedValue([]), getPlugins: vi.fn().mockResolvedValue([]), + registerUserPluginExportDiscovery: vi.fn(), })); vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() })); vi.mock('../common/database', () => ({ database: {} })); diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts index 7c23b1eee5..0baa8933cf 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts @@ -33,6 +33,7 @@ vi.mock('~/plugins', () => ({ getPluginCommonContext: vi.fn(), getTemplateTags: vi.fn().mockResolvedValue([]), getPlugins: vi.fn().mockResolvedValue([]), + registerUserPluginExportDiscovery: vi.fn(), })); vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() })); vi.mock('../common/database', () => ({ database: {} })); diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database-sendrequest-cacert-bypass.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database-sendrequest-cacert-bypass.test.ts index c2ec32beb1..edb9c1881a 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database-sendrequest-cacert-bypass.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database-sendrequest-cacert-bypass.test.ts @@ -44,6 +44,7 @@ vi.mock('~/plugins', () => ({ getPluginCommonContext: vi.fn(), getTemplateTags: vi.fn().mockResolvedValue([]), getPlugins: vi.fn().mockResolvedValue([]), + registerUserPluginExportDiscovery: vi.fn(), })); vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() })); vi.mock('../common/database', () => ({ database: {} })); diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database-surface.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database-surface.test.ts index 91f0d2caee..cdd967881b 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database-surface.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database-surface.test.ts @@ -30,6 +30,7 @@ vi.mock('~/plugins', () => ({ getPluginCommonContext: vi.fn(), getTemplateTags: vi.fn().mockResolvedValue([]), getPlugins: vi.fn().mockResolvedValue([]), + registerUserPluginExportDiscovery: vi.fn(), })); vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() })); vi.mock('../common/database', () => ({ database: {} })); diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database.test.ts index 12c21e3f5f..125a12eb02 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database.test.ts @@ -33,6 +33,7 @@ vi.mock('~/plugins', () => ({ getPluginCommonContext: vi.fn(), getTemplateTags: vi.fn().mockResolvedValue([]), getPlugins: vi.fn().mockResolvedValue([]), + registerUserPluginExportDiscovery: vi.fn(), })); vi.mock('~/common/cookies', () => ({ jarFromCookies: vi.fn() })); vi.mock('../common/database', () => ({ database: {} })); diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index 44e9a34437..928fd02267 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -41,7 +41,7 @@ import { type ProviderEmail, type ProviderRepository, } from '~/sync/git/providers'; -import type { FileIssue, FileIssueKind } from '~/sync/git/repo-file-watcher'; +import type { FileIssue, WorkspaceFileIssue } from '~/sync/git/repo-file-watcher'; import { INSOMNIA_GITLAB_API_URL } from '../common/constants'; import { database } from '../common/database'; @@ -174,14 +174,6 @@ export function vcsEventProperties(type: 'git', action: VCSAction, error?: strin return { type, action, error }; } -export interface WorkspaceFileIssue { - workspaceId: string; - gitRepositoryId: string; - relPath: string; - kind: FileIssueKind; - message: string; -} - interface GetProjectGitFileIssuesOptions { projectId: string; workspaceId?: string; diff --git a/packages/insomnia/src/main/plugin-window.ts b/packages/insomnia/src/main/plugin-window.ts index 545c39ae70..9728f1fab3 100644 --- a/packages/insomnia/src/main/plugin-window.ts +++ b/packages/insomnia/src/main/plugin-window.ts @@ -3,8 +3,10 @@ import path from 'node:path'; import { app, BrowserWindow, ipcMain, type IpcMainEvent, type IpcMainInvokeEvent } from 'electron'; +import { invariant } from '~/common/utils/invariant'; + import { requestPromptFromRenderer } from './prompt-bridge'; -import { getMainWindow } from './window-utils'; +import { getMainWindow, registerPluginWindowControls } from './window-utils'; let pluginWindow: BrowserWindow | null = null; let windowReady = false; @@ -16,6 +18,19 @@ const pendingRequests = new Map< let cachedHasRequestHooks: boolean | null = null; let cachedHasResponseHooks: boolean | null = null; +// Registered at module load so `window-utils` (its dev menu and main-window load hook) can drive +// the plugin window without importing this module, which would be a circular dependency. +registerPluginWindowControls({ + toggleVisibility: () => { + invariant(pluginWindow, 'pluginWindow is not defined'); + pluginWindow.isVisible() ? pluginWindow.hide() : pluginWindow.show(); + }, + destroyOrCreate: () => { + pluginWindow ? destroyPluginWindow() : createPluginWindow(); + }, + createAfterMainLoad: () => createPluginWindow(), +}); + // 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. diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index 7add09df93..270898d863 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -20,7 +20,12 @@ import type { PluginTemplateTagContext, PluginToMainAPIPaths, } from '~/common/templating/types'; -import { getPluginCommonContext, getPlugins, getTemplateTags } from '~/plugins'; +import { + getPluginCommonContext, + getPlugins, + getTemplateTags, + registerUserPluginExportDiscovery, +} from '~/plugins'; import { HOOK_REQUEST_FIELDS, type PluginExportManifest, @@ -472,6 +477,11 @@ export const discoverUserPluginExportsForLoader = async (body: { }); }; +// Register the sandbox-backed user-plugin discovery with the plugin loader (plugins/index.ts), +// which can't import this main-only module without creating a circular dependency. Loading this +// module in main always precedes plugin discovery, since getPlugins/getTemplateTags live here. +registerUserPluginExportDiscovery(discoverUserPluginExportsForLoader); + const pickHookRequestFields = (req: Record): Record => { const out: Record = {}; for (const key of HOOK_REQUEST_FIELDS) { diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index 1957abc359..2763cb4aca 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -25,7 +25,6 @@ import { docsBase } from '../common/documentation'; import { getElectronStorage } from './electron-storage'; import { ipcMainOn } from './ipc/electron'; import { getLogDirectory } from './log'; -import { createPluginWindow, destroyPluginWindow, getPluginWindow } from './plugin-window'; import { isTrustedAppOrigin } from './trusted-origin'; import { MAIN_WINDOW_SECURITY } from './window-security'; @@ -34,9 +33,26 @@ const DEFAULT_HEIGHT = 720; const MINIMUM_WIDTH = 500; const MINIMUM_HEIGHT = 400; const browserWindows = new Map<'Insomnia' | 'HiddenBrowserWindow', ElectronBrowserWindow>(); + +// The plugin window lives in `plugin-window`, which depends on `getMainWindow` below — importing +// it from here would be a circular dependency. Instead, `plugin-window` registers its controls +// at module load and this module calls through the registration. +interface PluginWindowControls { + toggleVisibility: () => void; + destroyOrCreate: () => void; + createAfterMainLoad: () => void; +} + +let pluginWindowControls: PluginWindowControls | null = null; + +export function registerPluginWindowControls(controls: PluginWindowControls): void { + pluginWindowControls = controls; +} + export function getMainWindow(): ElectronBrowserWindow | null { return browserWindows.get('Insomnia') ?? null; } + let hiddenWindowIsBusy = false; interface Bounds { height?: number; @@ -658,16 +674,15 @@ export function createWindow(): ElectronBrowserWindow { { label: 'Show/hide plugin browser window ', click: () => { - const pluginWindow = getPluginWindow(); - invariant(pluginWindow, 'pluginWindow is not defined'); - pluginWindow.isVisible() ? pluginWindow.hide() : pluginWindow.show(); + invariant(pluginWindowControls, 'pluginWindow is not defined'); + pluginWindowControls.toggleVisibility(); }, }, { label: 'Stop/start plugin browser window ', click: () => { - const pluginWindow = getPluginWindow(); - pluginWindow ? destroyPluginWindow() : createPluginWindow(); + invariant(pluginWindowControls, 'pluginWindow is not defined'); + pluginWindowControls.destroyOrCreate(); }, }, { @@ -852,8 +867,9 @@ export function createWindowsAndReturnMain() { // 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()); + mainWindow.webContents.once('did-finish-load', () => { + invariant(pluginWindowControls, 'pluginWindow controls are not registered'); + pluginWindowControls.createAfterMainLoad(); + }); return mainWindow; } - -export { destroyPluginWindow }; diff --git a/packages/insomnia/src/plugins/__tests__/plugin-load-order-quickjs-module-resolution.test.ts b/packages/insomnia/src/plugins/__tests__/plugin-load-order-quickjs-module-resolution.test.ts index 696193f789..bcb2793da6 100644 --- a/packages/insomnia/src/plugins/__tests__/plugin-load-order-quickjs-module-resolution.test.ts +++ b/packages/insomnia/src/plugins/__tests__/plugin-load-order-quickjs-module-resolution.test.ts @@ -22,31 +22,36 @@ import os from 'node:os'; import path from 'node:path'; import { services } from 'insomnia-data'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; -vi.mock('~/main/templating-worker-database', () => ({ - discoverUserPluginExportsForLoader: async () => { - // Stands in for the real function's eventual `await import('../templating/sandbox/ - // plugin-tag-sandbox')` → `getQuickJSModule()` call chain, without needing to also mock - // `buildSandboxBridge`/`readPluginModuleMap`/`electron.app.getVersion()` for no added - // evidentiary value — the property under test is what `getQuickJSModule()` itself resolves to, - // not the rest of real discovery's plumbing. - const { getQuickJSModule } = await import('~/templating/sandbox/quickjs-runtime'); - (globalThis as any).__sandboxedPluginResolvedQuickJSModule = await getQuickJSModule(); - return { - templateTags: [], - requestHooks: 0, - responseHooks: 0, - requestActions: [], - requestGroupActions: [], - workspaceActions: [], - documentActions: [], - themes: [], - }; - }, -})); +// plugins/index.ts no longer dynamically imports `~/main/templating-worker-database`; the main +// process now hands it the sandbox-backed discovery via `registerUserPluginExportDiscovery`. Mock +// that module would never register anything, so register a stub discovery through the real +// registration hook instead — standing in for the real function's eventual +// `await import('../templating/sandbox/plugin-tag-sandbox')` → `getQuickJSModule()` call chain, +// without needing to also mock `buildSandboxBridge`/`readPluginModuleMap`/`electron.app.getVersion()` +// for no added evidentiary value — the property under test is what `getQuickJSModule()` itself +// resolves to, not the rest of real discovery's plumbing. +import { _testOnlySetPlugins, getPlugins, registerUserPluginExportDiscovery } from '../index'; -import { _testOnlySetPlugins, getPlugins } from '../index'; +registerUserPluginExportDiscovery(async () => { + // Deliberately dynamic: this test's premise is that the process's first-ever + // `quickjs-emscripten` resolution happens *during* the sandboxed sibling's discovery call, so a + // static import would defeat what's being proven. The assignment below avoids a cast; the test + // assertions read the flag back off `globalThis`. + const { getQuickJSModule } = await import('~/templating/sandbox/quickjs-runtime'); + Object.assign(globalThis, { __sandboxedPluginResolvedQuickJSModule: await getQuickJSModule() }); + return { + templateTags: [], + requestHooks: 0, + responseHooks: 0, + requestActions: [], + requestGroupActions: [], + workspaceActions: [], + documentActions: [], + themes: [], + }; +}); const originalProcessType = process.type; diff --git a/packages/insomnia/src/plugins/index.ts b/packages/insomnia/src/plugins/index.ts index 61f357fe99..efa5a900a9 100644 --- a/packages/insomnia/src/plugins/index.ts +++ b/packages/insomnia/src/plugins/index.ts @@ -64,6 +64,21 @@ export async function init() { // discovery has to reach the sandbox from either. In main we call the sandbox directly — the // `insomnia-templating-worker-database://` protocol is a renderer<->main channel and main's own // `fetch` can't resolve it. In a renderer (the plugin window) we go over that protocol. +type UserPluginExportDiscovery = (body: { + directory: string; + name: string; + permissions?: { modules?: string[]; capabilities?: string[] }; +}) => Promise; + +// The main process registers the sandbox-backed discovery here at module load; importing +// `main/templating-worker-database` directly would be a circular dependency (that module loads +// this one for getPlugins/getTemplateTags). +let discoverUserPluginExportsInMain: UserPluginExportDiscovery | null = null; + +export function registerUserPluginExportDiscovery(discover: UserPluginExportDiscovery): void { + discoverUserPluginExportsInMain = discover; +} + async function discoverUserPluginExports( directory: string, name: string, @@ -73,8 +88,10 @@ async function discoverUserPluginExports( if (__IS_RENDERER__) { return (await fetchFromTemplateWorkerDatabase('plugin.discoverUserPluginExports', body)) as PluginExportManifest; } - const { discoverUserPluginExportsForLoader } = await import('~/main/templating-worker-database'); - return discoverUserPluginExportsForLoader(body); + if (!discoverUserPluginExportsInMain) { + throw new Error('User plugin export discovery is not registered; main/templating-worker-database did not load in the main process'); + } + return discoverUserPluginExportsInMain(body); } function buildUserPluginModuleFromManifest(pluginName: string, manifest: PluginExportManifest): Plugin['module'] { diff --git a/packages/insomnia/src/sync/git/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts index 2dc6a61161..ac2c3186b4 100644 --- a/packages/insomnia/src/sync/git/repo-file-watcher.ts +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -44,8 +44,6 @@ import path from 'node:path'; import type { BaseModel, Workspace, WorkspaceMeta } from 'insomnia-data'; import { models, services } from 'insomnia-data'; -import type { WorkspaceFileIssue } from '~/main/git-service'; - import { database as db } from '../../common/database'; import { InsomniaFileTypeValues } from '../../common/import-v5-parser'; import { getInsomniaV5DataExport, tryImportV5Data } from '../../common/insomnia-v5'; @@ -69,6 +67,19 @@ export interface FileIssue { message: string; } +/** + * A file issue scoped to its workspace and git repository, as reported over IPC to the + * renderer. Lives here (not in `main/git-service`) to avoid a circular dependency: + * git-service loads this module for the watcher registry. + */ +export interface WorkspaceFileIssue { + workspaceId: string; + gitRepositoryId: string; + relPath: string; + kind: FileIssueKind; + message: string; +} + export interface FileProblemsChangedPayload { repoId: string; problems: FileIssue[]; diff --git a/packages/insomnia/src/ui/components/tabs/tab.tsx b/packages/insomnia/src/ui/components/tabs/tab.tsx index 9eae16c61e..a3b3b6cb85 100644 --- a/packages/insomnia/src/ui/components/tabs/tab.tsx +++ b/packages/insomnia/src/ui/components/tabs/tab.tsx @@ -5,7 +5,7 @@ import React, { useCallback } from 'react'; import { Button, GridListItem } from 'react-aria-components'; import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label'; -import type { WorkspaceFileIssue } from '~/main/git-service'; +import type { WorkspaceFileIssue } from '~/sync/git/repo-file-watcher'; import { scrollElementIntoView } from '~/ui/utils'; import { type BaseTab, TAB_CONTEXT_MENU_COMMAND, type TabType, useInsomniaTabContext } from '../../context/app/insomnia-tab-context'; diff --git a/packages/insomnia/src/ui/hooks/use-git-file-issues.ts b/packages/insomnia/src/ui/hooks/use-git-file-issues.ts index edeef77496..cede3924a2 100644 --- a/packages/insomnia/src/ui/hooks/use-git-file-issues.ts +++ b/packages/insomnia/src/ui/hooks/use-git-file-issues.ts @@ -10,8 +10,7 @@ import React, { } from 'react'; import { invariant } from '~/common/utils/invariant'; -import type { WorkspaceFileIssue } from '~/main/git-service'; -import type { FileProblemsChangedPayload } from '~/sync/git/repo-file-watcher'; +import type { FileProblemsChangedPayload, WorkspaceFileIssue } from '~/sync/git/repo-file-watcher'; const mapIssuesByWorkspaceId = (issues: WorkspaceFileIssue[]) => { return Object.fromEntries(issues.map(issue => [issue.workspaceId, issue])) as Record; diff --git a/scripts/circular-references/known-violations.json b/scripts/circular-references/known-violations.json index 98b2d71838..8c000cdbff 100644 --- a/scripts/circular-references/known-violations.json +++ b/scripts/circular-references/known-violations.json @@ -2,11 +2,6 @@ "insomnia": [ "packages/insomnia/src/common/import.ts -> packages/insomnia/src/common/insomnia-v5.ts", "packages/insomnia/src/common/render.ts -> packages/insomnia/src/network/network.ts", - "packages/insomnia/src/main/git-service.ts -> packages/insomnia/src/sync/git/repo-file-watcher.ts", - "packages/insomnia/src/main/plugin-window.ts -> packages/insomnia/src/main/prompt-bridge.ts -> packages/insomnia/src/main/window-utils.ts", - "packages/insomnia/src/main/plugin-window.ts -> packages/insomnia/src/main/window-utils.ts", - "packages/insomnia/src/main/templating-worker-database.ts -> packages/insomnia/src/plugins/index.ts", - "packages/insomnia/src/main/templating-worker-database.ts -> packages/insomnia/src/templating/index.ts -> packages/insomnia/src/plugins/index.ts", "packages/insomnia/src/sync/git/providers/github.ts -> packages/insomnia/src/sync/git/utils.ts -> packages/insomnia/src/sync/git/providers/index.ts", "packages/insomnia/src/sync/git/providers/gitlab.ts -> packages/insomnia/src/sync/git/utils.ts -> packages/insomnia/src/sync/git/providers/index.ts", "packages/insomnia/src/ui/components/modals/export-requests-modal.tsx -> packages/insomnia/src/ui/components/settings/import-export.tsx",