From 628966d3b51968e45b10f833911c605238492c3c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 23:41:49 +0200 Subject: [PATCH] fix: split app into (main)/(sandbox) route groups so plugin iframe hydrates properly --- .../[locale]/auth/callback/page.tsx | 0 app/{ => (main)}/[locale]/calendar/page.tsx | 0 app/{ => (main)}/[locale]/contacts/page.tsx | 0 app/{ => (main)}/[locale]/error.tsx | 0 app/{ => (main)}/[locale]/files/page.tsx | 0 app/{ => (main)}/[locale]/layout.tsx | 0 app/{ => (main)}/[locale]/login/page.tsx | 0 app/{ => (main)}/[locale]/page.tsx | 0 app/{ => (main)}/[locale]/pro/page.tsx | 10 ++--- app/{ => (main)}/[locale]/settings/page.tsx | 0 .../admin/_tabs/_jmap-servers-section.tsx | 0 app/{ => (main)}/admin/_tabs/auth.tsx | 0 app/{ => (main)}/admin/_tabs/branding.tsx | 0 app/{ => (main)}/admin/_tabs/dashboard.tsx | 0 app/{ => (main)}/admin/_tabs/logs.tsx | 0 app/{ => (main)}/admin/_tabs/marketplace.tsx | 0 .../admin/_tabs/plugin-config-panel.tsx | 0 app/{ => (main)}/admin/_tabs/plugins.tsx | 0 app/{ => (main)}/admin/_tabs/policy.tsx | 0 app/{ => (main)}/admin/_tabs/settings.tsx | 0 app/{ => (main)}/admin/_tabs/telemetry.tsx | 0 app/{ => (main)}/admin/_tabs/themes.tsx | 0 app/{ => (main)}/admin/_tabs/version.tsx | 0 app/{ => (main)}/admin/auth/page.tsx | 0 app/{ => (main)}/admin/branding/page.tsx | 0 .../admin/change-password/page.tsx | 0 app/{ => (main)}/admin/layout.tsx | 0 app/{ => (main)}/admin/login/page.tsx | 0 app/{ => (main)}/admin/logs/page.tsx | 0 .../admin/marketplace/[slug]/page.tsx | 0 app/{ => (main)}/admin/marketplace/page.tsx | 0 app/{ => (main)}/admin/page.tsx | 0 app/{ => (main)}/admin/plugins/[id]/page.tsx | 0 app/{ => (main)}/admin/plugins/page.tsx | 0 app/{ => (main)}/admin/policy/page.tsx | 0 app/{ => (main)}/admin/settings/page.tsx | 0 app/{ => (main)}/admin/telemetry/page.tsx | 0 app/{ => (main)}/admin/themes/page.tsx | 0 app/{ => (main)}/admin/version/page.tsx | 0 app/{ => (main)}/global-error.tsx | 0 app/{ => (main)}/layout.tsx | 2 +- app/{ => (main)}/not-found.tsx | 0 app/{ => (main)}/protocol/mailto/page.tsx | 0 app/{ => (main)}/protocol/webcal/page.tsx | 0 app/{ => (main)}/setup/layout.tsx | 0 app/{ => (main)}/setup/page.tsx | 0 app/{plugin-sandbox => (sandbox)}/layout.tsx | 0 app/(sandbox)/plugin-sandbox/page.tsx | 10 +++++ app/api/admin/plugins/route.ts | 13 +++++- app/api/plugins/route.ts | 11 ++++- app/plugin-sandbox/page.tsx | 7 ---- lib/plugin-sandbox/host-bridge.ts | 10 ++++- lib/plugin-sandbox/loader.ts | 41 ++++++++++++++++--- lib/plugin-sandbox/protocol.ts | 11 +++-- lib/plugin-sandbox/runtime.tsx | 13 +++--- proxy.ts | 6 ++- stores/plugin-store.ts | 12 ++++-- 57 files changed, 109 insertions(+), 37 deletions(-) rename app/{ => (main)}/[locale]/auth/callback/page.tsx (100%) rename app/{ => (main)}/[locale]/calendar/page.tsx (100%) rename app/{ => (main)}/[locale]/contacts/page.tsx (100%) rename app/{ => (main)}/[locale]/error.tsx (100%) rename app/{ => (main)}/[locale]/files/page.tsx (100%) rename app/{ => (main)}/[locale]/layout.tsx (100%) rename app/{ => (main)}/[locale]/login/page.tsx (100%) rename app/{ => (main)}/[locale]/page.tsx (100%) rename app/{ => (main)}/[locale]/pro/page.tsx (97%) rename app/{ => (main)}/[locale]/settings/page.tsx (100%) rename app/{ => (main)}/admin/_tabs/_jmap-servers-section.tsx (100%) rename app/{ => (main)}/admin/_tabs/auth.tsx (100%) rename app/{ => (main)}/admin/_tabs/branding.tsx (100%) rename app/{ => (main)}/admin/_tabs/dashboard.tsx (100%) rename app/{ => (main)}/admin/_tabs/logs.tsx (100%) rename app/{ => (main)}/admin/_tabs/marketplace.tsx (100%) rename app/{ => (main)}/admin/_tabs/plugin-config-panel.tsx (100%) rename app/{ => (main)}/admin/_tabs/plugins.tsx (100%) rename app/{ => (main)}/admin/_tabs/policy.tsx (100%) rename app/{ => (main)}/admin/_tabs/settings.tsx (100%) rename app/{ => (main)}/admin/_tabs/telemetry.tsx (100%) rename app/{ => (main)}/admin/_tabs/themes.tsx (100%) rename app/{ => (main)}/admin/_tabs/version.tsx (100%) rename app/{ => (main)}/admin/auth/page.tsx (100%) rename app/{ => (main)}/admin/branding/page.tsx (100%) rename app/{ => (main)}/admin/change-password/page.tsx (100%) rename app/{ => (main)}/admin/layout.tsx (100%) rename app/{ => (main)}/admin/login/page.tsx (100%) rename app/{ => (main)}/admin/logs/page.tsx (100%) rename app/{ => (main)}/admin/marketplace/[slug]/page.tsx (100%) rename app/{ => (main)}/admin/marketplace/page.tsx (100%) rename app/{ => (main)}/admin/page.tsx (100%) rename app/{ => (main)}/admin/plugins/[id]/page.tsx (100%) rename app/{ => (main)}/admin/plugins/page.tsx (100%) rename app/{ => (main)}/admin/policy/page.tsx (100%) rename app/{ => (main)}/admin/settings/page.tsx (100%) rename app/{ => (main)}/admin/telemetry/page.tsx (100%) rename app/{ => (main)}/admin/themes/page.tsx (100%) rename app/{ => (main)}/admin/version/page.tsx (100%) rename app/{ => (main)}/global-error.tsx (100%) rename app/{ => (main)}/layout.tsx (99%) rename app/{ => (main)}/not-found.tsx (100%) rename app/{ => (main)}/protocol/mailto/page.tsx (100%) rename app/{ => (main)}/protocol/webcal/page.tsx (100%) rename app/{ => (main)}/setup/layout.tsx (100%) rename app/{ => (main)}/setup/page.tsx (100%) rename app/{plugin-sandbox => (sandbox)}/layout.tsx (100%) create mode 100644 app/(sandbox)/plugin-sandbox/page.tsx delete mode 100644 app/plugin-sandbox/page.tsx diff --git a/app/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx similarity index 100% rename from app/[locale]/auth/callback/page.tsx rename to app/(main)/[locale]/auth/callback/page.tsx diff --git a/app/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx similarity index 100% rename from app/[locale]/calendar/page.tsx rename to app/(main)/[locale]/calendar/page.tsx diff --git a/app/[locale]/contacts/page.tsx b/app/(main)/[locale]/contacts/page.tsx similarity index 100% rename from app/[locale]/contacts/page.tsx rename to app/(main)/[locale]/contacts/page.tsx diff --git a/app/[locale]/error.tsx b/app/(main)/[locale]/error.tsx similarity index 100% rename from app/[locale]/error.tsx rename to app/(main)/[locale]/error.tsx diff --git a/app/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx similarity index 100% rename from app/[locale]/files/page.tsx rename to app/(main)/[locale]/files/page.tsx diff --git a/app/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx similarity index 100% rename from app/[locale]/layout.tsx rename to app/(main)/[locale]/layout.tsx diff --git a/app/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx similarity index 100% rename from app/[locale]/login/page.tsx rename to app/(main)/[locale]/login/page.tsx diff --git a/app/[locale]/page.tsx b/app/(main)/[locale]/page.tsx similarity index 100% rename from app/[locale]/page.tsx rename to app/(main)/[locale]/page.tsx diff --git a/app/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx similarity index 97% rename from app/[locale]/pro/page.tsx rename to app/(main)/[locale]/pro/page.tsx index 14d2bb79..30cfda7c 100644 --- a/app/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -16,11 +16,11 @@ import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar"; import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store"; import { cn } from "@/lib/utils"; -import MailPage from "@/app/[locale]/page"; -import CalendarPage from "@/app/[locale]/calendar/page"; -import ContactsPage from "@/app/[locale]/contacts/page"; -import FilesPage from "@/app/[locale]/files/page"; -import SettingsPage from "@/app/[locale]/settings/page"; +import MailPage from "@/app/(main)/[locale]/page"; +import CalendarPage from "@/app/(main)/[locale]/calendar/page"; +import ContactsPage from "@/app/(main)/[locale]/contacts/page"; +import FilesPage from "@/app/(main)/[locale]/files/page"; +import SettingsPage from "@/app/(main)/[locale]/settings/page"; import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body"; import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body"; diff --git a/app/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx similarity index 100% rename from app/[locale]/settings/page.tsx rename to app/(main)/[locale]/settings/page.tsx diff --git a/app/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx similarity index 100% rename from app/admin/_tabs/_jmap-servers-section.tsx rename to app/(main)/admin/_tabs/_jmap-servers-section.tsx diff --git a/app/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx similarity index 100% rename from app/admin/_tabs/auth.tsx rename to app/(main)/admin/_tabs/auth.tsx diff --git a/app/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx similarity index 100% rename from app/admin/_tabs/branding.tsx rename to app/(main)/admin/_tabs/branding.tsx diff --git a/app/admin/_tabs/dashboard.tsx b/app/(main)/admin/_tabs/dashboard.tsx similarity index 100% rename from app/admin/_tabs/dashboard.tsx rename to app/(main)/admin/_tabs/dashboard.tsx diff --git a/app/admin/_tabs/logs.tsx b/app/(main)/admin/_tabs/logs.tsx similarity index 100% rename from app/admin/_tabs/logs.tsx rename to app/(main)/admin/_tabs/logs.tsx diff --git a/app/admin/_tabs/marketplace.tsx b/app/(main)/admin/_tabs/marketplace.tsx similarity index 100% rename from app/admin/_tabs/marketplace.tsx rename to app/(main)/admin/_tabs/marketplace.tsx diff --git a/app/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx similarity index 100% rename from app/admin/_tabs/plugin-config-panel.tsx rename to app/(main)/admin/_tabs/plugin-config-panel.tsx diff --git a/app/admin/_tabs/plugins.tsx b/app/(main)/admin/_tabs/plugins.tsx similarity index 100% rename from app/admin/_tabs/plugins.tsx rename to app/(main)/admin/_tabs/plugins.tsx diff --git a/app/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx similarity index 100% rename from app/admin/_tabs/policy.tsx rename to app/(main)/admin/_tabs/policy.tsx diff --git a/app/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx similarity index 100% rename from app/admin/_tabs/settings.tsx rename to app/(main)/admin/_tabs/settings.tsx diff --git a/app/admin/_tabs/telemetry.tsx b/app/(main)/admin/_tabs/telemetry.tsx similarity index 100% rename from app/admin/_tabs/telemetry.tsx rename to app/(main)/admin/_tabs/telemetry.tsx diff --git a/app/admin/_tabs/themes.tsx b/app/(main)/admin/_tabs/themes.tsx similarity index 100% rename from app/admin/_tabs/themes.tsx rename to app/(main)/admin/_tabs/themes.tsx diff --git a/app/admin/_tabs/version.tsx b/app/(main)/admin/_tabs/version.tsx similarity index 100% rename from app/admin/_tabs/version.tsx rename to app/(main)/admin/_tabs/version.tsx diff --git a/app/admin/auth/page.tsx b/app/(main)/admin/auth/page.tsx similarity index 100% rename from app/admin/auth/page.tsx rename to app/(main)/admin/auth/page.tsx diff --git a/app/admin/branding/page.tsx b/app/(main)/admin/branding/page.tsx similarity index 100% rename from app/admin/branding/page.tsx rename to app/(main)/admin/branding/page.tsx diff --git a/app/admin/change-password/page.tsx b/app/(main)/admin/change-password/page.tsx similarity index 100% rename from app/admin/change-password/page.tsx rename to app/(main)/admin/change-password/page.tsx diff --git a/app/admin/layout.tsx b/app/(main)/admin/layout.tsx similarity index 100% rename from app/admin/layout.tsx rename to app/(main)/admin/layout.tsx diff --git a/app/admin/login/page.tsx b/app/(main)/admin/login/page.tsx similarity index 100% rename from app/admin/login/page.tsx rename to app/(main)/admin/login/page.tsx diff --git a/app/admin/logs/page.tsx b/app/(main)/admin/logs/page.tsx similarity index 100% rename from app/admin/logs/page.tsx rename to app/(main)/admin/logs/page.tsx diff --git a/app/admin/marketplace/[slug]/page.tsx b/app/(main)/admin/marketplace/[slug]/page.tsx similarity index 100% rename from app/admin/marketplace/[slug]/page.tsx rename to app/(main)/admin/marketplace/[slug]/page.tsx diff --git a/app/admin/marketplace/page.tsx b/app/(main)/admin/marketplace/page.tsx similarity index 100% rename from app/admin/marketplace/page.tsx rename to app/(main)/admin/marketplace/page.tsx diff --git a/app/admin/page.tsx b/app/(main)/admin/page.tsx similarity index 100% rename from app/admin/page.tsx rename to app/(main)/admin/page.tsx diff --git a/app/admin/plugins/[id]/page.tsx b/app/(main)/admin/plugins/[id]/page.tsx similarity index 100% rename from app/admin/plugins/[id]/page.tsx rename to app/(main)/admin/plugins/[id]/page.tsx diff --git a/app/admin/plugins/page.tsx b/app/(main)/admin/plugins/page.tsx similarity index 100% rename from app/admin/plugins/page.tsx rename to app/(main)/admin/plugins/page.tsx diff --git a/app/admin/policy/page.tsx b/app/(main)/admin/policy/page.tsx similarity index 100% rename from app/admin/policy/page.tsx rename to app/(main)/admin/policy/page.tsx diff --git a/app/admin/settings/page.tsx b/app/(main)/admin/settings/page.tsx similarity index 100% rename from app/admin/settings/page.tsx rename to app/(main)/admin/settings/page.tsx diff --git a/app/admin/telemetry/page.tsx b/app/(main)/admin/telemetry/page.tsx similarity index 100% rename from app/admin/telemetry/page.tsx rename to app/(main)/admin/telemetry/page.tsx diff --git a/app/admin/themes/page.tsx b/app/(main)/admin/themes/page.tsx similarity index 100% rename from app/admin/themes/page.tsx rename to app/(main)/admin/themes/page.tsx diff --git a/app/admin/version/page.tsx b/app/(main)/admin/version/page.tsx similarity index 100% rename from app/admin/version/page.tsx rename to app/(main)/admin/version/page.tsx diff --git a/app/global-error.tsx b/app/(main)/global-error.tsx similarity index 100% rename from app/global-error.tsx rename to app/(main)/global-error.tsx diff --git a/app/layout.tsx b/app/(main)/layout.tsx similarity index 99% rename from app/layout.tsx rename to app/(main)/layout.tsx index ec50f651..b98b064c 100644 --- a/app/layout.tsx +++ b/app/(main)/layout.tsx @@ -5,7 +5,7 @@ import { getLocale } from "next-intl/server"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { configManager } from "@/lib/admin/config-manager"; -import "./globals.css"; +import "../globals.css"; const geistSans = Geist({ variable: "--font-geist-sans", diff --git a/app/not-found.tsx b/app/(main)/not-found.tsx similarity index 100% rename from app/not-found.tsx rename to app/(main)/not-found.tsx diff --git a/app/protocol/mailto/page.tsx b/app/(main)/protocol/mailto/page.tsx similarity index 100% rename from app/protocol/mailto/page.tsx rename to app/(main)/protocol/mailto/page.tsx diff --git a/app/protocol/webcal/page.tsx b/app/(main)/protocol/webcal/page.tsx similarity index 100% rename from app/protocol/webcal/page.tsx rename to app/(main)/protocol/webcal/page.tsx diff --git a/app/setup/layout.tsx b/app/(main)/setup/layout.tsx similarity index 100% rename from app/setup/layout.tsx rename to app/(main)/setup/layout.tsx diff --git a/app/setup/page.tsx b/app/(main)/setup/page.tsx similarity index 100% rename from app/setup/page.tsx rename to app/(main)/setup/page.tsx diff --git a/app/plugin-sandbox/layout.tsx b/app/(sandbox)/layout.tsx similarity index 100% rename from app/plugin-sandbox/layout.tsx rename to app/(sandbox)/layout.tsx diff --git a/app/(sandbox)/plugin-sandbox/page.tsx b/app/(sandbox)/plugin-sandbox/page.tsx new file mode 100644 index 00000000..fa9576ee --- /dev/null +++ b/app/(sandbox)/plugin-sandbox/page.tsx @@ -0,0 +1,10 @@ +import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; + +// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in +// Next's injected hydration/chunk scripts. With force-static, those scripts +// render without a nonce and the strict sandbox CSP blocks them. +export const dynamic = 'force-dynamic'; + +export default function PluginSandboxPage() { + return ; +} diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 78c7e8b6..7f03c047 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -240,9 +240,18 @@ export async function PATCH(request: NextRequest) { if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled; const { updatePluginMeta } = await import('@/lib/admin/plugin-registry'); - const updated = await updatePluginMeta(id, updates); + let updated = await updatePluginMeta(id, updates); if (!updated) { - return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); + // Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but + // forceEnabled is canonical-stored in policy.forceEnabledPlugins on the + // client. Skip the registry write and return the live dev plugin so the + // policy save path can proceed. + const devEntries = await listDevPlugins(); + const devEntry = devEntries.find(e => e.plugin.id === id); + if (!devEntry) { + return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); + } + updated = { ...devEntry.plugin, ...updates }; } // Enable/disable changes the set of plugins contributing frame origins. diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 22c1154a..2cde26c6 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; import { listDevPlugins } from '@/lib/admin/plugin-dev'; +import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; /** @@ -11,6 +12,10 @@ import { logger } from '@/lib/logger'; */ export async function GET() { try { + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []); + const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([ getPluginRegistry(), getThemeRegistry(), @@ -34,7 +39,11 @@ export async function GET() { type: p.type, permissions: p.permissions, entrypoint: p.entrypoint, - forceEnabled: p.forceEnabled || false, + // Policy is the canonical source for force-enable. The per-plugin field + // can drift for dev plugins (manifest always loads forceEnabled:false) + // and during pending policy saves; OR'ing here unifies the signal so + // the client's auto-enable path triggers consistently. + forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id), // Content hash + updatedAt let clients detect re-uploads even when // the manifest version is unchanged. bundleHash: p.bundleHash, diff --git a/app/plugin-sandbox/page.tsx b/app/plugin-sandbox/page.tsx deleted file mode 100644 index 6ddfd078..00000000 --- a/app/plugin-sandbox/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; - -export const dynamic = 'force-static'; - -export default function PluginSandboxPage() { - return ; -} diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 365ce1de..a421a136 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -118,7 +118,15 @@ export class SandboxInstance { }); this.iframe = document.createElement('iframe'); - this.iframe.setAttribute('sandbox', 'allow-scripts'); + // Dev-only: Next's HMR/dev runtime refuses requests from the opaque + // ("null") origin a strict sandbox produces, so the iframe never + // hydrates and `sandbox-ready` is never posted. Add allow-same-origin + // in dev so the iframe shares the host's origin and HMR works. + // Production keeps the strict opaque-origin sandbox. + const sandboxFlags = process.env.NODE_ENV === 'development' + ? 'allow-scripts allow-same-origin' + : 'allow-scripts'; + this.iframe.setAttribute('sandbox', sandboxFlags); this.iframe.setAttribute('referrerpolicy', 'no-referrer'); this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`; this.iframe.style.border = 'none'; diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 18c41bbd..16894bb8 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -65,20 +65,44 @@ async function getBundleCode(plugin: InstalledPlugin): Promise { // ─── Load ───────────────────────────────────────────────────── +// Bound on how long the sandbox iframe may take to send back init-done. +// Without this a single misbehaving plugin can hang the whole load loop. +// 30s accommodates Next.js dev-mode per-iframe compile + SSR + hydrate on +// slower machines, while still catching truly stuck plugins. +const INIT_TIMEOUT_MS = 30_000; + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms}ms`)); + }, ms); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { if (typeof window === 'undefined') return; + let background: ReturnType | null = null; try { const code = await getBundleCode(plugin); - const background = createBackgroundInstance({ + background = createBackgroundInstance({ plugin, code, locale: currentLocale, }); // Wait for the background runtime to evaluate the bundle, register hooks, - // and enumerate slots. - const info = await background.initPromise; + // and enumerate slots. Bounded so a stuck iframe doesn't hang activation. + const bg = background; + const info = await withTimeout( + bg.initPromise, + INIT_TIMEOUT_MS, + `[plugin-sandbox] "${plugin.id}" init`, + ); // Wire hook proxies: every hookName the plugin registered gets a HookBus // entry whose handler dispatches into the sandbox. `shortcut:` hooks @@ -93,7 +117,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { try { - return await background.invokeHook(hookName, args); + return await bg.invokeHook(hookName, args); } catch (err) { pluginErrorTracker.record(plugin.id, err); throw err; @@ -103,13 +127,13 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise void; reject: (err: Error) => void }>(); const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); @@ -436,13 +439,13 @@ function handleHostMessage(ev: MessageEvent): void { // ─── React entry ───────────────────────────────────────────── export function SandboxRuntime(): React.JSX.Element { - const inited = useRef(false); useEffect(() => { - if (inited.current) return; - inited.current = true; window.addEventListener('message', handleHostMessage); // Initial ping. We don't know parent origin yet, so '*' is required. - if (window.parent && window.parent !== window) { + // Guard at module scope so React strict mode's double-invoke doesn't + // re-post (and so a re-post can't race with the parent's init reply). + if (!readyPosted && window.parent && window.parent !== window) { + readyPosted = true; window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*'); } return () => { diff --git a/proxy.ts b/proxy.ts index 99093399..814abbd5 100644 --- a/proxy.ts +++ b/proxy.ts @@ -119,6 +119,10 @@ export async function proxy(request: NextRequest) { const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/'); const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/'); const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/'); + // The plugin sandbox lives in its own root layout under app/(sandbox)/ and + // is not part of the localized tree. Letting next-intl rewrite the path to + // /en/plugin-sandbox 404s, which kills the iframe and disables every plugin. + const isSandboxRoute = isSandboxPath; // When localePrefix is 'always', paths that already have a locale prefix // (e.g. /en/settings) should not be re-processed by the intl middleware - @@ -129,7 +133,7 @@ export async function proxy(request: NextRequest) { ); let intlResponse: ReturnType | null = null; - if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !hasLocalePrefix) { + if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !isSandboxRoute && !hasLocalePrefix) { try { intlResponse = intlMiddleware(request); } catch (error) { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index b8f56d4d..2d2303db 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -262,11 +262,11 @@ export const usePluginStore = create()( // Sync server-managed plugins before loading await syncServerPlugins(get, set); - // Load all enabled plugins + // Load all enabled plugins in parallel. Sequential `await` made one + // hung/slow plugin block every subsequent one; loadSandboxedPlugin + // catches its own errors so allSettled is just for tidy completion. const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error'); - for (const plugin of enabledPlugins) { - await loadPlugin(plugin); - } + await Promise.allSettled(enabledPlugins.map(plugin => loadPlugin(plugin))); set({ initialized: true }); })(); @@ -474,6 +474,9 @@ async function syncServerPlugins( ), })); } else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) { + // When forceEnabled flips on, enable the plugin in the same pass so + // the user doesn't need a second refresh for it to run. + const shouldAutoEnable = sp.forceEnabled && !local.enabled; set(state => ({ plugins: state.plugins.map(p => p.id === sp.id @@ -482,6 +485,7 @@ async function syncServerPlugins( managed: true, forceEnabled: sp.forceEnabled, settingsSchema: sp.settingsSchema, + ...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}), } : p ),