mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
Merge pull request #1442 from illuwa-soft/feat/upstream-progress-toasts
feat: expose frame-scoped plugin progress toasts # Conflicts: # lib/plugin-sandbox/host-bridge.ts
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -18,7 +18,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';
|
||||
@@ -122,6 +122,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',
|
||||
@@ -1986,30 +1988,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. */
|
||||
@@ -2017,7 +1995,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
|
||||
@@ -2068,10 +2046,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);
|
||||
|
||||
@@ -303,6 +303,9 @@ export class SandboxInstance {
|
||||
private ready = false;
|
||||
/** The runtime's activity reports (see ReadyMsg). */
|
||||
private activityPort: MessagePort | null = null;
|
||||
/** 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;
|
||||
|
||||
@@ -497,7 +500,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) });
|
||||
@@ -717,6 +720,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);
|
||||
|
||||
@@ -640,6 +640,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]); },
|
||||
|
||||
Reference in New Issue
Block a user