diff --git a/packages/insomnia-smoke-test/tests/smoke/template-tags-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/template-tags-interactions.test.ts index 38cac2ff26..99eccbca0a 100644 --- a/packages/insomnia-smoke-test/tests/smoke/template-tags-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/template-tags-interactions.test.ts @@ -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(); }); diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 1e835801fd..704b03bc8c 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -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, appendToFile: (options: { timelinePath: string; data: string }) => diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 214eea46b6..eb71c2b72c 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -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' diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 2daa99915c..b61e084045 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -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; decryptSecretValue: (encryptedValue: string, symmetricKey: JsonWebKey) => Promise; diff --git a/packages/insomnia/src/main/plugin-window.ts b/packages/insomnia/src/main/plugin-window.ts index 8d043da2ac..63faeb8b48 100644 --- a/packages/insomnia/src/main/plugin-window.ts +++ b/packages/insomnia/src/main/plugin-window.ts @@ -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 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(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( diff --git a/packages/insomnia/src/main/prompt-bridge.ts b/packages/insomnia/src/main/prompt-bridge.ts new file mode 100644 index 0000000000..0ed0e10ff7 --- /dev/null +++ b/packages/insomnia/src/main/prompt-bridge.ts @@ -0,0 +1,42 @@ +import { randomUUID } from 'node:crypto'; + +import { ipcMain } from 'electron'; + +import { getMainWindow } from './window-utils'; + +const promptPendingRequests = new Map void>(); + +export function requestPromptFromRenderer(options: { + title: string; + label?: string; + defaultValue?: string; +}): Promise { + 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); +}); diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index 32ed81fc6e..13f238466a 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -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 = {}; @@ -308,4 +309,33 @@ const pluginToMainAPI: Record 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[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(); + }, }; diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index 6dd0945d95..0771e8b22a 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -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; diff --git a/packages/insomnia/src/plugins/context/app.ts b/packages/insomnia/src/plugins/context/app.ts index c1e9ca036e..78d62671d6 100644 --- a/packages/insomnia/src/plugins/context/app.ts +++ b/packages/insomnia/src/plugins/context/app.ts @@ -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(), }, }, }); diff --git a/packages/insomnia/src/templating/liquid-extension-worker.ts b/packages/insomnia/src/templating/liquid-extension-worker.ts index 860b85a28d..67b48a581e 100644 --- a/packages/insomnia/src/templating/liquid-extension-worker.ts +++ b/packages/insomnia/src/templating/liquid-extension-worker.ts @@ -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[number], scope: Record): 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: { diff --git a/packages/insomnia/src/templating/types.ts b/packages/insomnia/src/templating/types.ts index b978c00c5d..e239db8695 100644 --- a/packages/insomnia/src/templating/types.ts +++ b/packages/insomnia/src/templating/types.ts @@ -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; dialog: ( title: string, body: HTMLElement, options?: { onHide?: () => void; tall?: boolean; skinny?: boolean; wide?: boolean }, - ) => void; + ) => Promise; prompt: ( title: string, options?: Pick, ) => Promise; - getPath: (name: string) => string; + getPath: (name: string) => Promise; getInfo: () => { version: string; platform: NodeJS.Platform }; showSaveDialog: (options?: { defaultPath?: string }) => Promise; - clipboard: { readText(): string; writeText(text: string): void; clear(): void }; + clipboard: { readText(): Promise; writeText(text: string): Promise; clear(): Promise }; } export interface PluginTemplateTagContext { app: AppContext; diff --git a/packages/insomnia/src/ui/renderer-listeners.ts b/packages/insomnia/src/ui/renderer-listeners.ts index c45daa0ff2..b23a869695 100644 --- a/packages/insomnia/src/ui/renderer-listeners.ts +++ b/packages/insomnia/src/ui/renderer-listeners.ts @@ -36,11 +36,11 @@ window.main.on('plugins.uiDialog', (_, options: Record) => { window.showWrapper?.(options); }); -window.main.on('plugins.uiPrompt', (_, id: string, options: Record) => { +window.main.on('ui.prompt', (_, id: string, options: Record) => { window.showPrompt?.({ ...options, onComplete: (value: string) => { - window.main.notifyPluginPromptResult(id, value); + window.main.notifyPromptResult(id, value); }, onHide: () => {}, });