orbit/public/plugin-sandbox.html

167 lines
7.4 KiB
HTML

<!doctype html>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<title>Orbit sandboxed plugin</title>
<style>
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>
/*
* 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 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's theme CSS vars onto our root so plugin UI can use var(--accent) etc.
// Also pin color-scheme to the APP theme (from its background), not the visitor's OS,
// so native controls don't render dark chrome under a light theme or vice-versa.
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.documentElement.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) {
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); },
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); },
},
// Populate this iframe's own DOM, then claim a UI slot in the app shell.
// Height is reported back so the host can size the frame to the content.
ui: function (slot, build) {
// Outer sizer stays inline-block so it shrink-wraps to content; the plugin
// only gets `inner` and may style it freely (even display:flex) without
// breaking measurement.
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);
},
};
}
// 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>