sandbox: re-handshake on iframe reload + size to content (fixes invisible UI)

This commit is contained in:
Jean Chevronnet 2026-07-04 05:43:22 +00:00
parent 06c3d90da4
commit 1b253a9c0f
No known key found for this signature in database
4 changed files with 53 additions and 35 deletions

View file

@ -3,8 +3,9 @@
<meta name="color-scheme" content="light dark">
<title>Orbit sandboxed plugin</title>
<style>
html, body { margin: 0; background: transparent; color: inherit; font: inherit; }
body { font: 13px/1.3 system-ui, sans-serif; }
html, body { margin: 0; padding: 0; background: transparent; color: inherit; }
/* inline-block shrink-wraps the body to its content so we can size the iframe to it */
body { display: inline-block; font: 13px/1.3 system-ui, sans-serif; }
</style>
<body>
<script>
@ -71,9 +72,12 @@
ui: function (slot, build) {
try { build(document.body); } catch (e) { rpc('log', 'ui build threw: ' + e); }
rpc('ui.claim', slot);
var report = function () { rpc('ui.resize', document.documentElement.scrollHeight); };
var report = function () {
var r = document.body.getBoundingClientRect();
rpc('ui.resize', Math.ceil(r.width), Math.ceil(r.height));
};
report();
if (window.ResizeObserver) new ResizeObserver(report).observe(document.documentElement);
if (window.ResizeObserver) new ResizeObserver(report).observe(document.body);
},
};
}

View file

@ -15,8 +15,10 @@ Orbit.plugin('sandbox-demo', function (orbit, log) {
orbit.ui('footer_item', function (root) {
var btn = document.createElement('button');
btn.textContent = '🧪 wave';
btn.style.cssText = 'font:inherit;cursor:pointer;border:1px solid #8884;border-radius:8px;' +
'padding:.25rem .5rem;background:transparent;color:inherit';
// Explicit colours: the sandbox is isolated from the app's theme vars, so a
// self-contained plugin ships its own visible styling (works on light + dark).
btn.style.cssText = 'font:600 13px system-ui,sans-serif;cursor:pointer;border:0;border-radius:8px;' +
'padding:.3rem .6rem;background:#8957e5;color:#fff;white-space:nowrap';
btn.onclick = function () {
var n = (orbit.storage.get('waves', 0)) + 1;
orbit.storage.set('waves', n);

View file

@ -258,6 +258,8 @@ input, textarea { font-family: inherit; }
border-radius: 11px; font-size: 1.1rem; color: var(--muted); cursor: pointer; transition: background .12s, color .12s;
}
.appbar__act:hover { background: var(--bg); color: var(--ink); }
/* host mount point for a sandboxed plugin's iframe (sized to content by the host) */
.sbx-slot { display: inline-flex; align-items: center; }
/* away toggle: dim = present (click to step away), amber-lit = away */
.appbar__act--away { filter: grayscale(1); opacity: .55; }
.appbar__act--away:hover { filter: grayscale(.5); opacity: .85; }

View file

@ -65,10 +65,8 @@ export async function mountSandboxedPlugin(entry: Exclude<PluginEntry, string>):
iframe.src = SANDBOX_DOC;
iframe.setAttribute('sandbox', 'allow-scripts'); // opaque origin — the isolation boundary
iframe.title = name;
iframe.style.cssText = 'border:0;width:100%;height:0;display:block;background:transparent';
iframe.style.cssText = 'border:0;width:0;height:0;display:block;background:transparent;color-scheme:normal';
const chan = new MessageChannel();
const port = chan.port1;
const log = (...a: unknown[]) => { if (pluginDebug()) console.log(`%c[${name}]`, 'color:#8957e5', ...a); };
let claimed = false;
@ -88,38 +86,50 @@ export async function mountSandboxedPlugin(entry: Exclude<PluginEntry, string>):
const slot = String(a[0] ?? 'footer_item') as UiSlot;
removeUi = usePluginRegistry.getState().addUi(slot, name, () => createElement(SandboxFrame, { iframe }));
},
'ui.resize': (a) => { iframe.style.height = `${Math.max(0, Math.min(2000, Number(a[0]) || 0))}px`; },
'ui.resize': (a) => {
const w = Math.max(0, Math.min(4000, Number(a[0]) || 0));
const h = Math.max(0, Math.min(2000, Number(a[1]) || 0));
if (w) iframe.style.width = `${w}px`;
iframe.style.height = `${h}px`;
},
};
port.onmessage = (e: MessageEvent) => {
const m = e.data as GuestToHost;
if (!m || m.type !== 'rpc' || typeof m.method !== 'string') return;
if (!isGranted(permissions, m.method)) {
log('DENIED ungranted capability', m.method);
port.postMessage({ type: 'rpc:reply', id: m.id, error: `denied: ${m.method}` });
return;
}
let result: unknown, error: string | undefined;
try { result = impl[m.method]?.(Array.isArray(m.args) ? m.args : []); }
catch (err) { error = String(err); }
port.postMessage({ type: 'rpc:reply', id: m.id, result, error });
};
// Forward only the curated events; refresh the cached snapshot when it can change.
const offs = FORWARDED_EVENTS.map((ev) =>
bus.on(ev, (...args: unknown[]) => {
port.postMessage({ type: 'event', name: ev, args });
if (ev === 'connected' || ev === 'buffer.active') port.postMessage({ type: 'snapshot', snapshot: snapshot() });
}));
void offs; // plugins live for the session; disposers kept for a future unload path
iframe.addEventListener('load', () => {
// (Re)establish the bridge on EVERY iframe load. Adopting the iframe into a UI
// slot reparents it, which reloads the document and neuters the transferred
// port — so we hand the fresh guest a new MessageChannel and re-send init each
// time (a plugin's on-load code must therefore be idempotent).
let teardown = () => {};
const handshake = () => {
teardown();
const chan = new MessageChannel();
const port = chan.port1;
port.onmessage = (e: MessageEvent) => {
const m = e.data as GuestToHost;
if (!m || m.type !== 'rpc' || typeof m.method !== 'string') return;
if (!isGranted(permissions, m.method)) {
log('DENIED ungranted capability', m.method);
port.postMessage({ type: 'rpc:reply', id: m.id, error: `denied: ${m.method}` });
return;
}
let result: unknown, error: string | undefined;
try { result = impl[m.method]?.(Array.isArray(m.args) ? m.args : []); }
catch (err) { error = String(err); }
port.postMessage({ type: 'rpc:reply', id: m.id, result, error });
};
const offs = FORWARDED_EVENTS.map((ev) =>
bus.on(ev, (...args: unknown[]) => {
port.postMessage({ type: 'event', name: ev, args });
if (ev === 'connected' || ev === 'buffer.active') port.postMessage({ type: 'snapshot', snapshot: snapshot() });
}));
teardown = () => { offs.forEach((o) => o()); port.close(); };
iframe.contentWindow?.postMessage(
{ type: 'init', name, permissions, source, snapshot: snapshot(), storage: loadStorage(name) },
'*', [chan.port2],
);
log('mounted');
});
log('handshake');
};
iframe.addEventListener('load', handshake);
holder().appendChild(iframe);
void removeUi; // referenced by the (future) unload path
}