diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 732c1029..f9c84323 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -60,6 +60,7 @@ const PERM_PER_METHOD: Record = { 'ui.prompt': null, 'ui.rerenderEmail': null, 'ui.openExternalUrl': null, + 'ui.downloadFile': 'ui:download-file' }; function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean { @@ -400,6 +401,24 @@ async function saveFile(formerFileID:string, file: File): Promise { return fileId; } +// ─── Download files generated by the plugin. This is not user's files or attachments ────────────────────────── +async function downloadFile(args: { content: string; filename: string; contentType?: string }): Promise { + const { content, filename, contentType = 'application/json' } = args; + + try { + const url = URL.createObjectURL(new Blob([content], { type: contentType })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + throw new Error(`Failed to download file: ${error}`); + } +} + // ─── admin config (same as before) ──────────────────────────── async function adminGetAll(pluginId: string): Promise> { @@ -547,6 +566,10 @@ export async function dispatchApiCall( window.open(parsed.toString(), '_blank', 'noopener,noreferrer'); return undefined; } + case 'ui.downloadFile': { + const opts = args[0] as { content: string; filename: string; contentType?: string }; + return downloadFile(opts); + } default: throw new Error(`Unhandled method "${method}"`); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 6db43b79..1cb025fa 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -245,7 +245,7 @@ export const API_METHODS = [ 'jmap.fetchBlob', 'jmap.sendRaw', 'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig', 'toast.success', 'toast.error', 'toast.info', 'toast.warning', - 'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl', + 'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl', 'ui.downloadFile' ] as const; export type ApiMethod = (typeof API_METHODS)[number]; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index e39a328d..9e72b519 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -232,6 +232,9 @@ function buildPluginApi(manifest: PluginManifest) { /** Opens an http/https URL in a new tab via host `window.open`. */ openExternalUrl: (url: string, target?: string) => callApi('ui.openExternalUrl', [url, target]) as Promise, + /** Downloads a file generated by the plugin. Not a user's file or attachment. */ + downloadFile: (opts: { content: string; filename: string; contentType?: string }) => + callApi('ui.downloadFile', [opts]) as Promise, }, admin: { getConfig: (key: string) => callApi('admin.getConfig', [key]), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 0245054c..32fcb573 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -925,6 +925,7 @@ export const ALL_PERMISSIONS = [ 'http:post', 'http:fetch', 'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', 'ui:email-details', + 'ui:download-file', 'ui:composer-toolbar', 'ui:composer-sidebar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',