Add plugin package installation UI
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s

This commit is contained in:
2026-10-25 05:59:45 +01:00
parent d458f6b9e4
commit cc5a36c086
2 changed files with 137 additions and 1 deletions
+84 -1
View File
@@ -40,6 +40,7 @@
await loadCollaborators();
await loadBotCommands();
await loadBotSecrets();
await loadPlugins();
await loadOverlays();
await loadBackups();
await loadStreamInfo();
@@ -172,6 +173,10 @@
let secretDescription = '';
let secretValue = '';
let secretError = '';
let plugins: Array<{ id: string; plugin_id: string; name: string; version: string; manifest: { permissions: string[]; api_version: string; entrypoint: string }; enabled: boolean; installed_at: string; updated_at: string }> = [];
let pluginError = '';
let pluginBusy = false;
let pluginDropActive = false;
let overlays: Array<{ id: string; name: string; slug: string; draft_html: string; draft_css: string; draft_javascript: string; published: boolean; enabled: boolean; disabled_design: string }> = [];
let selectedOverlayId = '';
let overlayName = '';
@@ -389,6 +394,7 @@
chatReplyParent = undefined;
chatThreadRoot = undefined;
pinnedMessageIds = [];
plugins = [];
}
function connectRealtime() {
@@ -928,6 +934,81 @@
}
}
async function loadPlugins() {
try {
const response = await fetch(`${serverAddress}/api/v1/admin/plugins`, { headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('Plugins could not be loaded.');
plugins = await response.json();
pluginError = '';
} catch (error) {
pluginError = error instanceof Error ? error.message : 'Plugins could not be loaded.';
}
}
async function installPluginFile(file: File) {
if (file.size > 6 * 1024 * 1024) {
pluginError = 'Plugin package is too large (maximum 6 MiB).';
return;
}
pluginBusy = true;
pluginError = '';
try {
const packageText = await file.text();
const packageValue = JSON.parse(packageText) as { manifest?: unknown; wasm_base64?: unknown };
if (!packageValue.manifest || typeof packageValue.wasm_base64 !== 'string') throw new Error('Package must contain manifest and wasm_base64 fields.');
const response = await fetch(`${serverAddress}/api/v1/admin/plugins`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify(packageValue) });
const result = (await response.json().catch(() => ({}))) as { code?: string; reason?: string };
if (!response.ok) throw new Error(result.reason || result.code || 'Plugin installation failed.');
await loadPlugins();
} catch (error) {
pluginError = error instanceof Error ? error.message : 'Plugin installation failed.';
} finally {
pluginBusy = false;
}
}
function handlePluginFileInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) void installPluginFile(file);
(event.currentTarget as HTMLInputElement).value = '';
}
function handlePluginDrop(event: DragEvent) {
event.preventDefault();
pluginDropActive = false;
const file = event.dataTransfer?.files?.[0];
if (file) void installPluginFile(file);
}
async function setPluginEnabled(plugin: (typeof plugins)[number]) {
pluginBusy = true;
pluginError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}`, { method: 'PATCH', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ enabled: !plugin.enabled }) });
if (!response.ok) throw new Error('Plugin state could not be changed.');
await loadPlugins();
} catch (error) {
pluginError = error instanceof Error ? error.message : 'Plugin state could not be changed.';
} finally {
pluginBusy = false;
}
}
async function removePlugin(plugin: (typeof plugins)[number]) {
if (!window.confirm(`Remove plugin ${plugin.name}?`)) return;
pluginBusy = true;
pluginError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}`, { method: 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('Plugin could not be removed.');
await loadPlugins();
} catch (error) {
pluginError = error instanceof Error ? error.message : 'Plugin could not be removed.';
} finally {
pluginBusy = false;
}
}
async function loadOverlays() {
try {
const response = await fetch(`${serverAddress}/api/v1/overlays`, { headers: { authorization: `Bearer ${sessionToken}` } });
@@ -1150,7 +1231,7 @@
<header class="app-bar">
<div class="brand"><span class="brand-mark">◇</span><span>TRIXIE</span></div>
<nav class="top-nav" aria-label="Primary navigation">
{#each ['Live dashboard', 'Chat', 'Bot', 'Collaboration', 'Overlays', 'Settings'] as item}
{#each ['Live dashboard', 'Chat', 'Bot', 'Plugins', 'Collaboration', 'Overlays', 'Settings'] as item}
<button class:active={activeView === item} class="nav-item" onclick={() => (activeView = item)}>{item}</button>
{/each}
</nav>
@@ -1230,6 +1311,8 @@
{#if authError}<div class="auth-error">{authError}</div>{/if}
</div>
</section>
{:else if activeView === 'Plugins'}
<section class="plugins-page panel"><div class="panel-header"><span>WEBASSEMBLY PLUGINS</span><button class="secondary" onclick={loadPlugins}>REFRESH</button></div><div class="settings-body"><p class="settings-note">Packages are validated and started in a resource-limited WebAssembly runtime. Only explicitly declared Trixie permissions are accepted; installation never grants filesystem or process access.</p><label class:drop-active={pluginDropActive} class="plugin-drop-zone" ondragover={(event) => { event.preventDefault(); pluginDropActive = true; }} ondragleave={() => (pluginDropActive = false)} ondrop={handlePluginDrop}><strong>DROP PLUGIN PACKAGE HERE</strong><span>or choose a `.trixie-plugin` JSON package</span><input class="visually-hidden" type="file" accept=".trixie-plugin,.json,application/json" onchange={handlePluginFileInput} /></label><div class="overlay-controls"><label class="primary plugin-file-button">CHOOSE PACKAGE<input class="visually-hidden" type="file" accept=".trixie-plugin,.json,application/json" onchange={handlePluginFileInput} /></label></div>{#if pluginError}<div class="auth-error">{pluginError}</div>{/if}{#if plugins.length}<div class="plugin-list">{#each plugins as plugin}<article class="plugin-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">{plugin.plugin_id} · v{plugin.version}</span><div class="micro">API {plugin.manifest.api_version} · {plugin.manifest.permissions.length ? plugin.manifest.permissions.join(' · ') : 'NO PERMISSIONS'}</div></div><div class="bot-command-meta"><span class:good={plugin.enabled}>{plugin.enabled ? 'ENABLED' : 'DISABLED'}</span><button class="secondary-action" onclick={() => setPluginEnabled(plugin)} disabled={pluginBusy}>{plugin.enabled ? 'DISABLE' : 'ENABLE'}</button><button class="secondary-action danger" onclick={() => removePlugin(plugin)} disabled={pluginBusy}>REMOVE</button></div></article>{/each}</div>{:else}<div class="empty-row">No plugins are installed.</div>{/if}</div></section>
{:else if activeView === 'Bot'}
<section class="bot-command-editor panel"><div class="panel-header"><span>{commandFormId ? 'EDIT COMMAND' : 'NEW COMMAND'}</span><button class="secondary" onclick={resetCommandForm}>CLEAR</button></div><div class="settings-body command-form"><div class="command-form-grid"><label class="micro" for="command-name">NAME</label><input id="command-name" class="control" bind:value={commandFormName} maxlength="64" placeholder="uptime" /><label class="micro" for="command-description">DESCRIPTION</label><input id="command-description" class="control" bind:value={commandFormDescription} maxlength="500" placeholder="What this command does" /><label class="micro" for="command-permission">PERMISSION</label><select id="command-permission" class="control" bind:value={commandFormPermission}><option value="everyone">EVERYONE</option><option value="subscriber">SUBSCRIBER</option><option value="moderator">MODERATOR</option><option value="broadcaster">BROADCASTER</option></select><label class="micro" for="command-aliases">ALIASES</label><input id="command-aliases" class="control" bind:value={commandFormAliases} placeholder="alias1, alias2" /><label class="micro" for="command-tags">TAGS</label><input id="command-tags" class="control" bind:value={commandFormTags} placeholder="utility, fun" /><label class="micro" for="command-cooldown">COOLDOWN (SECONDS)</label><input id="command-cooldown" class="control" type="number" min="0" max="86400" bind:value={commandFormCooldown} /><label class="micro" for="command-cooldown-scope">COOLDOWN SCOPE</label><select id="command-cooldown-scope" class="control" bind:value={commandFormCooldownScope}><option value="global">GLOBAL</option><option value="user">PER USER</option><option value="role">PER ROLE</option></select><label class="micro" for="command-response">RESPONSE TEMPLATE</label><textarea id="command-response" class="control command-template" bind:value={commandFormResponse} oninput={() => updateCommandVariableSuggestions(commandFormResponse)} maxlength="2000" placeholder="Hello ${user_name}"></textarea>{#if commandVariableSuggestions.length}<div class="command-variable-picker" role="listbox" aria-label="Command variables">{#each commandVariableSuggestions as variable}<button class="secondary-action" type="button" onclick={() => insertCommandVariable(variable)}>${`{${variable}}`}</button>{/each}</div>{/if}<label class="micro" for="command-error-response">ERROR RESPONSE (OPTIONAL)</label><textarea id="command-error-response" class="control command-template" bind:value={commandFormErrorResponse} maxlength="2000" placeholder="Something went wrong."></textarea></div><label class="overlay-enabled"><input type="checkbox" bind:checked={commandFormEnabled} /> ENABLED</label><button class="primary control-button" onclick={saveCommand} disabled={commandFormBusy || !commandFormName || !commandFormDescription || !commandFormResponse}>{commandFormBusy ? 'SAVING…' : commandFormId ? 'SAVE COMMAND' : 'CREATE COMMAND'}</button></div></section>
<section class="bot-page panel"><div class="panel-header"><span>BOT COMMANDS</span><button class="secondary" onclick={loadBotCommands}>REFRESH</button></div><div class="bot-page-body"><div class="micro">PUBLIC COMMAND CATALOG</div>{#if botCommandError}<div class="auth-error">{botCommandError}</div>{:else if botCommands.length}<div class="bot-command-list">{#each botCommands as command}<article class="bot-command-row"><div><strong>!{command.name}</strong><span class="bot-command-description">{command.description}</span>{#if command.tags.length}<div class="micro">{command.tags.join(' · ')}</div>{/if}</div><div class="bot-command-meta"><span>{command.permission_level.toUpperCase()}</span>{#if command.cooldown_seconds}<span>{command.cooldown_seconds}s / {command.cooldown_scope}</span>{/if}</div></article>{/each}</div>{:else}<div class="empty-state"><strong>No enabled commands</strong><span>Create commands on the server to make them available here.</span></div>{/if}<div class="bot-secret-section"><div class="micro">SECRETS MANAGEMENT</div><div class="bot-secret-note">Values are encrypted and cannot be read after saving.</div><div class="secret-form"><input class="control" bind:value={secretName} maxlength="100" placeholder="Name" /><input class="control" bind:value={secretDescription} maxlength="500" placeholder="Description" /><input class="control" type="password" bind:value={secretValue} maxlength="65536" placeholder="Value" /><button class="primary" onclick={createSecret} disabled={!secretName || !secretValue}>ADD SECRET</button></div>{#if botSecrets.length}<div class="bot-secret-list">{#each botSecrets as secret}<div class="bot-secret-row"><div><strong>{secret.name}</strong><span>{secret.description || 'No description'}</span>{#if secret.used_by.length}<div class="micro">USED BY: {secret.used_by.join(', ')}</div>{/if}</div><button class="secondary-action" onclick={() => deleteSecret(secret.id)}>DELETE</button></div>{/each}</div>{:else}<div class="empty-row">There are no secrets yet.</div>{/if}{#if secretError}<div class="auth-error">{secretError}</div>{/if}</div></div></section>
+53
View File
@@ -1009,6 +1009,59 @@ label {
.command-variable-picker .secondary-action {
font-family: var(--font-mono);
}
.plugin-drop-zone {
display: flex;
min-height: 150px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed var(--ui-line-focus);
background: var(--ui-surface);
color: var(--ed-fg-muted);
text-align: center;
cursor: pointer;
}
.plugin-drop-zone.drop-active,
.plugin-drop-zone:hover {
border-color: var(--ed-accent);
background: var(--ui-surface-hi);
}
.plugin-drop-zone strong {
color: var(--ed-fg);
letter-spacing: 0.12em;
}
.plugin-file-button {
position: relative;
display: inline-flex;
cursor: pointer;
}
.plugin-list {
display: grid;
gap: 1px;
margin-top: 16px;
background: var(--ui-divider);
}
.plugin-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
background: var(--ui-surface);
}
.plugin-row > div:first-child {
display: grid;
gap: 4px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.collaboration-state-panel .danger {
margin-top: auto;
}