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
),