sandbox: move the guest bootstrap into typed core source
The guest (bridge + SDK) was hand-written vanilla JS in public/plugin-sandbox.html — untyped, unlinted, and hand-duplicating protocol constants (drift risk that grew with the API). Move it to src/modules/sandbox/guest/ as TypeScript that imports the shared types from protocol.ts (rpc() is now typed against RpcMethod, so a method not wired on the host won't compile). A small Vite plugin transpiles it into plugin-sandbox.html: served on the fly in dev, emitted into the build otherwise — kept unminified so the served security-boundary code stays auditable, and inline so the response keeps its own header CSP (an external script can't use 'self' in an opaque origin). Behavior-neutral: verified clock/radio/dice mount, radio opens/plays identically, trigger centered.
This commit is contained in:
parent
f32282d1fe
commit
6821fe3617
5 changed files with 348 additions and 298 deletions
|
|
@ -46,7 +46,9 @@ UI inside its own iframe) needs no grant because it is already contained.
|
|||
Ungranted or unknown calls are refused host-side (fail-closed). The gate is unit
|
||||
tested in `src/modules/sandbox/protocol.test.ts`.
|
||||
|
||||
The sandboxed API (`src/modules/sandbox/host.ts` + `public/plugin-sandbox.html`):
|
||||
The guest is typed core source in `src/modules/sandbox/guest/` (the SDK lives here
|
||||
too); a Vite plugin transpiles it into `plugin-sandbox.html` at build (served on the
|
||||
fly in dev). The sandboxed API (`src/modules/sandbox/host.ts` + the guest):
|
||||
`orbit.log`, `orbit.on(event, fn)` (forwarded `connected` / `message` /
|
||||
`buffer.active` / `status`), `orbit.irc.*`, `orbit.notify`,
|
||||
`orbit.state.active/nick/account/buffers` (from a pushed snapshot, synchronous),
|
||||
|
|
|
|||
|
|
@ -1,296 +0,0 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Orbit sandboxed plugin</title>
|
||||
<style>
|
||||
/* Transparent iframe so a plugin sits on the app background; color-scheme goes on
|
||||
<body>, not the root (root scheme paints an opaque UA canvas = grey box).
|
||||
Hide scrollbars (not overflow:hidden — that paints an opaque backdrop and kills
|
||||
the transparency) so a mid-resize frame never flashes a scrollbar. */
|
||||
html { background: transparent; color-scheme: normal; scrollbar-width: none; }
|
||||
body { margin: 0; padding: 0; background: transparent; color: var(--ink, inherit); display: inline-block; font: 13px/1.4 system-ui, sans-serif; }
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
</style>
|
||||
<body>
|
||||
<script>
|
||||
/*
|
||||
* Guest bootstrap for a sandboxed Orbit plugin.
|
||||
*
|
||||
* This document runs in an OPAQUE origin (the embedding <iframe> has
|
||||
* sandbox="allow-scripts" and no allow-same-origin), so it has no access to the
|
||||
* app's DOM, cookies, localStorage or store. Its only link to the app is the
|
||||
* MessagePort handed over in the `init` message; every privileged action is an
|
||||
* RPC the host validates against the plugin's granted permissions.
|
||||
*
|
||||
* Kept as a static, dependency-free file (not part of the TS bundle) so it can be
|
||||
* served with its own strict CSP.
|
||||
*/
|
||||
(function () {
|
||||
var port, snap = { active: '', nick: '', account: '', buffers: [], network: '', isupport: {}, caps: [] }, store = {};
|
||||
var listeners = {}; // event name -> [fn]
|
||||
var hookN = 0; // unique id source for command/shortcut hooks
|
||||
var pending = new Map(), rpcId = 0; // in-flight RPCs
|
||||
|
||||
// Is this background colour a dark one? Rough luminance on a #rgb/#rrggbb or
|
||||
// rgb(...) string; anything unparseable counts as light.
|
||||
function isDark(c) {
|
||||
if (!c) return false;
|
||||
var m = String(c).trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i), r, g, b;
|
||||
if (m) {
|
||||
var h = m[1]; if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
|
||||
} else {
|
||||
var n = String(c).match(/(\d+)\D+(\d+)\D+(\d+)/); if (!n) return false;
|
||||
r = +n[1]; g = +n[2]; b = +n[3];
|
||||
}
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
|
||||
}
|
||||
|
||||
// Mirror the app theme vars onto our root; color-scheme on <body> (not root) so
|
||||
// native controls match without the root painting an opaque canvas.
|
||||
function applyTheme(vars) {
|
||||
if (!vars) return;
|
||||
for (var k in vars) { try { document.documentElement.style.setProperty(k, vars[k]); } catch (e) {} }
|
||||
if (vars['--bg']) document.body.style.colorScheme = isDark(vars['--bg']) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function rpc(method) {
|
||||
var args = Array.prototype.slice.call(arguments, 1);
|
||||
return new Promise(function (resolve, reject) {
|
||||
var id = ++rpcId;
|
||||
pending.set(id, { resolve: resolve, reject: reject });
|
||||
port.postMessage({ type: 'rpc', id: id, method: method, args: args });
|
||||
});
|
||||
}
|
||||
function emit(name) {
|
||||
var a = Array.prototype.slice.call(arguments, 1);
|
||||
(listeners[name] || []).forEach(function (fn) { try { fn.apply(null, a); } catch (e) { /* plugin threw */ } });
|
||||
}
|
||||
|
||||
// The constrained API a sandboxed plugin sees. Same shape spirit as the in-page
|
||||
// Orbit, but privileged bits go through the host and reads come from the snapshot.
|
||||
function makeApi(name) {
|
||||
// Low-level: fill this iframe's DOM, claim a UI slot, and keep the host-owned
|
||||
// frame sized to the content. Everything else (panel/el) is built on top.
|
||||
function ui(slot, build) {
|
||||
var outer = document.createElement('div');
|
||||
outer.style.cssText = 'display:inline-block';
|
||||
var inner = document.createElement('div');
|
||||
outer.appendChild(inner);
|
||||
document.body.appendChild(outer);
|
||||
try { build(inner); } catch (e) { rpc('log', 'ui build threw: ' + e); }
|
||||
rpc('ui.claim', slot);
|
||||
var report = function () {
|
||||
var r = outer.getBoundingClientRect();
|
||||
rpc('ui.resize', Math.ceil(r.width), Math.ceil(r.height));
|
||||
};
|
||||
report();
|
||||
if (window.ResizeObserver) new ResizeObserver(report).observe(outer);
|
||||
}
|
||||
|
||||
// ---- SDK ---------------------------------------------------------------
|
||||
// Themed atoms + a bar panel that hides every rough edge of the sandbox:
|
||||
// theming, the async frame-resize lag, upward growth and no half-drawn flash.
|
||||
var sdkStyled = false;
|
||||
function sdkCss() {
|
||||
if (sdkStyled) return; sdkStyled = true;
|
||||
var s = document.createElement('style');
|
||||
s.textContent =
|
||||
'@keyframes obx-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}' +
|
||||
'.obx-trig{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;min-width:52px;padding:6px 4px;cursor:pointer;border:0;border-radius:12px;background:transparent;color:var(--muted,#999);font:inherit;transition:color .12s,background .12s}' +
|
||||
'.obx-trig:hover{color:var(--ink,inherit);background:var(--bg,transparent)}' +
|
||||
'.obx-trig.on{color:var(--accent,#3a7)}' +
|
||||
'.obx-trig i{display:inline-flex;align-items:center;justify-content:center;font-size:1.2rem;line-height:1;font-style:normal}' +
|
||||
'.obx-trig b{font-size:.68rem;font-weight:600;letter-spacing:.01em;line-height:1;white-space:nowrap}' +
|
||||
'.obx-card{box-sizing:border-box;padding:12px;border-radius:16px;background:var(--bg,#0d0d0f);border:1px solid var(--border,#8884);box-shadow:0 20px 50px -12px rgba(0,0,0,.6),0 3px 10px -3px rgba(0,0,0,.4);animation:obx-in .18s ease both}' +
|
||||
'.obx-hd{display:flex;align-items:center;gap:.4rem;margin-bottom:10px;font-weight:800;font-size:13px}' +
|
||||
'.obx-hd b{flex:1}' +
|
||||
'.obx-x{border:0;background:transparent;color:var(--muted,#999);cursor:pointer;font-size:14px;line-height:1;border-radius:8px;width:24px;height:24px}' +
|
||||
'.obx-x:hover{color:var(--ink,inherit)}' +
|
||||
'.obx-btn{border:0;border-radius:10px;background:var(--accent,#3a7);color:var(--bg,#fff);font:inherit;font-weight:700;padding:8px 12px;cursor:pointer}' +
|
||||
'.obx-row{display:flex;flex-direction:column;gap:1px;padding:.4rem .5rem;border:0;border-radius:10px;background:transparent;color:var(--ink,inherit);font:inherit;text-align:left;cursor:pointer;width:100%;transition:background .12s}' +
|
||||
'.obx-row:hover{background:rgba(128,128,128,.13)}' +
|
||||
'.obx-row.on{box-shadow:inset 3px 0 0 var(--accent,#3a7)}' +
|
||||
'.obx-row span{font-weight:700;font-size:12.5px}.obx-row small{font-size:10.5px;color:var(--muted,#888)}' +
|
||||
'input[type=range].obx-range{-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;width:100%;cursor:pointer;background:var(--border,#8886);accent-color:var(--accent,#3a7)}';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
function setIcon(elm, icon) {
|
||||
if (icon == null) return;
|
||||
if (icon.nodeType) elm.appendChild(icon);
|
||||
else if (typeof icon === 'string' && icon.charAt(0) === '<') elm.innerHTML = icon;
|
||||
else elm.textContent = String(icon);
|
||||
}
|
||||
var el = {
|
||||
button: function (label, onClick) {
|
||||
var b = document.createElement('button'); b.type = 'button'; b.className = 'obx-btn'; b.textContent = label;
|
||||
if (onClick) b.onclick = onClick; return b;
|
||||
},
|
||||
row: function (title, sub, onClick) {
|
||||
var b = document.createElement('button'); b.type = 'button'; b.className = 'obx-row';
|
||||
var t = document.createElement('span'); t.textContent = title; b.appendChild(t);
|
||||
if (sub) { var s = document.createElement('small'); s.textContent = sub; b.appendChild(s); }
|
||||
if (onClick) b.onclick = onClick; return b;
|
||||
},
|
||||
slider: function (o) {
|
||||
o = o || {}; var r = document.createElement('input'); r.type = 'range'; r.className = 'obx-range';
|
||||
r.min = o.min != null ? o.min : 0; r.max = o.max != null ? o.max : 1; r.step = o.step != null ? o.step : 0.01;
|
||||
r.value = o.value != null ? o.value : 0.5;
|
||||
if (o.onInput) r.oninput = function () { o.onInput(Number(r.value), r); };
|
||||
return r;
|
||||
},
|
||||
};
|
||||
|
||||
// A bar trigger that opens a floating themed panel above it. Owns the whole
|
||||
// frame dance: on open the panel is measured but kept hidden until the frame has
|
||||
// grown to fit it, then revealed — so it never flashes half-drawn by the composer,
|
||||
// never grows the wrong way, and always matches the theme. Returns a controller.
|
||||
function panel(opts) {
|
||||
opts = opts || {}; sdkCss();
|
||||
var width = opts.width || 280;
|
||||
var open = false, hot = false, pending = null, card, body, trig, ctrl;
|
||||
function clearPending() { if (pending) { window.removeEventListener('resize', pending); pending = null; } }
|
||||
function paint() { if (trig) trig.className = 'obx-trig' + ((open || hot) ? ' on' : ''); }
|
||||
function setOpen(v) {
|
||||
open = v; clearPending(); paint();
|
||||
if (v) {
|
||||
card.style.visibility = 'hidden'; card.style.display = 'block';
|
||||
pending = function () {
|
||||
if (window.innerHeight < card.offsetHeight) return;
|
||||
clearPending();
|
||||
card.style.visibility = 'visible';
|
||||
card.style.animation = 'none'; void card.offsetWidth; card.style.animation = 'obx-in .18s ease both';
|
||||
};
|
||||
window.addEventListener('resize', pending);
|
||||
requestAnimationFrame(pending);
|
||||
} else { card.style.display = 'none'; card.style.visibility = ''; }
|
||||
if (opts.onToggle) try { opts.onToggle(v); } catch (e) {}
|
||||
}
|
||||
ctrl = {
|
||||
open: function () { setOpen(true); }, close: function () { setOpen(false); },
|
||||
toggle: function () { setOpen(!open); }, isOpen: function () { return open; },
|
||||
active: function (on) { hot = !!on; paint(); }, body: function () { return body; },
|
||||
};
|
||||
ui(opts.slot || 'nav_item', function (root) {
|
||||
// Centre the trigger over the slot: without this the fit-content trigger
|
||||
// left-aligns under the wider card when open and slides onto the next tab.
|
||||
root.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px';
|
||||
card = document.createElement('div'); card.className = 'obx-card';
|
||||
card.style.width = width + 'px'; card.style.display = 'none';
|
||||
if (opts.title != null) {
|
||||
var hd = document.createElement('div'); hd.className = 'obx-hd';
|
||||
var tb = document.createElement('b'); tb.textContent = opts.title;
|
||||
var x = document.createElement('button'); x.className = 'obx-x'; x.type = 'button'; x.textContent = '✕'; x.title = 'Close';
|
||||
x.onclick = function () { setOpen(false); };
|
||||
hd.appendChild(tb); hd.appendChild(x); card.appendChild(hd);
|
||||
}
|
||||
body = document.createElement('div'); card.appendChild(body);
|
||||
trig = document.createElement('button'); trig.type = 'button'; trig.className = 'obx-trig'; trig.title = opts.label || '';
|
||||
var i = document.createElement('i'); setIcon(i, opts.icon); trig.appendChild(i);
|
||||
if (opts.label) { var lb = document.createElement('b'); lb.textContent = opts.label; trig.appendChild(lb); }
|
||||
trig.onclick = function () { setOpen(!open); };
|
||||
root.appendChild(card); root.appendChild(trig);
|
||||
if (opts.render) try { opts.render(body, ctrl); } catch (e) { rpc('log', 'panel render threw: ' + e); }
|
||||
paint();
|
||||
});
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
sandboxed: true,
|
||||
log: function () { rpc.apply(null, ['log'].concat(Array.prototype.slice.call(arguments))); },
|
||||
on: function (ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); return function () {
|
||||
listeners[ev] = (listeners[ev] || []).filter(function (f) { return f !== fn; }); }; },
|
||||
irc: {
|
||||
say: function (t) { return rpc('irc.say', t); },
|
||||
msg: function (to, t) { return rpc('irc.msg', to, t); },
|
||||
send: function (l) { return rpc('irc.send', l); },
|
||||
join: function (c) { return rpc('irc.join', c); },
|
||||
part: function (c) { return rpc('irc.part', c); },
|
||||
list: function () { return rpc('irc.list'); },
|
||||
},
|
||||
notify: function (title, body) { return rpc('notify', title, body); },
|
||||
// Register a slash command: typing "/name a b" calls run(["a","b"], "a b").
|
||||
// Returns an unregister fn. Built-in commands win over a plugin's.
|
||||
command: function (nameStr, run, help) {
|
||||
var id = 'cmd:' + (++hookN);
|
||||
(listeners[id] = listeners[id] || []).push(run);
|
||||
rpc('command.register', String(nameStr), id, help != null ? String(help) : undefined);
|
||||
return function () { delete listeners[id]; rpc('command.dispose', id); };
|
||||
},
|
||||
// Register a keyboard shortcut, e.g. "mod+j" or "alt+shift+r" — a modifier is
|
||||
// REQUIRED (bare keys are refused). run() fires when it's pressed in the chat view.
|
||||
shortcut: function (combo, run) {
|
||||
var id = 'key:' + (++hookN);
|
||||
(listeners[id] = listeners[id] || []).push(run);
|
||||
rpc('shortcut.register', String(combo), id);
|
||||
return function () { delete listeners[id]; rpc('shortcut.dispose', id); };
|
||||
},
|
||||
state: {
|
||||
active: function () { return snap.active; },
|
||||
nick: function () { return snap.nick; },
|
||||
account: function () { return snap.account; },
|
||||
buffers: function () { return snap.buffers.slice(); },
|
||||
},
|
||||
// Read-only server / capability info, from the snapshot (no RPC, no grant).
|
||||
server: {
|
||||
network: function () { return snap.network || ''; },
|
||||
isupport: function () { var o = {}, m = snap.isupport || {}, k; for (k in m) o[k] = m[k]; return o; },
|
||||
hasCap: function (cap) { return (snap.caps || []).some(function (c) { return c.name === cap && c.enabled; }); },
|
||||
caps: function () { return (snap.caps || []).map(function (c) { return { name: c.name, available: c.available, enabled: c.enabled }; }); },
|
||||
},
|
||||
storage: {
|
||||
get: function (k, fallback) { return k in store ? store[k] : fallback; },
|
||||
set: function (k, v) { store[k] = v; return rpc('storage.set', k, v); },
|
||||
},
|
||||
ui: ui, // low-level slot
|
||||
panel: panel, // high-level bar panel (handles theming + the resize dance)
|
||||
el: el, // themed atoms: button / row / slider
|
||||
};
|
||||
}
|
||||
|
||||
// The global the plugin source registers against: Orbit.plugin('name', fn).
|
||||
var registered = false;
|
||||
window.Orbit = {
|
||||
sandboxed: true,
|
||||
plugin: function (name, fn) {
|
||||
if (registered) return; registered = true;
|
||||
var api = makeApi(name);
|
||||
try { fn(api, api.log); } catch (e) { rpc('log', 'plugin threw during init: ' + e); }
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener('message', function (e) {
|
||||
var m = e.data;
|
||||
if (!m || !m.type) return;
|
||||
if (m.type === 'init' && e.ports && e.ports[0]) {
|
||||
// The init message carries the plugin source that gets eval'd below, so
|
||||
// accept it only from the host frame, and only once (no port swap).
|
||||
if (e.source !== window.parent || port) return;
|
||||
port = e.ports[0];
|
||||
snap = m.snapshot || snap;
|
||||
store = m.storage || {};
|
||||
applyTheme(m.theme);
|
||||
port.onmessage = function (ev) {
|
||||
var d = ev.data;
|
||||
if (!d) return;
|
||||
if (d.type === 'rpc:reply') {
|
||||
var p = pending.get(d.id); if (!p) return; pending.delete(d.id);
|
||||
d.error ? p.reject(new Error(d.error)) : p.resolve(d.result);
|
||||
} else if (d.type === 'event') {
|
||||
emit.apply(null, [d.name].concat(d.args || []));
|
||||
} else if (d.type === 'snapshot') {
|
||||
snap = d.snapshot || snap;
|
||||
} else if (d.type === 'theme') {
|
||||
applyTheme(d.theme);
|
||||
}
|
||||
};
|
||||
// Run the plugin now that the bridge is live. new Function keeps it out of
|
||||
// this bootstrap's scope; it only reaches the app through window.Orbit.
|
||||
try { new Function(m.source).call(window); }
|
||||
catch (err) { rpc('log', 'plugin source failed: ' + err); }
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
297
src/modules/sandbox/guest/index.ts
Normal file
297
src/modules/sandbox/guest/index.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// Guest bootstrap for a sandboxed Orbit plugin — the code that runs INSIDE the
|
||||
// opaque-origin iframe. Counterpart to ../host.ts. It has no access to the app's
|
||||
// DOM, cookies, localStorage or store; its only link is the MessagePort handed over
|
||||
// in the `init` message, and every privileged action is an RPC the host validates
|
||||
// against the plugin's permissions.
|
||||
//
|
||||
// This is core, typed source. A Vite plugin transpiles it to a self-contained
|
||||
// script inlined into plugin-sandbox.html (it must be standalone — the opaque origin
|
||||
// can't load app chunks), so it imports ONLY types from the shared protocol.
|
||||
import type { RpcMethod, StateSnapshot, HostToGuest } from '../protocol';
|
||||
|
||||
(function () {
|
||||
let port: MessagePort;
|
||||
let snap: StateSnapshot = { active: '', nick: '', account: '', buffers: [], network: '', isupport: {}, caps: [] };
|
||||
let store: Record<string, unknown> = {};
|
||||
const listeners: Record<string, ((...a: unknown[]) => void)[]> = {}; // event name -> [fn]
|
||||
let hookN = 0; // unique id source for command/shortcut hooks
|
||||
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||
let rpcId = 0;
|
||||
|
||||
// Is this background colour a dark one? Rough luminance on a #rgb/#rrggbb or
|
||||
// rgb(...) string; anything unparseable counts as light.
|
||||
function isDark(c: string): boolean {
|
||||
if (!c) return false;
|
||||
const m = String(c).trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
let r: number, g: number, b: number;
|
||||
if (m) {
|
||||
let h = m[1]; if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
|
||||
} else {
|
||||
const n = String(c).match(/(\d+)\D+(\d+)\D+(\d+)/); if (!n) return false;
|
||||
r = +n[1]; g = +n[2]; b = +n[3];
|
||||
}
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
|
||||
}
|
||||
|
||||
// Mirror the app theme vars onto our root; color-scheme on <body> (not root) so
|
||||
// native controls match without the root painting an opaque canvas.
|
||||
function applyTheme(vars: Record<string, string> | undefined): void {
|
||||
if (!vars) return;
|
||||
for (const k in vars) { try { document.documentElement.style.setProperty(k, vars[k]); } catch { /* bad var */ } }
|
||||
if (vars['--bg']) document.body.style.colorScheme = isDark(vars['--bg']) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function rpc(method: RpcMethod, ...args: unknown[]): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++rpcId;
|
||||
pending.set(id, { resolve, reject });
|
||||
port.postMessage({ type: 'rpc', id, method, args });
|
||||
});
|
||||
}
|
||||
function emit(name: string, ...a: unknown[]): void {
|
||||
(listeners[name] || []).forEach((fn) => { try { fn(...a); } catch { /* plugin threw */ } });
|
||||
}
|
||||
|
||||
// The constrained API a sandboxed plugin sees. Same shape spirit as the in-page
|
||||
// Orbit, but privileged bits go through the host and reads come from the snapshot.
|
||||
function makeApi(name: string) {
|
||||
// Low-level: fill this iframe's DOM, claim a UI slot, and keep the host-owned
|
||||
// frame sized to the content. Everything else (panel/el) is built on top.
|
||||
function ui(slot: string, build: (root: HTMLElement) => void): void {
|
||||
const outer = document.createElement('div');
|
||||
outer.style.cssText = 'display:inline-block';
|
||||
const inner = document.createElement('div');
|
||||
outer.appendChild(inner);
|
||||
document.body.appendChild(outer);
|
||||
try { build(inner); } catch (e) { rpc('log', 'ui build threw: ' + e); }
|
||||
rpc('ui.claim', slot);
|
||||
const report = () => {
|
||||
const r = outer.getBoundingClientRect();
|
||||
rpc('ui.resize', Math.ceil(r.width), Math.ceil(r.height));
|
||||
};
|
||||
report();
|
||||
if (window.ResizeObserver) new ResizeObserver(report).observe(outer);
|
||||
}
|
||||
|
||||
// ---- SDK ---------------------------------------------------------------
|
||||
// Themed atoms + a bar panel that hides every rough edge of the sandbox:
|
||||
// theming, the async frame-resize lag, upward growth and no half-drawn flash.
|
||||
let sdkStyled = false;
|
||||
function sdkCss(): void {
|
||||
if (sdkStyled) return; sdkStyled = true;
|
||||
const s = document.createElement('style');
|
||||
s.textContent =
|
||||
'@keyframes obx-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}' +
|
||||
'.obx-trig{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;min-width:52px;padding:6px 4px;cursor:pointer;border:0;border-radius:12px;background:transparent;color:var(--muted,#999);font:inherit;transition:color .12s,background .12s}' +
|
||||
'.obx-trig:hover{color:var(--ink,inherit);background:var(--bg,transparent)}' +
|
||||
'.obx-trig.on{color:var(--accent,#3a7)}' +
|
||||
'.obx-trig i{display:inline-flex;align-items:center;justify-content:center;font-size:1.2rem;line-height:1;font-style:normal}' +
|
||||
'.obx-trig b{font-size:.68rem;font-weight:600;letter-spacing:.01em;line-height:1;white-space:nowrap}' +
|
||||
'.obx-card{box-sizing:border-box;padding:12px;border-radius:16px;background:var(--bg,#0d0d0f);border:1px solid var(--border,#8884);box-shadow:0 20px 50px -12px rgba(0,0,0,.6),0 3px 10px -3px rgba(0,0,0,.4);animation:obx-in .18s ease both}' +
|
||||
'.obx-hd{display:flex;align-items:center;gap:.4rem;margin-bottom:10px;font-weight:800;font-size:13px}' +
|
||||
'.obx-hd b{flex:1}' +
|
||||
'.obx-x{border:0;background:transparent;color:var(--muted,#999);cursor:pointer;font-size:14px;line-height:1;border-radius:8px;width:24px;height:24px}' +
|
||||
'.obx-x:hover{color:var(--ink,inherit)}' +
|
||||
'.obx-btn{border:0;border-radius:10px;background:var(--accent,#3a7);color:var(--bg,#fff);font:inherit;font-weight:700;padding:8px 12px;cursor:pointer}' +
|
||||
'.obx-row{display:flex;flex-direction:column;gap:1px;padding:.4rem .5rem;border:0;border-radius:10px;background:transparent;color:var(--ink,inherit);font:inherit;text-align:left;cursor:pointer;width:100%;transition:background .12s}' +
|
||||
'.obx-row:hover{background:rgba(128,128,128,.13)}' +
|
||||
'.obx-row.on{box-shadow:inset 3px 0 0 var(--accent,#3a7)}' +
|
||||
'.obx-row span{font-weight:700;font-size:12.5px}.obx-row small{font-size:10.5px;color:var(--muted,#888)}' +
|
||||
'input[type=range].obx-range{-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;width:100%;cursor:pointer;background:var(--border,#8886);accent-color:var(--accent,#3a7)}';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
function setIcon(elm: HTMLElement, icon: unknown): void {
|
||||
if (icon == null) return;
|
||||
if ((icon as Node).nodeType) elm.appendChild(icon as Node);
|
||||
else if (typeof icon === 'string' && icon.charAt(0) === '<') elm.innerHTML = icon;
|
||||
else elm.textContent = String(icon);
|
||||
}
|
||||
const el = {
|
||||
button(label: string, onClick?: () => void): HTMLButtonElement {
|
||||
const b = document.createElement('button'); b.type = 'button'; b.className = 'obx-btn'; b.textContent = label;
|
||||
if (onClick) b.onclick = onClick; return b;
|
||||
},
|
||||
row(title: string, sub?: string, onClick?: () => void): HTMLButtonElement {
|
||||
const b = document.createElement('button'); b.type = 'button'; b.className = 'obx-row';
|
||||
const t = document.createElement('span'); t.textContent = title; b.appendChild(t);
|
||||
if (sub) { const s = document.createElement('small'); s.textContent = sub; b.appendChild(s); }
|
||||
if (onClick) b.onclick = onClick; return b;
|
||||
},
|
||||
slider(o?: { min?: number; max?: number; step?: number; value?: number; onInput?: (v: number, el: HTMLInputElement) => void }): HTMLInputElement {
|
||||
o = o || {}; const r = document.createElement('input'); r.type = 'range'; r.className = 'obx-range';
|
||||
r.min = String(o.min != null ? o.min : 0); r.max = String(o.max != null ? o.max : 1); r.step = String(o.step != null ? o.step : 0.01);
|
||||
r.value = String(o.value != null ? o.value : 0.5);
|
||||
const onInput = o.onInput; if (onInput) r.oninput = () => onInput(Number(r.value), r);
|
||||
return r;
|
||||
},
|
||||
};
|
||||
|
||||
// A bar trigger that opens a floating themed panel above it. Owns the whole frame
|
||||
// dance: on open the panel is measured but kept hidden until the frame has grown to
|
||||
// fit it, then revealed — so it never flashes half-drawn by the composer, never grows
|
||||
// the wrong way, and always matches the theme. Returns a controller.
|
||||
function panel(opts: {
|
||||
slot?: string; width?: number; title?: string; label?: string; icon?: unknown;
|
||||
render?: (body: HTMLElement, ctrl: PanelCtrl) => void; onToggle?: (open: boolean) => void;
|
||||
}): PanelCtrl {
|
||||
opts = opts || {}; sdkCss();
|
||||
const width = opts.width || 280;
|
||||
let open = false, hot = false;
|
||||
let pendingGrow: (() => void) | null = null;
|
||||
let card: HTMLElement, body: HTMLElement, trig: HTMLElement;
|
||||
function clearPending() { if (pendingGrow) { window.removeEventListener('resize', pendingGrow); pendingGrow = null; } }
|
||||
function paint() { if (trig) trig.className = 'obx-trig' + ((open || hot) ? ' on' : ''); }
|
||||
function setOpen(v: boolean) {
|
||||
open = v; clearPending(); paint();
|
||||
if (v) {
|
||||
card.style.visibility = 'hidden'; card.style.display = 'block';
|
||||
pendingGrow = () => {
|
||||
if (window.innerHeight < card.offsetHeight) return;
|
||||
clearPending();
|
||||
card.style.visibility = 'visible';
|
||||
card.style.animation = 'none'; void card.offsetWidth; card.style.animation = 'obx-in .18s ease both';
|
||||
};
|
||||
window.addEventListener('resize', pendingGrow);
|
||||
requestAnimationFrame(pendingGrow);
|
||||
} else { card.style.display = 'none'; card.style.visibility = ''; }
|
||||
if (opts.onToggle) try { opts.onToggle(v); } catch { /* plugin threw */ }
|
||||
}
|
||||
const ctrl: PanelCtrl = {
|
||||
open: () => setOpen(true), close: () => setOpen(false),
|
||||
toggle: () => setOpen(!open), isOpen: () => open,
|
||||
active: (on: boolean) => { hot = !!on; paint(); }, body: () => body,
|
||||
};
|
||||
ui(opts.slot || 'nav_item', (root) => {
|
||||
// Centre the trigger over the slot: without this the fit-content trigger
|
||||
// left-aligns under the wider card when open and slides onto the next tab.
|
||||
root.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px';
|
||||
card = document.createElement('div'); card.className = 'obx-card';
|
||||
card.style.width = width + 'px'; card.style.display = 'none';
|
||||
if (opts.title != null) {
|
||||
const hd = document.createElement('div'); hd.className = 'obx-hd';
|
||||
const tb = document.createElement('b'); tb.textContent = opts.title;
|
||||
const x = document.createElement('button'); x.className = 'obx-x'; x.type = 'button'; x.textContent = '✕'; x.title = 'Close';
|
||||
x.onclick = () => setOpen(false);
|
||||
hd.appendChild(tb); hd.appendChild(x); card.appendChild(hd);
|
||||
}
|
||||
body = document.createElement('div'); card.appendChild(body);
|
||||
trig = document.createElement('button'); (trig as HTMLButtonElement).type = 'button'; trig.className = 'obx-trig'; trig.title = opts.label || '';
|
||||
const i = document.createElement('i'); setIcon(i, opts.icon); trig.appendChild(i);
|
||||
if (opts.label) { const lb = document.createElement('b'); lb.textContent = opts.label; trig.appendChild(lb); }
|
||||
trig.onclick = () => setOpen(!open);
|
||||
root.appendChild(card); root.appendChild(trig);
|
||||
if (opts.render) try { opts.render(body, ctrl); } catch (e) { rpc('log', 'panel render threw: ' + e); }
|
||||
paint();
|
||||
});
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
sandboxed: true,
|
||||
log: (...args: unknown[]) => rpc('log', ...args),
|
||||
on(ev: string, fn: (...a: unknown[]) => void) {
|
||||
(listeners[ev] = listeners[ev] || []).push(fn);
|
||||
return () => { listeners[ev] = (listeners[ev] || []).filter((f) => f !== fn); };
|
||||
},
|
||||
irc: {
|
||||
say: (t: string) => rpc('irc.say', t),
|
||||
msg: (to: string, t: string) => rpc('irc.msg', to, t),
|
||||
send: (l: string) => rpc('irc.send', l),
|
||||
join: (c: string) => rpc('irc.join', c),
|
||||
part: (c: string) => rpc('irc.part', c),
|
||||
list: () => rpc('irc.list'),
|
||||
},
|
||||
notify: (title: string, body: string) => rpc('notify', title, body),
|
||||
// Register a slash command: typing "/name a b" calls run(["a","b"], "a b").
|
||||
// Returns an unregister fn. Built-in commands win over a plugin's.
|
||||
command(nameStr: string, run: (...a: unknown[]) => void, help?: string) {
|
||||
const id = 'cmd:' + (++hookN);
|
||||
(listeners[id] = listeners[id] || []).push(run);
|
||||
rpc('command.register', String(nameStr), id, help != null ? String(help) : undefined);
|
||||
return () => { delete listeners[id]; rpc('command.dispose', id); };
|
||||
},
|
||||
// Register a keyboard shortcut, e.g. "mod+j" or "alt+shift+r" — a modifier is
|
||||
// REQUIRED (bare keys are refused). run() fires when it's pressed in the chat view.
|
||||
shortcut(combo: string, run: (...a: unknown[]) => void) {
|
||||
const id = 'key:' + (++hookN);
|
||||
(listeners[id] = listeners[id] || []).push(run);
|
||||
rpc('shortcut.register', String(combo), id);
|
||||
return () => { delete listeners[id]; rpc('shortcut.dispose', id); };
|
||||
},
|
||||
state: {
|
||||
active: () => snap.active,
|
||||
nick: () => snap.nick,
|
||||
account: () => snap.account,
|
||||
buffers: () => snap.buffers.slice(),
|
||||
},
|
||||
// Read-only server / capability info, from the snapshot (no RPC, no grant).
|
||||
server: {
|
||||
network: () => snap.network || '',
|
||||
isupport: () => ({ ...snap.isupport }),
|
||||
hasCap: (cap: string) => (snap.caps || []).some((c) => c.name === cap && c.enabled),
|
||||
caps: () => (snap.caps || []).map((c) => ({ name: c.name, available: c.available, enabled: c.enabled })),
|
||||
},
|
||||
storage: {
|
||||
get: (k: string, fallback?: unknown) => (k in store ? store[k] : fallback),
|
||||
set: (k: string, v: unknown) => { store[k] = v; return rpc('storage.set', k, v); },
|
||||
},
|
||||
ui, // low-level slot
|
||||
panel, // high-level bar panel (handles theming + the resize dance)
|
||||
el, // themed atoms: button / row / slider
|
||||
};
|
||||
}
|
||||
|
||||
// The global the plugin source registers against: Orbit.plugin('name', fn).
|
||||
let registered = false;
|
||||
(window as unknown as { Orbit: OrbitGlobal }).Orbit = {
|
||||
sandboxed: true,
|
||||
plugin(name: string, fn: (orbit: ReturnType<typeof makeApi>, log: (...a: unknown[]) => void) => void) {
|
||||
if (registered) return; registered = true;
|
||||
const api = makeApi(name);
|
||||
try { fn(api, api.log); } catch (e) { rpc('log', 'plugin threw during init: ' + e); }
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener('message', (e: MessageEvent) => {
|
||||
const m = e.data;
|
||||
if (!m || !m.type) return;
|
||||
if (m.type === 'init' && e.ports && e.ports[0]) {
|
||||
// The init message carries the plugin source that gets eval'd below, so accept
|
||||
// it only from the host frame, and only once (no port swap).
|
||||
if (e.source !== window.parent || port) return;
|
||||
port = e.ports[0];
|
||||
snap = m.snapshot || snap;
|
||||
store = m.storage || {};
|
||||
applyTheme(m.theme);
|
||||
port.onmessage = (ev: MessageEvent) => {
|
||||
const d = ev.data as HostToGuest;
|
||||
if (!d) return;
|
||||
if (d.type === 'rpc:reply') {
|
||||
const p = pending.get(d.id); if (!p) return; pending.delete(d.id);
|
||||
if (d.error) p.reject(new Error(d.error)); else p.resolve(d.result);
|
||||
} else if (d.type === 'event') {
|
||||
emit(d.name, ...(d.args || []));
|
||||
} else if (d.type === 'snapshot') {
|
||||
snap = d.snapshot || snap;
|
||||
} else if (d.type === 'theme') {
|
||||
applyTheme(d.theme);
|
||||
}
|
||||
};
|
||||
// Run the plugin now that the bridge is live. new Function keeps it out of this
|
||||
// bootstrap's scope; it only reaches the app through window.Orbit.
|
||||
try { new Function(m.source).call(window); }
|
||||
catch (err) { rpc('log', 'plugin source failed: ' + err); }
|
||||
}
|
||||
});
|
||||
|
||||
interface PanelCtrl {
|
||||
open: () => void; close: () => void; toggle: () => void; isOpen: () => boolean;
|
||||
active: (on: boolean) => void; body: () => HTMLElement;
|
||||
}
|
||||
interface OrbitGlobal {
|
||||
sandboxed: boolean;
|
||||
plugin: (name: string, fn: (orbit: ReturnType<typeof makeApi>, log: (...a: unknown[]) => void) => void) => void;
|
||||
}
|
||||
})();
|
||||
|
|
@ -47,6 +47,10 @@ export const RPC_CAPABILITY: Record<string, Permission | null> = {
|
|||
'log': null,
|
||||
};
|
||||
|
||||
/** Every RPC method name — the guest types its calls against this, so a method that
|
||||
* isn't wired on the host can't be called (no host/guest string drift). */
|
||||
export type RpcMethod = keyof typeof RPC_CAPABILITY;
|
||||
|
||||
/** Is `method` a known RPC, and is it permitted for a plugin holding `permissions`? */
|
||||
export function isGranted(permissions: readonly string[], method: string): boolean {
|
||||
if (!(method in RPC_CAPABILITY)) return false; // unknown method: refuse by default
|
||||
|
|
|
|||
|
|
@ -2,11 +2,54 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import react from '@vitejs/plugin-react'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'))
|
||||
let commit = ''
|
||||
try { commit = execSync('git rev-parse --short HEAD').toString().trim() } catch { /* not a git checkout */ }
|
||||
|
||||
// The sandbox guest (src/modules/sandbox/guest) runs INSIDE an opaque-origin iframe,
|
||||
// so it can't load app chunks — it must be a standalone script served with its own
|
||||
// strict CSP. We keep it as typed core source and transpile it here into a shell HTML
|
||||
// (plugin-sandbox.html): served on the fly in dev, emitted into the build otherwise.
|
||||
// Kept unminified so the served security-boundary code stays auditable.
|
||||
const GUEST_ENTRY = 'src/modules/sandbox/guest/index.ts'
|
||||
const GUEST_SHELL = `<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Orbit sandboxed plugin</title>
|
||||
<style>
|
||||
/* Transparent iframe so a plugin sits on the app background; color-scheme goes on
|
||||
<body>, not the root (root scheme paints an opaque UA canvas = grey box).
|
||||
Hide scrollbars (not overflow:hidden — that paints an opaque backdrop and kills
|
||||
the transparency) so a mid-resize frame never flashes a scrollbar. */
|
||||
html { background: transparent; color-scheme: normal; scrollbar-width: none; }
|
||||
body { margin: 0; padding: 0; background: transparent; color: var(--ink, inherit); display: inline-block; font: 13px/1.4 system-ui, sans-serif; }
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
</style>
|
||||
<body>
|
||||
%GUEST%`
|
||||
function buildGuestHtml(): string {
|
||||
const src = readFileSync(GUEST_ENTRY, 'utf8')
|
||||
const js = ts.transpileModule(src, { compilerOptions: { target: ts.ScriptTarget.ES2018, module: ts.ModuleKind.ESNext } })
|
||||
.outputText.replace(/^\s*export\s*\{\s*\}\s*;?\s*$/gm, '') // strip the module marker (breaks a classic <script>)
|
||||
return GUEST_SHELL.replace('%GUEST%', () => `<script>\n${js}</script>\n`)
|
||||
}
|
||||
const sandboxGuest: Plugin = {
|
||||
name: 'sandbox-guest',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if ((req.url || '').split('?')[0].endsWith('/plugin-sandbox.html')) {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(buildGuestHtml()); return
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
generateBundle() {
|
||||
this.emitFile({ type: 'asset', fileName: 'plugin-sandbox.html', source: buildGuestHtml() })
|
||||
},
|
||||
}
|
||||
|
||||
// Stamp the built app with its commit so the running client can poll for a newer
|
||||
// deploy (src/ui/appUpdate.ts) and refresh itself in place, no manual reload.
|
||||
const emitVersion: Plugin = {
|
||||
|
|
@ -25,7 +68,7 @@ const emitVersion: Plugin = {
|
|||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/app/',
|
||||
plugins: [react(), emitVersion],
|
||||
plugins: [react(), emitVersion, sandboxGuest],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue