From cf0e53596a65a5fc2f35cd6967d2100c0ee1c010 Mon Sep 17 00:00:00 2001 From: yontani Date: Fri, 30 Oct 2026 08:42:00 +0900 Subject: [PATCH] feat: expose frame-scoped plugin progress toasts --- PLUGIN_PROGRESS_TOASTS.md | 20 +++ lib/__tests__/plugin-progress-toasts.test.tsx | 129 ++++++++++++++++++ .../plugin-sandbox-runtime-host-gate.test.ts | 18 +++ lib/plugin-sandbox/host-api-toasts.ts | 72 ++++++++++ lib/plugin-sandbox/host-api.ts | 40 ++---- lib/plugin-sandbox/host-bridge.ts | 6 +- lib/plugin-sandbox/runtime.tsx | 6 + 7 files changed, 260 insertions(+), 31 deletions(-) create mode 100644 PLUGIN_PROGRESS_TOASTS.md create mode 100644 lib/__tests__/plugin-progress-toasts.test.tsx create mode 100644 lib/plugin-sandbox/host-api-toasts.ts diff --git a/PLUGIN_PROGRESS_TOASTS.md b/PLUGIN_PROGRESS_TOASTS.md new file mode 100644 index 000000000..cc91e177f --- /dev/null +++ b/PLUGIN_PROGRESS_TOASTS.md @@ -0,0 +1,20 @@ +# Plugin progress toasts + +`api.toast.progress(message): Promise` displays an indeterminate progress toast until it is dismissed or its sandbox frame is destroyed. It stays beyond the ordinary five-second timeout. No additional manifest permission is required; Lite's existing plugin policy remains unchanged. + +`api.toast.dismiss(id): Promise` removes only a progress handle created by the same plugin **and frame**. Repeated dismissal, unknown handles and handles from another frame are no-ops. Native application notices are not dismissible through this API. Closing a notice does not cancel the underlying task or recreate the notice. + +```js +const id = await api.toast.progress('Preparing the document…'); +try { + await prepareDocument(); +} finally { + await api.toast.dismiss(id); +} +``` + +Progress starts share the ordinary **five starts per ten seconds per plugin** budget. At most **five progress notices per plugin** may remain outstanding across frames. A refused start rejects its promise. Messages are capped at **300 characters**. Dismissal and frame-destruction cleanup are never rate-limited. + +The plugin owns task completion, failure, cancellation and terminal messages. The host owns display, frame-scoped handles and cleanup. Every `SandboxInstance` owns a dedicated `toastLifetime` AbortController, passed to dispatch and aborted by `destroy()`. Slot closure, navigation away, failed activation and plugin deactivation use that existing destruction path. The API adds no backend, credentials or product processing. + +진행 알림은 명시적 종료 또는 프레임 종료까지 유지됩니다. 플러그인은 작업의 성공·실패·취소를, 호스트는 프레임별 표시·소유권·정리를 담당합니다. diff --git a/lib/__tests__/plugin-progress-toasts.test.tsx b/lib/__tests__/plugin-progress-toasts.test.tsx new file mode 100644 index 000000000..ee6af9da1 --- /dev/null +++ b/lib/__tests__/plugin-progress-toasts.test.tsx @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { ToastContainer } from '@/components/ui/toast'; +import { toast, useToastStore } from '@/stores/toast-store'; +import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api'; +import { createBackgroundInstance } from '../plugin-sandbox/host-bridge'; +import type { InstalledPlugin } from '../plugin-types'; + +let sequence = 0; +const lifetimes: AbortController[] = []; + +/** Give each test its own plugin and frame lifetime. 각 시험의 플러그인과 프레임 수명을 분리합니다. */ +function owner() { + const controller = new AbortController(); + lifetimes.push(controller); + const plugin: InstalledPlugin = { + id: `progress-${++sequence}`, name: 'Progress', version: '1.0.0', author: '', description: '', + type: 'hook', permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, + }; + const options = { toastSignal: controller.signal }; + return { plugin, controller, options }; +} + +/** Render the real host notification lifecycle. 실제 호스트 알림 수명 주기를 렌더링합니다. */ +function Toasts() { + const toasts = useToastStore((state) => state.toasts); + const removeToast = useToastStore((state) => state.removeToast); + return ; +} + +beforeEach(() => { vi.useFakeTimers(); useToastStore.getState().clearToasts(); }); +afterEach(() => { + for (const controller of lifetimes.splice(0)) controller.abort(); + cleanup(); + useToastStore.getState().clearToasts(); + vi.useRealTimers(); +}); + +describe('sandbox progress toasts', () => { + it('keeps an indeterminate native toast beyond five seconds, then dismisses it explicitly', async () => { + const scope = owner(); + render(); + let id: unknown; + await act(async () => { id = await dispatchApiCall(scope.plugin, 'toast.progress', ['Working'], scope.options); }); + expect(typeof id).toBe('string'); + expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow'); + act(() => { toast.info('Ordinary'); }); + act(() => { vi.advanceTimersByTime(6000); }); + expect(screen.getByText('Working')).toBeInTheDocument(); + expect(screen.queryByText('Ordinary')).toBeNull(); + await act(async () => { await dispatchApiCall(scope.plugin, 'toast.dismiss', [id], scope.options); }); + expect(screen.queryByText('Working')).toBeNull(); + }); + + it('cannot dismiss native notices or notices belonging to another plugin or frame', async () => { + const a = owner(); + const b = owner(); + const id = await dispatchApiCall(a.plugin, 'toast.progress', ['Owned'], a.options); + toast.info('Native'); + const nativeId = useToastStore.getState().toasts.find((item) => item.title === 'Native')!.id; + await dispatchApiCall(b.plugin, 'toast.dismiss', [id], b.options); + await dispatchApiCall(a.plugin, 'toast.dismiss', [id], b.options); + await dispatchApiCall(a.plugin, 'toast.dismiss', [nativeId], a.options); + expect(useToastStore.getState().toasts.map((item) => item.title)).toEqual(['Owned', 'Native']); + await dispatchApiCall(a.plugin, 'toast.dismiss', [id], a.options); + await dispatchApiCall(a.plugin, 'toast.dismiss', [id], a.options); + expect(useToastStore.getState().toasts.map((item) => item.title)).toEqual(['Native']); + }); + + it('bounds outstanding progress notices across frames even after the burst window', async () => { + const scope = owner(); + const otherFrame = owner(); + const ids = []; + for (let index = 0; index < 5; index++) ids.push(await dispatchApiCall(scope.plugin, 'toast.progress', ['Working'], scope.options)); + vi.advanceTimersByTime(10001); + await expect(dispatchApiCall(scope.plugin, 'toast.progress', ['Overflow'], otherFrame.options)).rejects.toThrow(/too many/i); + await dispatchApiCall(scope.plugin, 'toast.dismiss', [ids[0]], scope.options); + await expect(dispatchApiCall(scope.plugin, 'toast.progress', ['Replacement'], scope.options)).resolves.toEqual(expect.any(String)); + }); + + it('shares the normal toast burst limit but never rate-limits dismissal', async () => { + const scope = owner(); + const id = await dispatchApiCall(scope.plugin, 'toast.progress', ['Working'], scope.options); + for (let index = 0; index < 4; index++) await dispatchApiCall(scope.plugin, 'toast.info', ['Info']); + await expect(dispatchApiCall(scope.plugin, 'toast.progress', ['Overflow'], scope.options)).rejects.toThrow(/too many/i); + await dispatchApiCall(scope.plugin, 'toast.dismiss', [id], scope.options); + expect(useToastStore.getState().toasts.some((item) => item.id === id)).toBe(false); + }); + + it('releases dismissed entries and caps text without recreating user-closed progress', async () => { + const scope = owner(); + render(); + await act(async () => { await dispatchApiCall(scope.plugin, 'toast.progress', ['x'.repeat(1000)], scope.options); }); + expect(useToastStore.getState().toasts[0].title).toHaveLength(300); + fireEvent.click(screen.getByRole('group').querySelector('button')!); + act(() => { vi.advanceTimersByTime(20000); }); + expect(screen.queryByRole('progressbar')).toBeNull(); + for (let index = 0; index < 5; index++) await dispatchApiCall(scope.plugin, 'toast.progress', ['Next'], scope.options); + expect(useToastStore.getState().toasts).toHaveLength(5); + }); + + it('cleans only the aborted frame and rejects calls without a live owner', async () => { + const a = owner(); + const b = owner(); + await dispatchApiCall(a.plugin, 'toast.progress', ['First'], a.options); + await dispatchApiCall(a.plugin, 'toast.progress', ['Other frame'], b.options); + a.controller.abort(); + expect(useToastStore.getState().toasts.map((item) => item.title)).toEqual(['Other frame']); + await expect(dispatchApiCall(a.plugin, 'toast.progress', ['Late'], a.options)).rejects.toThrow(/live/i); + await expect(dispatchApiCall(a.plugin, 'toast.progress', ['Unowned'])).rejects.toThrow(/live/i); + expect(PERM_PER_METHOD['toast.progress']).toBeNull(); + expect(PERM_PER_METHOD['toast.dismiss']).toBeNull(); + }); + + it('binds RPC-created notices to the real sandbox destruction lifecycle', async () => { + vi.useRealTimers(); + const scope = owner(); + const instance = createBackgroundInstance({ plugin: scope.plugin, code: '', locale: 'en', tier: 'untrusted' }); + try { + window.dispatchEvent(new MessageEvent('message', { + source: instance.iframe.contentWindow, origin: 'null', + data: { type: 'api-request', id: 'progress', method: 'toast.progress', args: ['Frame work'] }, + })); + await vi.waitFor(() => expect(useToastStore.getState().toasts).toHaveLength(1)); + instance.destroy(); + expect(useToastStore.getState().toasts).toHaveLength(0); + } finally { instance.destroy(); } + }); +}); diff --git a/lib/__tests__/plugin-sandbox-runtime-host-gate.test.ts b/lib/__tests__/plugin-sandbox-runtime-host-gate.test.ts index d1ffd7b94..e0d083d9a 100644 --- a/lib/__tests__/plugin-sandbox-runtime-host-gate.test.ts +++ b/lib/__tests__/plugin-sandbox-runtime-host-gate.test.ts @@ -128,6 +128,24 @@ describe('plugin sandbox runtime host gate (GHSA-96cx-gx36-3g79)', () => { expect(marker.__sandboxBundleRan).toBe(HOST_ORIGIN); }); + it('returns the progress handle through RPC and awaits its explicit dismissal', async () => { + framed = true; + await mountRuntime(); + const message = initMessage(); + message.payload.code = 'module.exports = { activate: async (api) => { const id = await api.toast.progress("Working"); await api.toast.dismiss(id); } };'; + post(hostWindow, HOST_ORIGIN, message); + await flush(); + const request = hostWindow.postMessage.mock.calls.map(([msg]) => msg).find((msg) => msg.method === 'toast.progress'); + expect(request).toMatchObject({ type: 'api-request', args: ['Working'] }); + post(hostWindow, HOST_ORIGIN, { type: 'api-response', id: request.id, ok: true, result: 'owned-toast' }); + await flush(); + const dismiss = hostWindow.postMessage.mock.calls.map(([msg]) => msg).find((msg) => msg.method === 'toast.dismiss'); + expect(dismiss).toMatchObject({ type: 'api-request', args: ['owned-toast'] }); + post(hostWindow, HOST_ORIGIN, { type: 'api-response', id: dismiss.id, ok: true }); + await flush(); + expect(postedTypes().some(([type]) => type === 'init-done')).toBe(true); + }); + it('boots the bundle for the framing window on its own origin and reports back to it', async () => { framed = true; await mountRuntime(); diff --git a/lib/plugin-sandbox/host-api-toasts.ts b/lib/plugin-sandbox/host-api-toasts.ts new file mode 100644 index 000000000..c00c86358 --- /dev/null +++ b/lib/plugin-sandbox/host-api-toasts.ts @@ -0,0 +1,72 @@ +import { toast, useToastStore } from '@/stores/toast-store'; +import type { ToastType } from '@/components/ui/toast'; + +const recentToasts = new Map(); +const progressToasts = new Map void }>(); + +/** Bound plugin text before it reaches host UI. 호스트 UI에 전달하기 전에 플러그인 문구를 제한합니다. */ +function toastText(value: unknown): string { + const text = String(value ?? ''); + if (text.length <= 300) return text; + return text.slice(0, 299) + '…'; +} + +/** Share the existing five-per-ten-seconds budget across toast kinds. + * 모든 알림 종류에 기존 10초당 5회 제한을 함께 적용합니다. */ +function mayToast(pluginId: string): boolean { + const now = Date.now(); + const recent = (recentToasts.get(pluginId) ?? []).filter((time) => now - time < 10_000); + recentToasts.set(pluginId, recent); + if (recent.length >= 5) return false; + recent.push(now); + return true; +} + +/** Keep ordinary notices fire-and-forget and burst-limited. + * 일반 알림은 기존의 비동기 단방향 호출과 빈도 제한을 유지합니다. */ +export function showPluginToast(pluginId: string, type: ToastType, message: unknown): void { + if (mayToast(pluginId)) toast[type](toastText(message)); +} + +/** Release UI and abort listeners together, including user dismissal. + * 사용자가 닫은 경우에도 UI와 수명 구독을 함께 해제합니다. */ +function releaseProgressToast(id: string): void { + const entry = progressToasts.get(id); + if (!entry) return; + progressToasts.delete(id); + entry.signal.removeEventListener('abort', entry.release); + toast.dismiss(id); +} + +/** Admit an indeterminate notice owned by one live sandbox frame. + * 살아 있는 샌드박스 프레임 하나에 속한 불확정 진행 알림을 생성합니다. */ +export function showPluginProgressToast(pluginId: string, message: unknown, signal?: AbortSignal): string { + if (!signal || signal.aborted) throw new Error('Progress toasts require a live sandbox instance'); + const visible = new Set(useToastStore.getState().toasts.map((item) => item.id)); + for (const id of progressToasts.keys()) { + if (!visible.has(id)) releaseProgressToast(id); + } + const active = [...progressToasts.values()].filter((entry) => entry.pluginId === pluginId).length; + if (active >= 5 || !mayToast(pluginId)) throw new Error('Plugin has too many toasts'); + const id = useToastStore.getState().addToast({ + type: 'info', title: toastText(message), progress: null, duration: 0, + onDismiss: () => releaseProgressToast(id), + }); + const release = () => releaseProgressToast(id); + progressToasts.set(id, { pluginId, signal, release }); + signal.addEventListener('abort', release, { once: true }); + if (signal.aborted) { + release(); + throw new Error('Progress toasts require a live sandbox instance'); + } + return id; +} + +/** Dismiss only this frame's handle; cleanup is idempotent and never throttled. + * 현재 프레임의 알림만 닫으며 정리는 반복 호출해도 안전하고 빈도 제한을 받지 않습니다. */ +export function dismissPluginToast(pluginId: string, id: unknown, signal?: AbortSignal): void { + if (typeof id !== 'string') throw new Error('A toast handle is required'); + const entry = progressToasts.get(id); + if (!entry || entry.pluginId !== pluginId || entry.signal !== signal) return; + releaseProgressToast(id); +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 4aa0e3253..351682f89 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -13,7 +13,7 @@ import { checkDayLabels, setDayLabels } from './day-labels'; import { refreshRowBadges } from './row-badges'; import { checkListFilters, setPluginListFilters } from './list-filters'; import { checkPages, setPluginPages } from './pages'; -import { toast as appToast } from '@/stores/toast-store'; +import { dismissPluginToast, showPluginProgressToast, showPluginToast } from './host-api-toasts'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountStore } from '@/stores/account-store'; import { claimLegacyPluginStorage, pluginStoragePrefix } from './storage-scope'; @@ -112,6 +112,8 @@ export const PERM_PER_METHOD: Readonly> = { 'toast.error': null, 'toast.info': null, 'toast.warning': null, + 'toast.progress': null, + 'toast.dismiss': null, // http 'http.post': 'http:post', 'http.fetch': 'http:fetch', @@ -1935,30 +1937,6 @@ async function adminDelete(pluginId: string, key: string): Promise { })); } -// ─── Toasts ────────────────────────────────────────────────── - -const MAX_TOAST_LENGTH = 300; -const TOASTS_PER_TEN_SECONDS = 5; -const recentToasts = new Map(); - -function toastText(value: unknown): string { - const text = String(value ?? ''); - return text.length > MAX_TOAST_LENGTH ? text.slice(0, MAX_TOAST_LENGTH - 1) + '…' : text; -} - -/** A plugin may show a few toasts; past that they are dropped, not queued. */ -function mayToast(pluginId: string): boolean { - const now = Date.now(); - const recent = (recentToasts.get(pluginId) ?? []).filter((t) => now - t < 10_000); - if (recent.length >= TOASTS_PER_TEN_SECONDS) { - recentToasts.set(pluginId, recent); - return false; - } - recent.push(now); - recentToasts.set(pluginId, recent); - return true; -} - // ─── Dispatcher ────────────────────────────────────────────── /** Resolves an api-request method against the per-plugin permissions. */ @@ -1966,7 +1944,7 @@ export async function dispatchApiCall( plugin: InstalledPlugin, method: string, args: unknown[], - opts?: { privileged?: boolean; sink?: StreamSink }, + opts?: { privileged?: boolean; sink?: StreamSink; toastSignal?: AbortSignal }, ): Promise { // Tier gate: privileged-only methods are refused for untrusted (null-origin) // instances even if the permission is somehow present. Defence-in-depth on @@ -2016,10 +1994,12 @@ export async function dispatchApiCall( case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined; case 'storage.keys': return storageKeys(plugin.id); - case 'toast.success': if (mayToast(plugin.id)) appToast.success(toastText(args[0])); return undefined; - case 'toast.error': if (mayToast(plugin.id)) appToast.error(toastText(args[0])); return undefined; - case 'toast.info': if (mayToast(plugin.id)) appToast.info(toastText(args[0])); return undefined; - case 'toast.warning': if (mayToast(plugin.id)) appToast.warning(toastText(args[0])); return undefined; + case 'toast.success': showPluginToast(plugin.id, 'success', args[0]); return undefined; + case 'toast.error': showPluginToast(plugin.id, 'error', args[0]); return undefined; + case 'toast.info': showPluginToast(plugin.id, 'info', args[0]); return undefined; + case 'toast.warning': showPluginToast(plugin.id, 'warning', args[0]); return undefined; + case 'toast.progress': return showPluginProgressToast(plugin.id, args[0], opts?.toastSignal); + case 'toast.dismiss': dismissPluginToast(plugin.id, args[0], opts?.toastSignal); return undefined; case 'http.post': return doHttpPost(plugin, args[0] as string, args[1], args[2] as PluginHttpPostOptions | undefined); case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 1d0e7d67c..b97e41984 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -298,6 +298,9 @@ export class SandboxInstance { private rejectInit!: (err: Error) => void; private listener: (ev: MessageEvent) => void; private destroyed = false; + /** Own notification cleanup for exactly this sandbox frame. + * 현재 샌드박스 프레임의 알림 정리 수명을 소유합니다. */ + private readonly toastLifetime = new AbortController(); /** The frame has answered init-done: its hooks and slots are in place. */ private initialized = false; @@ -487,7 +490,7 @@ export class SandboxInstance { const { id, method, args } = msg; void (async () => { try { - const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged, sink: this }); + const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged, sink: this, toastSignal: this.toastLifetime.signal }); this.send({ type: 'api-response', id, ok: true, result }); } catch (err) { this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) }); @@ -694,6 +697,7 @@ export class SandboxInstance { destroy(): void { if (this.destroyed) return; this.destroyed = true; + this.toastLifetime.abort(); // Whoever waits for init (the loader, calls held back below) stops waiting. this.rejectInit(new Error('sandbox destroyed')); liveInstances.delete(this); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 4f98353fc..b702b5b67 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -589,6 +589,12 @@ function buildPluginApi(manifest: PluginManifest) { callApi('composer.attach', [composerId, file]) as Promise, }, toast: { + /** Stay visible until dismissed or this frame closes; returns a frame-owned handle. + * 직접 닫거나 프레임이 종료될 때까지 표시하고 프레임 소유 핸들을 반환합니다. */ + progress: (m: string) => callApi('toast.progress', [m]) as Promise, + /** Dismiss this frame's progress handle, even after the burst limit. + * 알림 빈도 제한과 관계없이 현재 프레임의 진행 알림을 닫습니다. */ + dismiss: (id: string) => callApi('toast.dismiss', [id]) as Promise, success: (m: string) => { void callApi('toast.success', [m]); }, error: (m: string) => { void callApi('toast.error', [m]); }, info: (m: string) => { void callApi('toast.info', [m]); },