backport #10048 to release/13.0 (#10053)

* fix prompt

* refactor: implement app context methods via fetch bridge (#10034)

Replace stub app context methods (alert, dialog, prompt, getPath, clipboard operations, showSaveDialog) with actual implementations using the fetch bridge to the main process.

- Add 8 new entries to pluginToMainAPI in templating-worker-database.ts
- Update AppContext interface to use async methods (matching worker reality)
- Replace worker-side throws with fetchFromTemplateWorkerDatabase calls
- Update renderer-side app context to return promises

---------

Co-authored-by: jackkav <jackkav@gmail.com>
Co-authored-by: Jay Wu <jay.wu@konghq.com>
This commit is contained in:
Insomnia
2026-06-11 15:40:00 +05:30
committed by GitHub
parent d16ebbe0f5
commit db2c2fe212
12 changed files with 122 additions and 81 deletions

View File

@@ -150,7 +150,4 @@ test('Critical Path For Template Tags Interactions', async ({ page, app, insomni
const { tagPrefix } = templateTagTestCases.prompt[0];
await page.locator(`[data-template^="${tagPrefix}"]`).isVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
// prompt tag is blocked in the sandboxed render-adapter worker
await expect.soft(page.getByText('Unexpected Request Failure')).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
});

View File

@@ -442,8 +442,8 @@ const main: Window['main'] = {
applyResponseHooks: (args: ApplyResponseHooksArgs) => invokePluginBridgeMethod('applyResponseHooks', args),
getBridgeMetrics: () => invokeWithNormalizedError('plugins.getBridgeMetrics'),
},
notifyPluginPromptResult: (id: string, value: string | null) =>
ipcRenderer.send('plugins.uiPromptResult', { id, value }),
notifyPromptResult: (id: string, value: string | null) =>
ipcRenderer.send('ui.promptResult', { id, value }),
timeline: {
getPath: (responseId: string) => invokeWithNormalizedError('timeline.getPath', responseId) as Promise<string>,
appendToFile: (options: { timelinePath: string; data: string }) =>

View File

@@ -145,7 +145,6 @@ export type HandleChannels =
| 'plugins.hasResponseHooks'
| 'plugins.reloadPlugins'
| 'plugins.runTemplateTagAction'
| 'plugins.uiPrompt'
| 'openPath'
| 'parseImport'
| 'readCurlResponse'
@@ -251,7 +250,7 @@ export type MainOnChannels =
| 'sync.cancelConflict'
| 'sync.resolveConflict'
| 'mcp.sendMCPRequest'
| 'plugins.uiPromptResult'
| 'ui.promptResult'
| 'writeText';
export type RendererOnChannels =
@@ -259,7 +258,7 @@ export type RendererOnChannels =
| 'db.changes'
| 'plugins.uiAlert'
| 'plugins.uiDialog'
| 'plugins.uiPrompt'
| 'ui.prompt'
| 'grpc.data'
| 'grpc.end'
| 'grpc.error'

View File

@@ -313,7 +313,7 @@ export interface RendererToMainBridgeAPI {
>;
syncNewWorkspaceIfNeeded: typeof syncNewWorkspaceIfNeeded;
plugins: PluginsBridgeAPI;
notifyPluginPromptResult: (id: string, value: string | null) => void;
notifyPromptResult: (id: string, value: string | null) => void;
vault: {
encryptSecretValue: (rawValue: string, symmetricKey: JsonWebKey) => Promise<string>;
decryptSecretValue: (encryptedValue: string, symmetricKey: JsonWebKey) => Promise<string>;

View File

@@ -3,6 +3,9 @@ import path from 'node:path';
import { app, BrowserWindow, ipcMain } from 'electron';
import { requestPromptFromRenderer } from './prompt-bridge';
import { getMainWindow } from './window-utils';
let pluginWindow: BrowserWindow | null = null;
let windowReady = false;
const pendingRequests = new Map<
@@ -12,7 +15,6 @@ const pendingRequests = new Map<
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
@@ -67,9 +69,6 @@ export function getBridgeMetricsSnapshot() {
};
}
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;
@@ -109,35 +108,7 @@ function ensureIpcListeners() {
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('plugins.uiPrompt', id, options);
});
});
ipcMain.on('plugins.uiPromptResult', (event, { id, value }: { id: string; value: string | null }) => {
const mainWindow = getMainWindow();
if (!mainWindow || event.sender !== mainWindow.webContents) {
return;
}
const resolve = promptPendingRequests.get(id);
if (!resolve) {
return;
}
promptPendingRequests.delete(id);
resolve(value);
return requestPromptFromRenderer(options as { title: string; label?: string; defaultValue?: string });
});
ipcMain.on(

View File

@@ -0,0 +1,42 @@
import { randomUUID } from 'node:crypto';
import { ipcMain } from 'electron';
import { getMainWindow } from './window-utils';
const promptPendingRequests = new Map<string, (value: string | null) => void>();
export function requestPromptFromRenderer(options: {
title: string;
label?: string;
defaultValue?: string;
}): Promise<string | null> {
const mainWindow = getMainWindow();
if (!mainWindow) {
return Promise.resolve(null);
}
const id = randomUUID();
return new Promise(resolve => {
const timeout = setTimeout(() => {
promptPendingRequests.delete(id);
resolve(null);
}, 60_000);
promptPendingRequests.set(id, value => {
clearTimeout(timeout);
resolve(value);
});
mainWindow.webContents.send('ui.prompt', id, options);
});
}
ipcMain.on('ui.promptResult', (event, { id, value }: { id: string; value: string | null }) => {
if (event.sender !== getMainWindow()?.webContents) {
return;
}
const resolve = promptPendingRequests.get(id);
if (!resolve) {
return;
}
promptPendingRequests.delete(id);
resolve(value);
});

View File

@@ -2,7 +2,7 @@ import type { BinaryToTextEncoding } from 'node:crypto';
import crypto from 'node:crypto';
import os from 'node:os';
import { shell } from 'electron';
import { app, clipboard, dialog, shell } from 'electron';
import iconv from 'iconv-lite';
import type { AllTypes, CloudProviderCredential, Request as DBRequest, RequestGroup, Workspace } from 'insomnia-data';
import { services } from 'insomnia-data';
@@ -18,6 +18,7 @@ import { fetchRequestData, sendCurlAndWriteTimeline, tryToInterpolateRequest } f
import { type Plugin, type TemplateTag } from '../plugins/types';
import type { PluginTemplateTag, PluginTemplateTagContext, PluginToMainAPIPaths } from '../templating/types';
import { curlRequest } from './network/libcurl-promise';
import { requestPromptFromRenderer } from './prompt-bridge';
import { secureReadFile } from './secure-read-file';
const bundlePluginModuleMap: Record<string, Plugin['module']> = {};
@@ -308,4 +309,33 @@ const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => Promise<
}
throw new Error(`Unsupported action named ${actionName} for plugin ${pluginName}`);
},
'app.alert': async (body: { title: string; message?: string }) => {
await dialog.showMessageBox({ type: 'info', title: body.title, message: body.message || '' });
},
'app.dialog': async (body: { title: string; message?: string }) => {
await dialog.showMessageBox({ type: 'info', title: body.title, message: body.message || '' });
},
'app.prompt': async (body: { title: string; options?: { label?: string; defaultValue?: string } }) => {
return requestPromptFromRenderer({
title: body.title,
label: body.options?.label ?? body.title,
defaultValue: body.options?.defaultValue ?? '',
});
},
'app.getPath': async (body: { name: string }) => {
return app.getPath(body.name as Parameters<typeof app.getPath>[0]);
},
'app.showSaveDialog': async (body: { options?: { defaultPath?: string } }) => {
const result = await dialog.showSaveDialog(body.options ?? {});
return result.canceled ? null : result.filePath;
},
'app.clipboard.readText': async () => {
return clipboard.readText();
},
'app.clipboard.writeText': async (body: { text: string }) => {
clipboard.writeText(body.text);
},
'app.clipboard.clear': async () => {
clipboard.clear();
},
};

View File

@@ -32,6 +32,9 @@ const DEFAULT_HEIGHT = 720;
const MINIMUM_WIDTH = 500;
const MINIMUM_HEIGHT = 400;
const browserWindows = new Map<'Insomnia' | 'HiddenBrowserWindow', ElectronBrowserWindow>();
export function getMainWindow(): ElectronBrowserWindow | null {
return browserWindows.get('Insomnia') ?? null;
}
let hiddenWindowIsBusy = false;
interface Bounds {
height?: number;

View File

@@ -8,12 +8,12 @@ const isRenderer = process.type === 'renderer';
export const init = (renderPurpose: RenderPurpose = 'general'): { app: AppContext } => ({
app: {
alert: (title: string, message?: string) => {
alert: async (title: string, message?: string) => {
if (isRenderer) {
return window.showAlert({ title, message });
}
},
dialog: (title, body, options = {}) => {
dialog: async (title, body, options = {}) => {
if (isRenderer) {
window.showWrapper({
...options,
@@ -41,7 +41,7 @@ export const init = (renderPurpose: RenderPurpose = 'general'): { app: AppContex
});
},
getPath: (name: string) => {
getPath: async (name: string) => {
invariant(name.toLowerCase() === 'desktop', `Unknown path name ${name}`);
return window.app.getPath('desktop');
},
@@ -63,9 +63,9 @@ export const init = (renderPurpose: RenderPurpose = 'general'): { app: AppContex
},
clipboard: {
readText: () => window.clipboard.readText(),
writeText: text => window.clipboard.writeText(text),
clear: () => window.clipboard.clear(),
readText: async () => window.clipboard.readText(),
writeText: async (text: string) => window.clipboard.writeText(text),
clear: async () => window.clipboard.clear(),
},
},
});

View File

@@ -49,7 +49,6 @@ export const fetchFromTemplateWorkerDatabase = async (path: PluginToMainAPIPaths
return result;
};
const legacyModeErrorMessage = `This version improves the security around plugins by limiting scope of access by default. This may break some plugins which rely on having the same kind of access Insomnia does. You can still grant elevated access to plugins, should your workflow absolutely require it, by navigating to Preferences > Plugins and checking the box enabling elevated access for plugins.`;
function resolveArg(arg: ReturnType<typeof tokenizeArgs>[number], scope: Record<string, any>): any {
if (arg.type === 'variable') {
@@ -85,32 +84,24 @@ export function createLiquidTagWorker(
const helperContext: PluginTemplateTagContext = {
app: {
alert: () => {
throw new Error(legacyModeErrorMessage);
},
dialog: () => {
throw new Error(legacyModeErrorMessage);
},
prompt: () => {
throw new Error(legacyModeErrorMessage);
},
getPath: () => {
throw new Error(legacyModeErrorMessage);
},
alert: async (title: string, message?: string) =>
fetchFromTemplateWorkerDatabase('app.alert', { title, message }),
dialog: async (title: string) =>
fetchFromTemplateWorkerDatabase('app.dialog', { title }),
prompt: async (title: string, options?: { label?: string; defaultValue?: string; submitName?: string; inputType?: string }) =>
fetchFromTemplateWorkerDatabase('app.prompt', { title, options }),
getPath: async (name: string) =>
fetchFromTemplateWorkerDatabase('app.getPath', { name }),
getInfo: () => ({ version: packageJson.version, platform }),
showSaveDialog: async () => {
throw new Error(legacyModeErrorMessage);
},
showSaveDialog: async (options?: { defaultPath?: string }) =>
fetchFromTemplateWorkerDatabase('app.showSaveDialog', { options }),
clipboard: {
readText: () => {
throw new Error(legacyModeErrorMessage);
},
writeText: () => {
throw new Error(legacyModeErrorMessage);
},
clear: () => {
throw new Error(legacyModeErrorMessage);
},
readText: async () =>
fetchFromTemplateWorkerDatabase('app.clipboard.readText', {}),
writeText: async (text: string) =>
fetchFromTemplateWorkerDatabase('app.clipboard.writeText', { text }),
clear: async () =>
fetchFromTemplateWorkerDatabase('app.clipboard.clear', {}),
},
},
store: {

View File

@@ -89,7 +89,15 @@ export type PluginToMainAPIPaths =
| 'network.sendRequestWithoutSideEffects'
| 'plugin.getBundlePluginTemplateTags'
| 'plugin.executeBundlePluginTag'
| 'plugin.executeBundlePluginMainAction';
| 'plugin.executeBundlePluginMainAction'
| 'app.alert'
| 'app.dialog'
| 'app.prompt'
| 'app.getPath'
| 'app.showSaveDialog'
| 'app.clipboard.readText'
| 'app.clipboard.writeText'
| 'app.clipboard.clear';
export type RenderedRequest = Request & {
cookies: {
@@ -270,20 +278,20 @@ interface PromptModalOptions {
}
export interface AppContext {
alert: (title: string, message?: string) => void;
alert: (title: string, message?: string) => Promise<void>;
dialog: (
title: string,
body: HTMLElement,
options?: { onHide?: () => void; tall?: boolean; skinny?: boolean; wide?: boolean },
) => void;
) => Promise<void>;
prompt: (
title: string,
options?: Pick<PromptModalOptions, 'label' | 'defaultValue' | 'submitName' | 'inputType'>,
) => Promise<string>;
getPath: (name: string) => string;
getPath: (name: string) => Promise<string>;
getInfo: () => { version: string; platform: NodeJS.Platform };
showSaveDialog: (options?: { defaultPath?: string }) => Promise<string | null>;
clipboard: { readText(): string; writeText(text: string): void; clear(): void };
clipboard: { readText(): Promise<string>; writeText(text: string): Promise<void>; clear(): Promise<void> };
}
export interface PluginTemplateTagContext {
app: AppContext;

View File

@@ -36,11 +36,11 @@ window.main.on('plugins.uiDialog', (_, options: Record<string, any>) => {
window.showWrapper?.(options);
});
window.main.on('plugins.uiPrompt', (_, id: string, options: Record<string, any>) => {
window.main.on('ui.prompt', (_, id: string, options: Record<string, any>) => {
window.showPrompt?.({
...options,
onComplete: (value: string) => {
window.main.notifyPluginPromptResult(id, value);
window.main.notifyPromptResult(id, value);
},
onHide: () => {},
});