refactor: break main-process circular imports (git-service/plugin-window/templating-worker-database)

This commit is contained in:
Bingbing committed 2026-09-15 14:13:58 +08:00
1 parent 7e8a74b656
commit 54befeb9d6
18 files changed
+123 -55

No files matched your search

@@ -42,6 +42,7 @@ vi.mock('electron', () => ({
vi.mock('../window-utils', () => ({
getMainWindow: vi.fn(() => fakeMainWindow),
registerPluginWindowControls: vi.fn(),
}));
vi.mock('../prompt-bridge', () => ({
@@ -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: {} }));
@@ -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: {} }));
@@ -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: {} }));
@@ -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: {} }));
@@ -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: {} }));
@@ -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: {} }));
@@ -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: {} }));
+1 -9
View File
@@ -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;
+16 -1
View File
@@ -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.
@@ -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<string, any>): Record<string, any> => {
const out: Record<string, any> = {};
for (const key of HOOK_REQUEST_FIELDS) {
+25 -9
View File
@@ -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 };
@@ -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;
+19 -2
View File
@@ -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<PluginExportManifest>;
// 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'] {
@@ -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[];
@@ -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';
@@ -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<string, WorkspaceFileIssue>;
@@ -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",