feat: lock down plugin runtime in sandbox + signing + approval
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// Plugin shortcut dispatcher.
|
||||
//
|
||||
// Each enabled plugin declares zero-or-more keyboard shortcuts via its
|
||||
// `shortcuts` export. On init the host registers each binding here. A single
|
||||
// window keydown listener matches keys against the active bindings and
|
||||
// dispatches via `instance.invokeHook('shortcut:<id>', [])`.
|
||||
//
|
||||
// The listener ignores events when an editable element has focus, matching
|
||||
// the convention in `use-keyboard-shortcuts.ts`.
|
||||
|
||||
import type { SandboxInstance } from './host-bridge';
|
||||
|
||||
interface Binding {
|
||||
pluginId: string;
|
||||
shortcutId: string;
|
||||
keys: string; // "Ctrl+Shift+L"
|
||||
label: string;
|
||||
category?: string;
|
||||
invoke: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface NormalisedCombo {
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
const bindings = new Map<string, Binding>(); // key: `${pluginId}:${shortcutId}`
|
||||
let listenerInstalled = false;
|
||||
|
||||
function normaliseCombo(combo: string): NormalisedCombo | null {
|
||||
if (typeof combo !== 'string') return null;
|
||||
const parts = combo.split('+').map(p => p.trim()).filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
let ctrl = false, shift = false, alt = false, meta = false;
|
||||
let key = '';
|
||||
for (const p of parts) {
|
||||
const lower = p.toLowerCase();
|
||||
if (lower === 'ctrl' || lower === 'control') ctrl = true;
|
||||
else if (lower === 'shift') shift = true;
|
||||
else if (lower === 'alt' || lower === 'option') alt = true;
|
||||
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') meta = true;
|
||||
else key = lower;
|
||||
}
|
||||
if (!key) return null;
|
||||
return { ctrl, shift, alt, meta, key };
|
||||
}
|
||||
|
||||
function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean {
|
||||
if (combo.ctrl !== (ev.ctrlKey || ev.metaKey ? ev.ctrlKey : false)) {
|
||||
// Treat Ctrl and Cmd as equivalent: a binding declaring Ctrl matches a
|
||||
// Cmd press on macOS.
|
||||
if (combo.ctrl) {
|
||||
if (!(ev.ctrlKey || ev.metaKey)) return false;
|
||||
} else if (ev.ctrlKey) return false;
|
||||
}
|
||||
if (combo.shift !== ev.shiftKey) return false;
|
||||
if (combo.alt !== ev.altKey) return false;
|
||||
if (!combo.ctrl && combo.meta !== ev.metaKey) return false;
|
||||
return ev.key.toLowerCase() === combo.key;
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function onKeyDown(ev: KeyboardEvent): void {
|
||||
if (isEditableTarget(ev.target)) return;
|
||||
if (bindings.size === 0) return;
|
||||
for (const binding of bindings.values()) {
|
||||
const combo = normaliseCombo(binding.keys);
|
||||
if (!combo) continue;
|
||||
if (eventMatches(ev, combo)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void binding.invoke();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureListener(): void {
|
||||
if (listenerInstalled || typeof window === 'undefined') return;
|
||||
listenerInstalled = true;
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
}
|
||||
|
||||
export function registerShortcuts(
|
||||
instance: SandboxInstance,
|
||||
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>,
|
||||
): () => void {
|
||||
ensureListener();
|
||||
const keys: string[] = [];
|
||||
for (const sc of shortcuts) {
|
||||
const key = `${instance.pluginId}:${sc.id}`;
|
||||
bindings.set(key, {
|
||||
pluginId: instance.pluginId,
|
||||
shortcutId: sc.id,
|
||||
keys: sc.keys,
|
||||
label: sc.label,
|
||||
category: sc.category,
|
||||
invoke: async () => {
|
||||
try {
|
||||
await instance.invokeHook(`shortcut:${sc.id}`, []);
|
||||
} catch {
|
||||
/* hook tracker already logs */
|
||||
}
|
||||
},
|
||||
});
|
||||
keys.push(key);
|
||||
}
|
||||
return () => {
|
||||
for (const k of keys) bindings.delete(k);
|
||||
};
|
||||
}
|
||||
|
||||
/** Snapshot of currently active shortcuts. Used by the help modal. */
|
||||
export function listShortcuts(): Array<{ pluginId: string; id: string; keys: string; label: string; category?: string }> {
|
||||
return [...bindings.values()].map(b => ({
|
||||
pluginId: b.pluginId,
|
||||
id: b.shortcutId,
|
||||
keys: b.keys,
|
||||
label: b.label,
|
||||
category: b.category,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user