feat: expose frame-scoped plugin progress toasts

This commit is contained in:
yontani
2026-10-30 08:42:00 +09:00
parent 950fc21a79
commit cf0e53596a
7 changed files with 260 additions and 31 deletions
+20
View File
@@ -0,0 +1,20 @@
# Plugin progress toasts
`api.toast.progress(message): Promise<string>` 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<void>` 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.
진행 알림은 명시적 종료 또는 프레임 종료까지 유지됩니다. 플러그인은 작업의 성공·실패·취소를, 호스트는 프레임별 표시·소유권·정리를 담당합니다.
@@ -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 <ToastContainer toasts={toasts} onClose={removeToast} />;
}
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(<Toasts />);
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(<Toasts />);
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(); }
});
});
@@ -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();
+72
View File
@@ -0,0 +1,72 @@
import { toast, useToastStore } from '@/stores/toast-store';
import type { ToastType } from '@/components/ui/toast';
const recentToasts = new Map<string, number[]>();
const progressToasts = new Map<string, { pluginId: string; signal: AbortSignal; release: () => 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);
}
+10 -30
View File
@@ -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<Record<string, Permission | null>> = {
'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<void> {
}));
}
// ─── Toasts ──────────────────────────────────────────────────
const MAX_TOAST_LENGTH = 300;
const TOASTS_PER_TEN_SECONDS = 5;
const recentToasts = new Map<string, number[]>();
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<unknown> {
// 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);
+5 -1
View File
@@ -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);
+6
View File
@@ -589,6 +589,12 @@ function buildPluginApi(manifest: PluginManifest) {
callApi('composer.attach', [composerId, file]) as Promise<boolean>,
},
toast: {
/** Stay visible until dismissed or this frame closes; returns a frame-owned handle.
* 직접 닫거나 프레임이 종료될 때까지 표시하고 프레임 소유 핸들을 반환합니다. */
progress: (m: string) => callApi('toast.progress', [m]) as Promise<string>,
/** Dismiss this frame's progress handle, even after the burst limit.
* 알림 빈도 제한과 관계없이 현재 프레임의 진행 알림을 닫습니다. */
dismiss: (id: string) => callApi('toast.dismiss', [id]) as Promise<void>,
success: (m: string) => { void callApi('toast.success', [m]); },
error: (m: string) => { void callApi('toast.error', [m]); },
info: (m: string) => { void callApi('toast.info', [m]); },