diff --git a/src/App.svelte b/src/App.svelte index d1fee48..74fa494 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@
◇TRIXIE
@@ -1230,6 +1311,8 @@ {#if authError}
{authError}
{/if} + {:else if activeView === 'Plugins'} +
WEBASSEMBLY PLUGINS

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.

{#if pluginError}
{pluginError}
{/if}{#if plugins.length}
{#each plugins as plugin}
{plugin.name}{plugin.plugin_id} · v{plugin.version}
API {plugin.manifest.api_version} · {plugin.manifest.permissions.length ? plugin.manifest.permissions.join(' · ') : 'NO PERMISSIONS'}
{plugin.enabled ? 'ENABLED' : 'DISABLED'}
{/each}
{:else}
No plugins are installed.
{/if}
{:else if activeView === 'Bot'}
{commandFormId ? 'EDIT COMMAND' : 'NEW COMMAND'}
{#if commandVariableSuggestions.length}
{#each commandVariableSuggestions as variable}{/each}
{/if}
BOT COMMANDS
PUBLIC COMMAND CATALOG
{#if botCommandError}
{botCommandError}
{:else if botCommands.length}
{#each botCommands as command}
!{command.name}{command.description}{#if command.tags.length}
{command.tags.join(' · ')}
{/if}
{command.permission_level.toUpperCase()}{#if command.cooldown_seconds}{command.cooldown_seconds}s / {command.cooldown_scope}{/if}
{/each}
{:else}
No enabled commandsCreate commands on the server to make them available here.
{/if}
SECRETS MANAGEMENT
Values are encrypted and cannot be read after saving.
{#if botSecrets.length}
{#each botSecrets as secret}
{secret.name}{secret.description || 'No description'}{#if secret.used_by.length}
USED BY: {secret.used_by.join(', ')}
{/if}
{/each}
{:else}
There are no secrets yet.
{/if}{#if secretError}
{secretError}
{/if}
diff --git a/src/styles.css b/src/styles.css index 79a3181..39415a3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }