Compare commits

..
41 Commits
Author SHA1 Message Date
ReaperOfVeriod ad3648ca6c Support completing first-run setup after session restoration
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 17:58:21 +01:00
ReaperOfVeriod 0c60409874 Keep credential replacement required for legacy session responses
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 17:56:08 +01:00
ReaperOfVeriod 8d8474c349 Keep keyring session payloads out of process arguments
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 17:52:22 +01:00
ReaperOfVeriod 7aef4171af Preserve native sessions during temporary server outages
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 17:51:10 +01:00
ReaperOfVeriod 58df8d06e1 refresh client implementation documentation
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 09:47:50 +01:00
ReaperOfVeriod bc06d323c7 preserve client session after partial load failure
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 09:44:07 +01:00
ReaperOfVeriod 092b58cdc2 allow manual connection retry during backoff
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 09:33:08 +01:00
ReaperOfVeriod b54c7b23fd open streamer channel from collab fallback
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 07:37:42 +01:00
ReaperOfVeriod 3dfeb98840 show guest star media state
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 07:34:59 +01:00
ReaperOfVeriod d81094861e type guest star media state
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 07:34:25 +01:00
ReaperOfVeriod 9bc0db7b2a use theme font fallbacks without missing assets
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 07:12:26 +01:00
ReaperOfVeriod aeae58d0d8 validate client server origins
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:57:31 +01:00
ReaperOfVeriod 313535fe70 add client lint command
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:53:53 +01:00
ReaperOfVeriod 9cf297613d Add fixed-source client update detection
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:34:37 +01:00
ReaperOfVeriod 8a3ac54204 Document overlay editor workflow
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:32:07 +01:00
ReaperOfVeriod d58d435fde Add safe overlay source completions
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:31:55 +01:00
ReaperOfVeriod ed30446136 Add tabbed line-numbered overlay editor
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:29:01 +01:00
ReaperOfVeriod 5920055473 Expose plugin runtime action
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:25:02 +01:00
ReaperOfVeriod 7fabf936b8 Add shared chat status popover
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:22:10 +01:00
ReaperOfVeriod 8879897e06 Show collaboration invitation state
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:21:48 +01:00
ReaperOfVeriod 8a28596f47 Add Guest Star participant controls
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:21:28 +01:00
ReaperOfVeriod 027e7434f6 Query collaboration capabilities
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:20:26 +01:00
ReaperOfVeriod 9f8a5a5ecc Expose overlay alert playback settings
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:18:37 +01:00
ReaperOfVeriod d1636a3a2a Show collaboration action notices
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:16:07 +01:00
ReaperOfVeriod b4bc860533 Confirm destructive collaboration actions
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:15:38 +01:00
ReaperOfVeriod 3d29bd5544 Expose plugin secret approval UI
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:14:57 +01:00
ReaperOfVeriod 7682abdc9f Add typed argument command editor
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:12:47 +01:00
ReaperOfVeriod a48cd5bc22 Add user-specific command rule editor
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:10:25 +01:00
ReaperOfVeriod 52a5ce895f Expose command web action configuration
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:05:48 +01:00
ReaperOfVeriod dc3985ee00 Add command simulation controls
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:03:24 +01:00
ReaperOfVeriod 1707731029 Add plugin rollback control
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 06:00:51 +01:00
ReaperOfVeriod cc5a36c086 Add plugin package installation UI
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:59:45 +01:00
ReaperOfVeriod d458f6b9e4 Document client authority boundary
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:56:04 +01:00
ReaperOfVeriod 1043d4057b Add authenticated backup restore control
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:54:58 +01:00
ReaperOfVeriod 733660e684 Add bot command variable autocomplete
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:54:05 +01:00
ReaperOfVeriod 7df188b3fd Add editable follow alert starter
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:52:51 +01:00
ReaperOfVeriod 6792544c16 Refresh collaboration state from realtime events
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:52:33 +01:00
ReaperOfVeriod 1325415085 Show Guest Star channel settings
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:50:54 +01:00
ReaperOfVeriod 1cf940c7dd Add dedicated collaboration dashboard
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:50:08 +01:00
ReaperOfVeriod 0881bb9b0f Format generated Tauri schemas
Client dependency and security checks / audit (push) Canceled after 0s
Client checks / checks (push) Canceled after 0s
2026-10-25 05:49:25 +01:00
ReaperOfVeriod f7d466eddb Restore native client sessions 2026-10-25 05:49:21 +01:00
18 changed files with 2803 additions and 149 deletions
+2
View File
@@ -16,7 +16,9 @@ jobs:
- run: test -f README.md && test -f AGENTS.md && test -f .prettierrc.json
- run: npm ci
- run: npm run format:check
- run: npm run lint
- run: npm run check
- run: npm test
- run: npm run build
- uses: actions-rust-lang/setup-rust-toolchain@v1
- working-directory: native
+2 -1
View File
@@ -14,4 +14,5 @@ jobs:
with:
node-version: 22
cache: npm
- run: test -s CHANGELOG.md && npm ci && npm run format:check && npm run check && npm run build
- run: test -s CHANGELOG.md && npm ci && npm run format:check && npm run lint && npm run check && npm run build
- run: npm test
+6
View File
@@ -1,3 +1,9 @@
# Contributing
Run formatting, linting, type checks, tests, and the AppImage build before submission. Use deterministic mocked server/API fixtures.
The client uses Prettier for formatting and `svelte-check` for the lint/check
gate. Run `npm run format:check`, `npm run lint`, `npm run check`, and
`npm run build` before pushing changes.
The server address is treated as untrusted input. Client requests accept only
HTTP(S) origins without embedded credentials, query strings, or fragments.
+5 -1
View File
@@ -16,6 +16,10 @@ then retries wait 3, 10, and 15 seconds, followed by an indefinite 30-second
interval until the server reconnects. Manual connection checks do not create
overlapping retry loops.
Implementation has not started. See the parent workspace's `PLAN.md`, `TODO.md`, and `AGENTS.md` before beginning work.
The client currently includes the live dashboard shell, server connection and
retry flow, administrator authentication, Twitch identity controls, chat and
moderation surfaces, bot/overlay/plugin administration, and the native keyring
storage boundary. Remaining live Twitch acceptance work is tracked in the
parent workspace's `TODO.md` and operator checklist.
Consumes the versioned contracts from the sibling `trixie-api` repository.
+35
View File
@@ -0,0 +1,35 @@
# Client boundary
The Linux client is a Svelte/Tauri control surface for `trixie-server`. It
renders server-owned state and sends explicit user actions through the shared
versioned HTTP/WebSocket boundary.
The client never stores Twitch refresh tokens or the administrator password,
calls Twitch directly, executes plugin code, or treats local UI state as
authoritative. Local overlay drafts and display preferences are recoverable
presentation data only; the server remains authoritative for publication,
authorization, Twitch state, and durable history.
# Saved-session recovery
Native startup verifies the keyring session before loading authenticated data.
HTTP 401/403 clears the saved session and requests login. Server failures retain
the credential and retry immediately, then after 3, 10, 15, and repeatedly 30
seconds. Manual login and logout cancel pending restoration. Dashboard-loading
failures do not invalidate a successfully verified session.
Restored sessions retain the server's mandatory credential-replacement state.
An omitted replacement flag is treated conservatively as requiring setup rather
than silently allowing the dashboard to consider setup complete.
Dashboard loading and realtime connection wait until credential setup is complete;
the server independently enforces the same operational-access restriction.
The setup form includes a current-password field because restored keyring
sessions intentionally do not restore the user's password. This lets setup
continue after a client restart without storing the password on disk.
Run `node --test src/session-recovery.test.js` for response classification and
retry-schedule coverage. This is not a substitute for native AppImage acceptance.
Keyring writes use `keyctl padd` with the session supplied through standard input,
never process arguments. Helper stdout/stderr are discarded so credential payloads
cannot enter diagnostics. Native tests cover the argument boundary, stdin delivery,
and helper failure handling without reading or modifying real keyring entries.
+13
View File
@@ -0,0 +1,13 @@
# Overlay editor
The Overlays view edits HTML, CSS, and JavaScript independently. Each source
tab shows line numbers and a small completion picker containing literal starter
snippets. Selecting a completion only inserts text into the active draft; it
does not execute or evaluate the snippet in the client.
Draft changes are cached locally and autosaved to the server after the debounce
period. The live preview uses a sandboxed iframe. Publishing is a separate
explicit action that changes the OBS browser-source output; saving a draft does
not publish it. Diagnostics remain available through the warning indicator,
and the server applies the authoritative size, slug, and browser-source
security checks.
+11
View File
@@ -0,0 +1,11 @@
# Client updates
The Settings view checks the fixed stable release source for
`ReaperOfVeriod/trixy-linux-client` on Gitea. It compares the release tag with
the running client version and shows the release page when a newer version is
available.
Applying an update remains manual: review the release, download the x86-64
AppImage, replace the existing file, and restart it. The client never accepts
an arbitrary update URL, silently replaces itself, or stores credentials in the
update flow.
+47 -2
View File
@@ -9,8 +9,9 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs, io,
io::Write,
path::{Path, PathBuf},
process::Command,
process::{Command, Stdio},
};
pub const KEYRING_SERVICE: &str = "trixie.server.session";
@@ -84,7 +85,34 @@ pub fn store_session(settings: &Settings, session: &str) -> io::Result<()> {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty session"));
}
let description = key_description(settings);
keyctl(&["add", "user", &description, session, "@s"]).map(|_| ())
write_key_payload(key_write_command(&description), session.as_bytes())
}
// Secret payloads must never be arguments: process listings can expose argv.
fn key_write_command(description: &str) -> Command {
let mut command = Command::new("keyctl");
command.args(["padd", "user", description, "@s"]);
command
}
fn write_key_payload(mut command: Command, payload: &[u8]) -> io::Result<()> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let result = child.stdin.take().expect("piped stdin").write_all(payload);
// Closing stdin above signals EOF. Reap the child even if writing failed.
let status = child.wait()?;
result?;
if status.success() {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"Linux kernel keyring access failed",
))
}
}
pub fn load_session(settings: &Settings) -> io::Result<Option<String>> {
@@ -117,6 +145,23 @@ pub fn clear_session(settings: &Settings) -> io::Result<()> {
mod tests {
use super::*;
#[test]
fn key_write_arguments_contain_only_metadata() {
let command = key_write_command("test-description");
let args: Vec<_> = command.get_args().collect();
assert_eq!(args, ["padd", "user", "test-description", "@s"]);
}
#[test]
fn payload_is_delivered_via_stdin_and_failures_are_reported() {
let mut receiver = Command::new("sh");
receiver.args(["-c", "test \"$(cat)\" = fixture-payload"]);
write_key_payload(receiver, b"fixture-payload").unwrap();
let mut failure = Command::new("sh");
failure.args(["-c", "cat >/dev/null; exit 1"]);
assert!(write_key_payload(failure, b"fixture-payload").is_err());
}
fn settings() -> Settings {
Settings {
server_address: "https://trixie.example".into(),
+2
View File
@@ -4,12 +4,14 @@
"version": "0.1.0-dev",
"type": "module",
"scripts": {
"test": "node --test src/*.test.js",
"dev": "vite",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "svelte-check --tsconfig ./tsconfig.json",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{}
{}
+13 -41
View File
@@ -21,9 +21,7 @@
{
"description": "A list of capabilities.",
"type": "object",
"required": [
"capabilities"
],
"required": ["capabilities"],
"properties": {
"capabilities": {
"description": "The list of capabilities.",
@@ -39,10 +37,7 @@
"Capability": {
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
"type": "object",
"required": [
"identifier",
"permissions"
],
"required": ["identifier", "permissions"],
"properties": {
"identifier": {
"description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`",
@@ -93,10 +88,7 @@
},
"platforms": {
"description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`",
"type": [
"array",
"null"
],
"type": ["array", "null"],
"items": {
"$ref": "#/definitions/Target"
}
@@ -106,9 +98,7 @@
"CapabilityRemote": {
"description": "Configuration for remote URLs that are associated with the capability.",
"type": "object",
"required": [
"urls"
],
"required": ["urls"],
"properties": {
"urls": {
"description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api",
@@ -146,20 +136,14 @@
},
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"type": ["array", "null"],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"type": ["array", "null"],
"items": {
"$ref": "#/definitions/Value"
}
@@ -167,9 +151,7 @@
}
}
],
"required": [
"identifier"
]
"required": ["identifier"]
}
]
},
@@ -2278,39 +2260,29 @@
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
"enum": ["macOS"]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
"enum": ["windows"]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
"enum": ["linux"]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
"enum": ["android"]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
"enum": ["iOS"]
}
]
}
}
}
}
+13 -41
View File
@@ -21,9 +21,7 @@
{
"description": "A list of capabilities.",
"type": "object",
"required": [
"capabilities"
],
"required": ["capabilities"],
"properties": {
"capabilities": {
"description": "The list of capabilities.",
@@ -39,10 +37,7 @@
"Capability": {
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
"type": "object",
"required": [
"identifier",
"permissions"
],
"required": ["identifier", "permissions"],
"properties": {
"identifier": {
"description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`",
@@ -93,10 +88,7 @@
},
"platforms": {
"description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`",
"type": [
"array",
"null"
],
"type": ["array", "null"],
"items": {
"$ref": "#/definitions/Target"
}
@@ -106,9 +98,7 @@
"CapabilityRemote": {
"description": "Configuration for remote URLs that are associated with the capability.",
"type": "object",
"required": [
"urls"
],
"required": ["urls"],
"properties": {
"urls": {
"description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api",
@@ -146,20 +136,14 @@
},
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"type": ["array", "null"],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"type": ["array", "null"],
"items": {
"$ref": "#/definitions/Value"
}
@@ -167,9 +151,7 @@
}
}
],
"required": [
"identifier"
]
"required": ["identifier"]
}
]
},
@@ -2278,39 +2260,29 @@
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
"enum": ["macOS"]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
"enum": ["windows"]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
"enum": ["linux"]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
"enum": ["android"]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
"enum": ["iOS"]
}
]
}
}
}
}
+575 -35
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { sessionResponseState, sessionRetryDelay } from './session-recovery.js';
let sessionRestoreTimer: ReturnType<typeof setTimeout> | undefined;
let sessionRestoreAttempt = 0;
let sessionRestoreGeneration = 0;
type ClientSettings = { server_address: string; username: string };
@@ -8,9 +13,23 @@
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
function validatedServerAddress(value: string): string {
const candidate = value.trim();
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
throw new Error('The server address must be a valid HTTP or HTTPS URL.');
}
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || !parsed.hostname) {
throw new Error('The server address must use HTTP or HTTPS without credentials or URL data.');
}
return parsed.toString().replace(/\/$/, '');
}
async function saveNativeSession() {
if (!nativeClientAvailable() || !username || !serverAddress || !sessionToken) return;
const settings: ClientSettings = { server_address: serverAddress, username };
const settings: ClientSettings = { server_address: validatedServerAddress(serverAddress), username };
await invoke('save_client_settings', { settings });
await invoke('store_server_session', { settings, session: sessionToken });
}
@@ -24,21 +43,92 @@
if (!nativeClientAvailable()) return;
try {
const settings = await invoke<ClientSettings>('load_client_settings');
serverAddress = settings.server_address;
serverAddress = validatedServerAddress(settings.server_address);
username = settings.username;
} catch {
authError = 'Native settings could not be read. Enter the server address and username again.';
}
}
async function loadAuthenticatedData() {
if (mustReplaceCredentials) return;
await loadActivity();
await loadNotifications();
await loadDiagnostics();
await loadOAuthStatus();
await loadCollaboration();
await loadCollaborators();
await loadBotCommands();
await loadBotSecrets();
await loadPlugins();
await loadOverlays();
await loadBackups();
await loadStreamInfo();
connectRealtime();
}
async function restoreNativeSession() {
if (!nativeClientAvailable() || !username || !serverAddress) return;
const generation = ++sessionRestoreGeneration;
clearTimeout(sessionRestoreTimer);
let address = serverAddress;
const account = username;
const isCurrentRestore = () => generation === sessionRestoreGeneration && address === serverAddress && account === username;
try {
const safeServerAddress = validatedServerAddress(serverAddress);
serverAddress = safeServerAddress;
address = safeServerAddress;
const settings: ClientSettings = { server_address: safeServerAddress, username };
const storedSession = await invoke<string | null>('load_server_session', { settings });
if (!storedSession || !isCurrentRestore()) return;
const response = await fetch(`${safeServerAddress}/api/v1/auth/session`, {
headers: { authorization: `Bearer ${storedSession}` },
});
if (!isCurrentRestore()) return;
const state = sessionResponseState(response.status);
if (state === 'revoked') {
await invoke('clear_server_session', { settings });
if (!isCurrentRestore()) return;
sessionToken = '';
authenticated = false;
authError = 'Authentication revoked. Log in again.';
return;
}
if (state !== 'valid') throw new Error('Session verification unavailable');
const result: { must_replace_credentials: boolean } = await response.json();
if (!isCurrentRestore()) return;
sessionRestoreAttempt = 0;
authError = '';
sessionToken = storedSession;
authenticated = true;
mustReplaceCredentials = result.must_replace_credentials !== false;
connectionState = 'CONNECTED';
await loadAuthenticatedData().catch(() => {
authError = 'Authenticated, but dashboard data could not be loaded.';
});
if (mustReplaceCredentials) newUsername = username;
} catch {
if (!isCurrentRestore()) return;
// Transport/server failures are not proof of revocation: preserve the keyring.
authError = 'Server unavailable. Retrying saved session.';
sessionRestoreTimer = setTimeout(() => {
if (isCurrentRestore()) void restoreNativeSession();
}, sessionRetryDelay(sessionRestoreAttempt++));
}
}
let serverAddress = 'http://127.0.0.1:8787';
let connectionState = 'OFFLINE';
let serverVersion = '—';
let serverCompatibility = '';
let clientUpdateStatus = '';
let clientUpdateUrl = '';
let clientUpdateBusy = false;
let diagnostics: { status: string; database: string; twitch_identity_count: number; unseen_notification_count: number; realtime: string } | undefined;
let backups: Array<{ filename: string; size_bytes: number; modified_at: string | null }> = [];
let backupError = '';
let backupBusy = false;
let restoreBusy = false;
let activeView = 'Live dashboard';
let sessionToken = '';
let authenticated = false;
@@ -53,6 +143,7 @@
let authBusy = false;
let connectionNotice = '';
let connectionCheckRunning = false;
let connectionCheckGeneration = 0;
let realtimeState = 'DISCONNECTED';
let realtimeSocket: WebSocket | undefined;
let activityEvents: Array<{ event_id: string; event_type: string; occurred_at: string }> = [];
@@ -90,10 +181,11 @@
let moderationReason = '';
let moderationBusy = false;
let moderationError = '';
let collaborationSession: { id: string; guests: Array<{ user_id: string; user_display_name?: string | null; slot_id?: string | null; is_live?: boolean | null }> } | null = null;
let collaborationSession: { id: string; guests: Array<{ user_id: string; user_display_name?: string | null; user_login?: string | null; slot_id?: string | null; is_live?: boolean | null; volume?: number | null; assigned_at?: string | null; audio_settings?: { is_host_enabled?: boolean | null; is_guest_enabled?: boolean | null; is_available?: boolean | null } | null; video_settings?: { is_host_enabled?: boolean | null; is_guest_enabled?: boolean | null; is_available?: boolean | null } | null }> } | null = null;
let collaborationInvites: Array<{ user_id: string; invited_at: string; status: string }> = [];
let collaborationBusy = false;
let collaborationError = '';
let collaborationNotice = '';
let showCollaboration = false;
let collaborationGuestId = '';
let collaborators: Array<{ twitch_user_id: string; display_name: string; favorite: boolean; last_invited_at: string | null }> = [];
@@ -103,11 +195,14 @@
let collaboratorSearchError = '';
let collaboratorSearchTimer: number | undefined;
let sharedChatSession: { session_id: string; host_broadcaster_id: string; participants: Array<{ broadcaster_id: string }> } | null = null;
let showSharedChatDetails = false;
let collaborationSettings: { is_moderator_send_live_enabled: boolean; slot_count: number; is_browser_source_audio_enabled: boolean; group_layout: string; browser_source_token: string } | null = null;
let collaborationCapabilities: { guest_star_available: boolean; guest_star_reason?: string | null; shared_chat_available: boolean; shared_chat_reason?: string | null } | null = null;
let oauthLink = '';
let oauthIdentity: 'streamer' | 'bot' = 'streamer';
let oauthBusy = false;
let oauthError = '';
let botCommands: Array<{ id: string; name: string; description: string; permission_level: string; aliases: string[]; tags: string[]; cooldown_seconds: number; cooldown_scope: string; response_template: string; error_template: string | null; enabled: boolean }> = [];
let botCommands: Array<{ id: string; name: string; description: string; permission_level: string; aliases: string[]; tags: string[]; cooldown_seconds: number; cooldown_scope: string; response_template: string; error_template: string | null; enabled: boolean; web_request?: { method: string; url: string; body?: string | null } | null; response_fields?: Array<[string, string]>; argument_definitions?: unknown[] }> = [];
let publicBotCommands: Array<{ id: string; name: string; description: string; permission_level: string; aliases: string[]; tags: string[] }> = [];
let botCommandError = '';
let commandFormId = '';
@@ -121,13 +216,32 @@
let commandFormResponse = '';
let commandFormErrorResponse = '';
let commandFormEnabled = true;
let commandWebMethod = 'GET';
let commandWebUrl = '';
let commandWebBody = '';
let commandResponseFields = '';
let commandArgumentDefinitions = '[]';
let commandRules: Array<{ user_id: string; response_template: string | null }> = [];
let commandRuleUserId = '';
let commandRuleResponse = '';
let commandRuleBusy = false;
let commandRuleError = '';
let commandFormBusy = false;
let commandSimulationBusy = false;
let commandSimulationError = '';
let commandSimulationResult: { rendered?: string | null; extracted_variables?: Record<string, string>; error?: string | null } | null = null;
let commandSimulationSampleResponse = '{"status":"ok"}';
let commandVariableSuggestions: string[] = [];
let botSecrets: Array<{ id: string; name: string; description: string; used_by: string[] }> = [];
let secretName = '';
let secretDescription = '';
let secretValue = '';
let secretError = '';
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 plugins: Array<{ id: string; plugin_id: string; name: string; version: string; manifest: { permissions: string[]; required_secrets?: 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; alert_group_window_ms: number; alert_group_size: number; alert_display_ms: number }> = [];
let selectedOverlayId = '';
let overlayName = '';
let overlaySlug = '';
@@ -136,12 +250,17 @@
let overlayJavascript = '';
let overlayEnabled = true;
let overlayDisabledDesign = 'transparent';
let overlayAlertGroupWindowMs = 200;
let overlayAlertGroupSize = 5;
let overlayAlertDisplayMs = 4000;
let overlayError = '';
let overlayBusy = false;
let overlaySavedSignature = '';
let overlayAutosaveAttemptedSignature = '';
let pendingOverlayDraft: { overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string } | null = null;
let pendingOverlayDraft: { overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string; overlayAlertGroupWindowMs: number; overlayAlertGroupSize: number; overlayAlertDisplayMs: number } | null = null;
let overlayDiagnosticsOpen = false;
let overlayEditorTab: 'HTML' | 'CSS' | 'JAVASCRIPT' = 'HTML';
let overlayCompletion = '';
let overlayAutosaveTimer: number | undefined;
let oauthScopes: Record<'streamer' | 'bot', { connected: boolean; missing_scopes: string[]; reauthorization_required: boolean }> = {
streamer: { connected: false, missing_scopes: [], reauthorization_required: false },
@@ -155,6 +274,13 @@
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
}
function isVersionNewer(candidate: string, current: string) {
const candidateParts = versionParts(candidate);
const currentParts = versionParts(current);
if (!candidateParts || !currentParts) return false;
return candidateParts.some((part, index) => part > currentParts[index] && candidateParts.slice(0, index).every((previous, previousIndex) => previous === currentParts[previousIndex]));
}
function isClientCompatible(minimum: string, maximum: string) {
const client = versionParts(CLIENT_VERSION);
const minimumParts = versionParts(minimum);
@@ -179,7 +305,7 @@
}
async function checkServer() {
if (connectionCheckRunning) return;
const generation = ++connectionCheckGeneration;
connectionCheckRunning = true;
connectionState = 'CONNECTING';
let attempt = 0;
@@ -192,6 +318,7 @@
} else {
connectionNotice = 'Connecting to server';
}
if (generation !== connectionCheckGeneration) return;
try {
const response = await fetch(`${serverAddress}/api/v1/server/info`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -217,7 +344,27 @@
}
}
async function checkClientUpdate() {
clientUpdateBusy = true;
clientUpdateStatus = 'Checking fixed stable release source…';
try {
const response = await fetch('https://git.reaperofveriod.nl/api/v1/repos/ReaperOfVeriod/trixy-linux-client/releases/latest');
if (!response.ok) throw new Error('Release source unavailable.');
const release: { tag_name?: string; html_url?: string } = await response.json();
const latest = release.tag_name?.replace(/^v/, '') || '';
const current = CLIENT_VERSION.replace(/^v/, '');
clientUpdateUrl = release.html_url || 'https://git.reaperofveriod.nl/ReaperOfVeriod/trixy-linux-client/releases/latest';
clientUpdateStatus = latest && isVersionNewer(latest, current) ? `Update available: ${latest}` : `Up to date (${current})`;
} catch (error) {
clientUpdateStatus = error instanceof Error ? error.message : 'Release source unavailable.';
} finally {
clientUpdateBusy = false;
}
}
async function login() {
sessionRestoreGeneration += 1;
clearTimeout(sessionRestoreTimer);
if (connectionState === 'INCOMPATIBLE') {
authError = 'This client version is not compatible with the server.';
return;
@@ -225,12 +372,17 @@
authBusy = true;
authError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/auth/login`, {
const safeServerAddress = validatedServerAddress(serverAddress);
serverAddress = safeServerAddress;
const response = await fetch(`${safeServerAddress}/api/v1/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) throw new Error('Your username or password may be incorrect.');
if (!response.ok) {
const failure = (await response.json().catch(() => undefined)) as { message?: string; code?: string } | undefined;
throw new Error(failure?.message || failure?.code || 'The server could not process the login.');
}
const result: { session_token: string; must_replace_credentials: boolean } = await response.json();
sessionToken = result.session_token;
authenticated = true;
@@ -239,18 +391,11 @@
authError = 'Login succeeded, but the session could not be stored in the Linux keyring.';
});
connectionState = 'CONNECTED';
await loadActivity();
await loadNotifications();
await loadDiagnostics();
await loadOAuthStatus();
await loadCollaboration();
await loadCollaborators();
await loadBotCommands();
await loadBotSecrets();
await loadOverlays();
await loadBackups();
await loadStreamInfo();
connectRealtime();
try {
await loadAuthenticatedData();
} catch {
authError = 'Login succeeded, but some dashboard data could not be loaded.';
}
if (!mustReplaceCredentials) password = '';
if (mustReplaceCredentials) newUsername = username;
} catch (error) {
@@ -287,6 +432,26 @@
}
}
async function restoreBackup(filename: string) {
if (!window.confirm(`Restore ${filename}? This replaces the current database and cannot be undone.`)) return;
restoreBusy = true;
backupError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/admin/backups/restore`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
body: JSON.stringify({ filename, confirmation: 'RESTORE' }),
});
const result: { status?: string; reason?: string | null } = await response.json();
if (!response.ok || result.status !== 'ok') throw new Error(result.reason || 'Database restore failed.');
backupError = 'Database restored. Restart the server before continuing to use the dashboard.';
} catch (error) {
backupError = error instanceof Error ? error.message : 'Database restore failed.';
} finally {
restoreBusy = false;
}
}
async function replaceCredentials() {
authBusy = true;
authError = '';
@@ -313,6 +478,8 @@
}
async function logout() {
sessionRestoreGeneration += 1;
clearTimeout(sessionRestoreTimer);
realtimeSocket?.close();
realtimeSocket = undefined;
realtimeState = 'DISCONNECTED';
@@ -330,15 +497,18 @@
chatMessages = [];
collaborationSession = null;
collaborationInvites = [];
collaborationSettings = null;
showCollaboration = false;
chatReplyParent = undefined;
chatThreadRoot = undefined;
pinnedMessageIds = [];
plugins = [];
}
function connectRealtime() {
realtimeSocket?.close();
const socketUrl = serverAddress.replace(/^http/, 'ws') + '/api/v1/ws';
const safeServerAddress = validatedServerAddress(serverAddress);
const socketUrl = safeServerAddress.replace(/^http/, 'ws') + '/api/v1/ws';
realtimeState = 'CONNECTING';
const socket = new WebSocket(socketUrl);
realtimeSocket = socket;
@@ -357,6 +527,9 @@
if (activity.event_type === 'twitch.channel.chat.message' && activity.payload?.message?.text) {
chatMessages = [{ event_id: activity.event_id, message_id: activity.payload.message_id ?? activity.event_id, user_id: activity.payload.chatter_user_id ?? '', user_name: activity.payload.chatter_user_name ?? 'unknown', text: activity.payload.message.text, fragments: (activity.payload.message.fragments ?? []).map((fragment) => ({ type: fragment.type, text: fragment.text, emote_id: fragment.emote?.id })), sent_at: activity.occurred_at, reply_parent_message_id: activity.payload.reply?.parent_message_id }, ...chatMessages].slice(0, 100);
}
if (activity.event_type.startsWith('twitch.channel.shared_chat.') || activity.event_type.startsWith('twitch.channel.guest_star_')) {
void loadCollaboration();
}
}
} catch {
realtimeState = 'DISCONNECTED';
@@ -410,6 +583,8 @@
async function loadCollaboration() {
try {
const capabilities = await fetch(`${serverAddress}/api/v1/twitch/collaboration/capabilities`, { headers: { authorization: `Bearer ${sessionToken}` } });
if (capabilities.ok) collaborationCapabilities = await capabilities.json();
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/session`, { headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) return;
collaborationSession = await response.json();
@@ -419,9 +594,14 @@
}
const sharedChat = await fetch(`${serverAddress}/api/v1/twitch/collaboration/shared-chat`, { headers: { authorization: `Bearer ${sessionToken}` } });
if (sharedChat.ok) sharedChatSession = await sharedChat.json();
const settings = await fetch(`${serverAddress}/api/v1/twitch/collaboration/channel-settings`, { headers: { authorization: `Bearer ${sessionToken}` } });
if (settings.ok) collaborationSettings = await settings.json();
} catch {
collaborationSession = null;
sharedChatSession = null;
showSharedChatDetails = false;
collaborationSettings = null;
collaborationCapabilities = null;
}
}
@@ -481,6 +661,7 @@
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/session`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('Twitch could not start a Guest Star session.');
collaborationSession = await response.json();
collaborationNotice = 'Guest Star session started.';
await loadCollaboration();
} catch (error) {
collaborationError = error instanceof Error ? error.message : 'Collaboration session failed.';
@@ -491,6 +672,7 @@
async function endCollaboration() {
if (!collaborationSession) return;
if (!window.confirm('End the current Guest Star collaboration? Twitch will remove the active session.')) return;
collaborationBusy = true;
collaborationError = '';
try {
@@ -498,6 +680,7 @@
if (!response.ok) throw new Error('Twitch could not end the Guest Star session.');
collaborationSession = null;
collaborationInvites = [];
collaborationNotice = 'Guest Star session ended.';
} catch (error) {
collaborationError = error instanceof Error ? error.message : 'Collaboration session could not end.';
} finally {
@@ -514,6 +697,7 @@
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/invites/send?${query}`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('Twitch could not send the collaboration invite.');
collaborationGuestId = '';
collaborationNotice = 'Collaboration invite sent.';
await loadCollaboration();
} catch (error) {
collaborationError = error instanceof Error ? error.message : 'Invite could not be sent.';
@@ -524,12 +708,14 @@
async function cancelCollaborationInvite(guestId: string) {
if (!collaborationSession) return;
if (!window.confirm(`Cancel the collaboration invite for ${guestId}?`)) return;
collaborationBusy = true;
collaborationError = '';
try {
const query = `session_id=${encodeURIComponent(collaborationSession.id)}&guest_id=${encodeURIComponent(guestId)}`;
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/invites/cancel?${query}`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('Twitch could not cancel the collaboration invite.');
collaborationNotice = 'Collaboration invite cancelled.';
await loadCollaboration();
} catch (error) {
collaborationError = error instanceof Error ? error.message : 'Invite could not be cancelled.';
@@ -538,6 +724,40 @@
}
}
async function updateCollaborationSlot(action: 'assign' | 'remove', guestId: string, slotId: string) {
if (!collaborationSession || !slotId.trim()) return;
collaborationBusy = true;
collaborationError = '';
try {
const query = `session_id=${encodeURIComponent(collaborationSession.id)}&guest_id=${encodeURIComponent(guestId)}&slot_id=${encodeURIComponent(slotId.trim())}`;
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/slot/${action}?${query}`, { method: action === 'assign' ? 'POST' : 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error(`Twitch could not ${action} the Guest Star slot.`);
collaborationNotice = action === 'assign' ? 'Guest assigned to slot.' : 'Guest removed from slot.';
await loadCollaboration();
} catch (error) {
collaborationError = error instanceof Error ? error.message : 'Guest Star slot update failed.';
} finally {
collaborationBusy = false;
}
}
async function updateCollaborationSlotSettings(guestId: string, slotId: string, isLive: boolean) {
if (!collaborationSession || !slotId.trim()) return;
collaborationBusy = true;
collaborationError = '';
try {
const query = `session_id=${encodeURIComponent(collaborationSession.id)}&guest_id=${encodeURIComponent(guestId)}&slot_id=${encodeURIComponent(slotId.trim())}&is_live=${isLive}`;
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/slot/settings?${query}`, { method: 'PATCH', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('Twitch could not update the Guest Star slot settings.');
collaborationNotice = 'Guest Star slot settings updated.';
await loadCollaboration();
} catch (error) {
collaborationError = error instanceof Error ? error.message : 'Guest Star slot settings update failed.';
} finally {
collaborationBusy = false;
}
}
async function markNotificationSeen(notificationId: string) {
const response = await fetch(`${serverAddress}/api/v1/admin/notifications/${notificationId}/seen`, {
method: 'POST',
@@ -755,7 +975,36 @@
commandFormCooldownScope = 'global';
commandFormResponse = '';
commandFormErrorResponse = '';
commandVariableSuggestions = [];
commandSimulationError = '';
commandSimulationResult = null;
commandFormEnabled = true;
commandWebMethod = 'GET';
commandWebUrl = '';
commandWebBody = '';
commandResponseFields = '';
commandArgumentDefinitions = '[]';
commandRules = [];
commandRuleUserId = '';
commandRuleResponse = '';
commandRuleError = '';
}
const commandVariables = ['user_id', 'user_name', 'message', 'args', ...Array.from({ length: 16 }, (_, index) => `arg_${index + 1}`)];
function updateCommandVariableSuggestions(value: string) {
const match = value.match(/\$\{([A-Za-z0-9_]*)$/);
commandVariableSuggestions = match ? commandVariables.filter((variable) => variable.startsWith(match[1])) : [];
}
function insertCommandVariable(variable: string) {
const match = commandFormResponse.match(/\$\{[A-Za-z0-9_]*$/);
commandFormResponse = match ? `${commandFormResponse.slice(0, -match[0].length)}\${${variable}} ` : `${commandFormResponse}\${${variable}} `;
commandVariableSuggestions = [];
}
function formatExtractedVariables(values: Record<string, string>) {
return Object.entries(values).map(([key, value]) => `${key}=${value}`).join(' · ');
}
function editCommand(command: (typeof botCommands)[number]) {
@@ -770,6 +1019,59 @@
commandFormResponse = command.response_template;
commandFormErrorResponse = command.error_template ?? '';
commandFormEnabled = command.enabled;
commandWebMethod = command.web_request?.method ?? 'GET';
commandWebUrl = command.web_request?.url ?? '';
commandWebBody = command.web_request?.body ?? '';
commandResponseFields = (command.response_fields ?? []).map(([name, path]) => `${name}=${path}`).join('\n');
commandArgumentDefinitions = JSON.stringify(command.argument_definitions ?? [], null, 2);
void loadCommandRules(command.id);
}
async function loadCommandRules(commandId: string) {
commandRuleError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandId)}/user-rules`, { headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('User rules could not be loaded.');
commandRules = await response.json();
} catch (error) {
commandRuleError = error instanceof Error ? error.message : 'User rules could not be loaded.';
}
}
async function saveCommandRule() {
if (!commandFormId || !/^\d{1,64}$/.test(commandRuleUserId)) {
commandRuleError = 'Enter a valid Twitch user ID.';
return;
}
commandRuleBusy = true;
commandRuleError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandFormId)}/user-rules`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ user_id: commandRuleUserId, response_template: commandRuleResponse || null }) });
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || 'The user rule could not be saved.');
commandRuleUserId = '';
commandRuleResponse = '';
await loadCommandRules(commandFormId);
} catch (error) {
commandRuleError = error instanceof Error ? error.message : 'The user rule could not be saved.';
} finally {
commandRuleBusy = false;
}
}
async function deleteCommandRule(userId: string) {
if (!commandFormId) return;
commandRuleBusy = true;
commandRuleError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandFormId)}/user-rules/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('The user rule could not be deleted.');
await loadCommandRules(commandFormId);
} catch (error) {
commandRuleError = error instanceof Error ? error.message : 'The user rule could not be deleted.';
} finally {
commandRuleBusy = false;
}
}
async function saveCommand() {
@@ -777,11 +1079,21 @@
botCommandError = '';
const aliases = commandFormAliases.split(',').map((value) => value.trim()).filter(Boolean);
const tags = commandFormTags.split(',').map((value) => value.trim()).filter(Boolean);
const responseFields = commandResponseFields.split('\n').map((value) => value.trim()).filter(Boolean).map((value) => value.split('=').map((part) => part.trim())).filter((parts): parts is [string, string] => parts.length === 2 && Boolean(parts[0]) && Boolean(parts[1]));
let argumentDefinitions: unknown[] = [];
try {
argumentDefinitions = JSON.parse(commandArgumentDefinitions);
if (!Array.isArray(argumentDefinitions)) throw new Error('Argument definitions must be a JSON array.');
} catch (error) {
botCommandError = error instanceof Error ? error.message : 'Argument definitions must be valid JSON.';
commandFormBusy = false;
return;
}
try {
const response = await fetch(commandFormId ? `${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandFormId)}` : `${serverAddress}/api/v1/bot/commands`, {
method: commandFormId ? 'PATCH' : 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
body: JSON.stringify({ name: commandFormName, description: commandFormDescription, permission_level: commandFormPermission, aliases, tags, cooldown_seconds: Number(commandFormCooldown), cooldown_scope: commandFormCooldownScope, response_template: commandFormResponse, error_template: commandFormErrorResponse || null, enabled: commandFormEnabled }),
body: JSON.stringify({ name: commandFormName, description: commandFormDescription, permission_level: commandFormPermission, aliases, tags, cooldown_seconds: Number(commandFormCooldown), cooldown_scope: commandFormCooldownScope, response_template: commandFormResponse, error_template: commandFormErrorResponse || null, enabled: commandFormEnabled, web_request: commandWebUrl ? { method: commandWebMethod, url: commandWebUrl, body: commandWebBody || null } : null, response_fields: responseFields, argument_definitions: argumentDefinitions }),
});
if (!response.ok) throw new Error('The command could not be saved. Check its name, aliases, and fields.');
resetCommandForm();
@@ -794,6 +1106,42 @@
}
}
async function simulateCommand(useRealRequest = false) {
commandSimulationBusy = true;
commandSimulationError = '';
commandSimulationResult = null;
let sampleResponse: unknown = undefined;
if (!useRealRequest) {
try {
sampleResponse = JSON.parse(commandSimulationSampleResponse);
} catch {
commandSimulationBusy = false;
commandSimulationError = 'Sample response must contain valid JSON.';
return;
}
}
try {
const response = await fetch(`${serverAddress}/api/v1/bot/commands/simulate`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` },
body: JSON.stringify({
template: commandFormResponse,
variables: { user_name: 'PreviewUser', user_id: 'preview', message: '!preview', args: 'sample' },
sample_response: sampleResponse,
use_real_request: useRealRequest,
response_fields: [],
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || 'The command simulation failed.');
commandSimulationResult = result;
} catch (error) {
commandSimulationError = error instanceof Error ? error.message : 'The command simulation failed.';
} finally {
commandSimulationBusy = false;
}
}
async function deleteCommand(id: string) {
if (!window.confirm('Delete this command permanently?')) return;
botCommandError = '';
@@ -853,6 +1201,126 @@
}
}
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 bindPluginSecret(plugin: (typeof plugins)[number]) {
const secretName = window.prompt('Enter the existing secret name to approve for this plugin:');
if (!secretName) return;
pluginBusy = true;
pluginError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}/secrets`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ secret_name: secretName, approved: true }) });
if (!response.ok) throw new Error('The plugin secret binding could not be approved.');
pluginError = `Approved ${secretName} for ${plugin.name}. Enable the plugin when all requirements are satisfied.`;
} catch (error) {
pluginError = error instanceof Error ? error.message : 'The plugin secret binding could not be approved.';
} finally {
pluginBusy = false;
}
}
async function runPlugin(plugin: (typeof plugins)[number]) {
pluginBusy = true;
pluginError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}/run`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('The plugin execution failed or the plugin is disabled.');
pluginError = `${plugin.name} executed successfully.`;
} catch (error) {
pluginError = error instanceof Error ? error.message : 'The plugin execution failed.';
} 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 rollbackPlugin(plugin: (typeof plugins)[number]) {
if (!window.confirm(`Roll back ${plugin.name} to its previous package?`)) return;
pluginBusy = true;
pluginError = '';
try {
const response = await fetch(`${serverAddress}/api/v1/admin/plugins/${encodeURIComponent(plugin.plugin_id)}/rollback`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) throw new Error('Plugin rollback is unavailable for this installation.');
await loadPlugins();
} catch (error) {
pluginError = error instanceof Error ? error.message : 'Plugin rollback failed.';
} finally {
pluginBusy = false;
}
}
async function loadOverlays() {
try {
const response = await fetch(`${serverAddress}/api/v1/overlays`, { headers: { authorization: `Bearer ${sessionToken}` } });
@@ -875,13 +1343,16 @@
overlayJavascript = overlay.draft_javascript;
overlayEnabled = overlay.enabled;
overlayDisabledDesign = overlay.disabled_design;
overlayAlertGroupWindowMs = overlay.alert_group_window_ms ?? 200;
overlayAlertGroupSize = overlay.alert_group_size ?? 5;
overlayAlertDisplayMs = overlay.alert_display_ms ?? 4000;
overlaySavedSignature = overlayDraftSignature();
overlayAutosaveAttemptedSignature = overlaySavedSignature;
restoreOverlayDraft(overlay.id);
}
function overlayDraftSignature() {
return JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign });
return JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs });
}
function overlayVariableWarnings() {
@@ -899,6 +1370,24 @@
return warnings;
}
function overlayLineNumbers(source: string) {
return Array.from({ length: Math.max(1, source.split('\n').length) }, (_, index) => index + 1);
}
function overlayCompletionOptions() {
if (overlayEditorTab === 'HTML') return ['<div class="alert">${text}</div>', '<span>${text}</span>'];
if (overlayEditorTab === 'CSS') return ['.alert { color: var(--ed-cyan); }', 'display: flex; align-items: center;'];
return ["document.querySelector('.alert')", "window.addEventListener('trixie-alert', (event) => {})"];
}
function insertOverlayCompletion() {
if (!overlayCompletion) return;
if (overlayEditorTab === 'HTML') overlayHtml += overlayCompletion;
if (overlayEditorTab === 'CSS') overlayCss += overlayCompletion;
if (overlayEditorTab === 'JAVASCRIPT') overlayJavascript += overlayCompletion;
overlayCompletion = '';
}
function overlayDraftCacheKey(id: string) {
return `trixie.overlay.draft.${id}`;
}
@@ -906,7 +1395,7 @@
function cacheOverlayDraft() {
if (!selectedOverlayId) return;
try {
localStorage.setItem(overlayDraftCacheKey(selectedOverlayId), JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign }));
localStorage.setItem(overlayDraftCacheKey(selectedOverlayId), JSON.stringify({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs }));
} catch {
// Local storage is an optional recovery aid; server persistence remains authoritative.
}
@@ -914,7 +1403,7 @@
function restoreOverlayDraft(id: string) {
try {
const cached = JSON.parse(localStorage.getItem(overlayDraftCacheKey(id)) || 'null') as Partial<{ overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string }> | null;
const cached = JSON.parse(localStorage.getItem(overlayDraftCacheKey(id)) || 'null') as Partial<{ overlayName: string; overlaySlug: string; overlayHtml: string; overlayCss: string; overlayJavascript: string; overlayEnabled: boolean; overlayDisabledDesign: string; overlayAlertGroupWindowMs: number; overlayAlertGroupSize: number; overlayAlertDisplayMs: number }> | null;
if (!cached || typeof cached.overlayName !== 'string') return;
const cachedSignature = JSON.stringify(cached);
if (cachedSignature !== overlayDraftSignature()) pendingOverlayDraft = {
@@ -925,6 +1414,9 @@
overlayJavascript: cached.overlayJavascript ?? overlayJavascript,
overlayEnabled: cached.overlayEnabled ?? overlayEnabled,
overlayDisabledDesign: cached.overlayDisabledDesign ?? overlayDisabledDesign,
overlayAlertGroupWindowMs: cached.overlayAlertGroupWindowMs ?? overlayAlertGroupWindowMs,
overlayAlertGroupSize: cached.overlayAlertGroupSize ?? overlayAlertGroupSize,
overlayAlertDisplayMs: cached.overlayAlertDisplayMs ?? overlayAlertDisplayMs,
};
} catch {
// Ignore malformed or unavailable local cache entries.
@@ -940,16 +1432,19 @@
selectedOverlayId = '';
overlayName = 'New Overlay';
overlaySlug = `overlay-${Date.now()}`;
overlayHtml = '<div class="alert">Hello ${username}</div>';
overlayCss = '.alert { color: white; font: 32px sans-serif; }';
overlayJavascript = '';
overlayHtml = '<div id="trixie-follow-alert" class="alert" role="status" aria-live="polite"><span id="trixie-follow-name"></span> followed!</div>';
overlayCss = '.alert { opacity: 0; transform: translateY(12px); transition: opacity 180ms ease, transform 180ms ease; color: white; font: 700 32px sans-serif; padding: 18px 24px; border-radius: 8px; background: rgba(25, 18, 40, 0.92); } .alert.visible { opacity: 1; transform: translateY(0); }';
overlayJavascript = "const alertBox = document.getElementById('trixie-follow-alert'); const alertName = document.getElementById('trixie-follow-name'); window.addEventListener('trixie-alert', (event) => { const detail = event.detail || {}; const payload = detail.payload || {}; if (detail.event_type !== 'follow') return; alertName.textContent = String(payload.user_name || payload.user_login || 'Someone'); alertBox.classList.add('visible'); clearTimeout(window.trixieAlertTimer); window.trixieAlertTimer = setTimeout(() => alertBox.classList.remove('visible'), 6000); });";
overlayEnabled = true;
overlayDisabledDesign = 'transparent';
overlayAlertGroupWindowMs = 200;
overlayAlertGroupSize = 5;
overlayAlertDisplayMs = 4000;
}
function restorePendingOverlayDraft() {
if (!pendingOverlayDraft) return;
({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign } = pendingOverlayDraft);
({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs } = pendingOverlayDraft);
pendingOverlayDraft = null;
overlaySavedSignature = '';
overlayAutosaveAttemptedSignature = '';
@@ -965,7 +1460,7 @@
overlayBusy = true;
overlayError = '';
try {
const body = JSON.stringify({ name: overlayName, slug: overlaySlug, html: overlayHtml, css: overlayCss, javascript: overlayJavascript, disabled_design: overlayDisabledDesign, enabled: overlayEnabled });
const body = JSON.stringify({ name: overlayName, slug: overlaySlug, html: overlayHtml, css: overlayCss, javascript: overlayJavascript, disabled_design: overlayDisabledDesign, enabled: overlayEnabled, alert_group_window_ms: Number(overlayAlertGroupWindowMs), alert_group_size: Number(overlayAlertGroupSize), alert_display_ms: Number(overlayAlertDisplayMs) });
const response = await fetch(selectedOverlayId ? `${serverAddress}/api/v1/overlays/${selectedOverlayId}` : `${serverAddress}/api/v1/overlays`, { method: selectedOverlayId ? 'PATCH' : 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, body });
if (!response.ok) throw new Error('The overlay draft could not be saved.');
const saved = await response.json();
@@ -1065,6 +1560,7 @@
await loadNativeSettings();
void checkServer();
void loadPublicCommands();
void restoreNativeSession();
});
</script>
@@ -1074,7 +1570,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', '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>
@@ -1148,20 +1644,53 @@
<div class="panel-header"><span>FIRST-RUN SETUP</span></div>
<div class="auth-gate-body credential-form">
<strong>Replace the generated administrator credentials</strong>
<label for="setup-current-password" class="micro">CURRENT PASSWORD</label><input id="setup-current-password" class="control" type="password" bind:value={password} autocomplete="current-password" />
<label for="new-username" class="micro">NEW USERNAME</label><input id="new-username" class="control" bind:value={newUsername} autocomplete="username" />
<label for="new-password" class="micro">NEW PASSWORD</label><input id="new-password" class="control" type="password" bind:value={newPassword} autocomplete="new-password" />
<button class="primary" onclick={replaceCredentials} disabled={authBusy}>SAVE AND CONTINUE</button>
{#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" onclick={() => rollbackPlugin(plugin)} disabled={pluginBusy}>ROLLBACK</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>
<section class="panel plugin-secrets-panel"><div class="panel-header"><span>PLUGIN SECRET APPROVAL</span></div><div class="settings-body"><p class="settings-note">Plugins cannot read secrets until an administrator explicitly approves a binding. The secret value is never shown here.</p>{#if plugins.some((plugin) => plugin.manifest.required_secrets?.length)}<div class="bot-command-list">{#each plugins.filter((plugin) => plugin.manifest.required_secrets?.length) as plugin}<article class="bot-command-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">Required: {plugin.manifest.required_secrets?.join(' · ')}</span></div><button class="secondary-action" onclick={() => bindPluginSecret(plugin)} disabled={pluginBusy}>APPROVE SECRET</button></article>{/each}</div>{:else}<div class="empty-row">Installed plugins have no secret requirements.</div>{/if}</div></section>
<section class="panel plugin-runtime-panel"><div class="panel-header"><span>PLUGIN RUNTIME</span></div><div class="settings-body"><p class="settings-note">Run only enabled packages. The server applies the manifest permissions and WebAssembly resource limits.</p>{#if plugins.length}{#each plugins as plugin}<div class="bot-command-row"><div><strong>{plugin.name}</strong><span class="bot-command-description">{plugin.enabled ? 'READY' : 'DISABLED'}</span></div><button class="primary" onclick={() => runPlugin(plugin)} disabled={pluginBusy || !plugin.enabled}>RUN</button></div>{/each}{:else}<div class="empty-row">No installed plugins.</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} maxlength="2000" placeholder="Hello ${user_name}"></textarea><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-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><div class="command-simulation panel"><div class="micro">SIMULATION</div><p class="bot-secret-note">Preview with literal sample data. Real requests are opt-in and use the same server-side safety checks.</p><textarea class="control command-template" bind:value={commandSimulationSampleResponse} aria-label="Sample JSON response" placeholder="status ok"></textarea><div class="command-simulation-actions"><button class="secondary" type="button" onclick={() => simulateCommand(false)} disabled={commandSimulationBusy || !commandFormResponse}>SAMPLE REQUEST</button><button class="secondary" type="button" onclick={() => simulateCommand(true)} disabled={commandSimulationBusy || !commandFormResponse}>REAL REQUEST</button></div>{#if commandSimulationError}<div class="auth-error">{commandSimulationError}</div>{/if}{#if commandSimulationResult}<div class="command-simulation-result"><strong>{commandSimulationResult.rendered ?? commandSimulationResult.error ?? 'Simulation completed.'}</strong>{#if commandSimulationResult.extracted_variables && Object.keys(commandSimulationResult.extracted_variables).length}<span>Extracted: {formatExtractedVariables(commandSimulationResult.extracted_variables)}</span>{/if}</div>{/if}</div><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>
<section class="panel command-management-panel"><div class="panel-header"><span>COMMAND MANAGEMENT</span></div><div class="settings-body">{#each botCommands as command}<div class="bot-command-row"><div><strong>!{command.name}</strong><span class="bot-command-description">{command.enabled ? 'ENABLED' : 'DISABLED'} · {command.permission_level.toUpperCase()}</span></div><div class="bot-command-meta"><button class="secondary-action" onclick={() => editCommand(command)}>EDIT</button><button class="secondary-action danger" onclick={() => deleteCommand(command.id)}>DELETE</button></div></div>{/each}{#if !botCommands.length}<div class="empty-row">No commands are configured yet.</div>{/if}</div></section>
<section class="panel command-web-action-panel"><div class="panel-header"><span>WEB ACTION CONFIGURATION</span></div><div class="settings-body command-form"><p class="bot-secret-note">Optional HTTPS request and response variables. The server validates the URL and never executes template values as code.</p><div class="command-form-grid"><label class="micro" for="command-web-method">METHOD</label><select id="command-web-method" class="control" bind:value={commandWebMethod}><option value="GET">GET</option><option value="POST">POST</option></select><label class="micro" for="command-web-url">HTTPS URL</label><input id="command-web-url" class="control" bind:value={commandWebUrl} maxlength="2048" placeholder="https://example.com/status" /><label class="micro" for="command-web-body">JSON BODY (OPTIONAL)</label><textarea id="command-web-body" class="control command-template" bind:value={commandWebBody} maxlength="65536" placeholder="JSON body"></textarea><label class="micro" for="command-response-fields">RESPONSE VARIABLES (one name=path per line)</label><textarea id="command-response-fields" class="control command-template" bind:value={commandResponseFields} maxlength="4096" placeholder="status=$.status"></textarea></div></div></section>
<section class="panel command-rules-panel"><div class="panel-header"><span>USER-SPECIFIC RULES</span><span class="micro">{commandFormId ? `COMMAND ${commandFormName}` : 'SELECT A COMMAND'}</span></div><div class="settings-body command-form"><p class="bot-secret-note">A command with rules is limited to the named Twitch users. Add an optional response for a personalized variant.</p><div class="chat-send-row"><input class="control" bind:value={commandRuleUserId} inputmode="numeric" maxlength="64" placeholder="Twitch user ID" disabled={!commandFormId} /><input class="control" bind:value={commandRuleResponse} maxlength="2000" placeholder="Optional personalized response" disabled={!commandFormId} /><button class="primary" onclick={saveCommandRule} disabled={commandRuleBusy || !commandFormId}>ADD / UPDATE</button></div>{#if commandRuleError}<div class="auth-error">{commandRuleError}</div>{/if}{#if commandRules.length}<div class="bot-command-list">{#each commandRules as rule}<div class="bot-command-row"><div><strong>{rule.user_id}</strong><span class="bot-command-description">{rule.response_template || 'Uses the command response template'}</span></div><button class="secondary-action danger" onclick={() => deleteCommandRule(rule.user_id)} disabled={commandRuleBusy}>DELETE</button></div>{/each}</div>{:else}<div class="empty-row">No user-specific rules configured.</div>{/if}</div></section>
<section class="panel command-arguments-panel"><div class="panel-header"><span>TYPED ARGUMENTS</span><span class="micro">JSON DEFINITIONS</span></div><div class="settings-body command-form"><p class="bot-secret-note">Declare text, number, choice, username, or duration arguments. Values are validated as data and never executed.</p><textarea class="control command-template" bind:value={commandArgumentDefinitions} maxlength="12000" placeholder="[]" aria-label="Typed argument definitions"></textarea><div class="micro">Example: a JSON array containing count, number, required, minimum, and maximum fields.</div></div></section>
{:else if activeView === 'Collaboration'}
{#if collaborationSession}
<section class="collaboration-page panel">
<div class="panel-header"><span>ACTIVE COLLABORATION</span><span class="badge">{collaborationSession.guests.length} GUESTS</span></div>
<div class="collaboration-active-grid">
<div class="collaboration-call-placeholder"><div class="offline-mark">GUEST STAR CALL</div><div class="offline-subtitle">The call is controlled by Twitch</div><p class="settings-note">Twitch does not expose the call media as an embeddable Trixie API. Use Twitch for the live call controls while Trixie keeps the session, slots, invites, and shared-chat state synchronized.</p><button class="secondary" onclick={() => window.open('https://www.twitch.tv', '_blank', 'noopener,noreferrer')}>OPEN ON TWITCH</button></div>
<div class="collaboration-state-panel"><div class="micro">PARTICIPANTS</div>{#each collaborationSession.guests as guest}<div class="collaboration-invite"><span>{guest.user_display_name || guest.user_id}</span><span class="micro">{guest.is_live ? 'LIVE' : 'WAITING'}{guest.slot_id ? ` · SLOT ${guest.slot_id}` : ''}</span></div>{:else}<div class="empty-row">No participants have joined.</div>{/each}<div class="micro collaboration-subheading">SHARED CHAT</div>{#if sharedChatSession}<div class="collaboration-status">ACTIVE · {sharedChatSession.participants.length} CHANNELS</div>{:else}<div class="empty-row">Shared Chat is not active.</div>{/if}{#if collaborationSettings}<div class="micro collaboration-subheading">CHANNEL SETTINGS</div><div class="collaboration-status">{collaborationSettings.slot_count} SLOTS · {collaborationSettings.group_layout}</div><div class="micro">AUDIO {collaborationSettings.is_browser_source_audio_enabled ? 'ENABLED' : 'DISABLED'} · MODERATOR LIVE CONTROL {collaborationSettings.is_moderator_send_live_enabled ? 'ENABLED' : 'DISABLED'}</div>{/if}<button class="danger control-button" onclick={endCollaboration} disabled={collaborationBusy}>END SESSION</button></div>
</div>
{#if collaborationNotice}<div class="collaboration-notice">{collaborationNotice}</div>{/if}{#if collaborationError}<div class="auth-error">{collaborationError}</div>{/if}
</section>
<section class="panel collaboration-slot-panel"><div class="panel-header"><span>PARTICIPANT CONTROLS</span><span class="micro">TWITCH-AUTHORITATIVE</span></div><div class="settings-body"><p class="settings-note">Assign, remove, or toggle live state for ready participants. Twitch remains authoritative for slot availability.</p>{#each collaborationSession.guests as guest}<div class="collaboration-invite"><span><strong>{guest.user_display_name || guest.user_id}</strong><span class="micro">{guest.slot_id ? ` SLOT ${guest.slot_id}` : ' UNSLOTTED'}</span></span><div class="chat-send-row"><input class="control" value={guest.slot_id || ''} maxlength="8" inputmode="numeric" placeholder="Slot" aria-label={`Slot for ${guest.user_display_name || guest.user_id}`} /><button class="secondary-action" onclick={(event) => { const input = event.currentTarget.parentElement?.querySelector('input') as HTMLInputElement | null; updateCollaborationSlot('assign', guest.user_id, input?.value || ''); }} disabled={collaborationBusy}>ASSIGN</button>{#if guest.slot_id}<button class="secondary-action" onclick={() => updateCollaborationSlot('remove', guest.user_id, guest.slot_id || '')} disabled={collaborationBusy}>REMOVE</button><button class="secondary-action" onclick={() => updateCollaborationSlotSettings(guest.user_id, guest.slot_id || '', !guest.is_live)} disabled={collaborationBusy}>{guest.is_live ? 'SET WAITING' : 'SET LIVE'}</button>{/if}</div></div>{/each}{#if !collaborationSession.guests.length}<div class="empty-row">No participants are ready for slot controls.</div>{/if}</div></section>
<section class="panel collaboration-invites-panel"><div class="panel-header"><span>INVITATIONS</span><span class="micro">TWITCH STATUS</span></div><div class="settings-body">{#if collaborationInvites.length}{#each collaborationInvites as invite}<div class="collaboration-invite"><span><strong>{invite.user_id}</strong><span class="micro">INVITED {new Date(invite.invited_at).toLocaleString()}</span></span><span class="bot-command-meta"><span>{invite.status.toUpperCase()}</span><button class="secondary-action" onclick={() => cancelCollaborationInvite(invite.user_id)} disabled={collaborationBusy || invite.status !== 'pending'}>CANCEL</button></span></div>{/each}{:else}<div class="empty-row">No invitations reported by Twitch.</div>{/if}</div></section>
<section class="panel collaboration-media-panel"><div class="panel-header"><span>GUEST MEDIA STATUS</span><span class="micro">TWITCH-AUTHORITATIVE</span></div><div class="settings-body">{#each collaborationSession.guests as guest}<div class="collaboration-invite"><span><strong>{guest.user_display_name || guest.user_id}</strong>{#if guest.user_login}<span class="micro"> @{guest.user_login}</span>{/if}</span><span class="micro">{guest.volume === null || guest.volume === undefined ? 'VOL —' : `VOL ${guest.volume}%`} · AUDIO {guest.audio_settings?.is_available ? 'READY' : '—'} · VIDEO {guest.video_settings?.is_available ? 'READY' : '—'}</span></div>{/each}<button class="secondary" onclick={() => window.open(streamerName ? `https://www.twitch.tv/${encodeURIComponent(streamerName)}` : 'https://www.twitch.tv', '_blank', 'noopener,noreferrer')}>OPEN STREAMER CHANNEL</button></div></section>
{:else}
<section class="collaboration-page panel">
<div class="panel-header"><span>COLLABORATION</span><span class="micro">SEARCH OR REVIEW INCOMING</span></div>
<div class="collaboration-idle-grid">
<div class="collaboration-search-panel"><div class="micro">FIND A COLLABORATOR</div><label class="micro" for="collaboration-search-tab">EXACT TWITCH LOGIN</label><div class="chat-send-row"><input id="collaboration-search-tab" class="control" value={collaboratorSearch} oninput={(event) => searchCollaborators(event.currentTarget.value)} autocomplete="off" placeholder="Search Twitch" /><button class="secondary" onclick={clearCollaboratorSearch} disabled={!collaboratorSearch}>CLEAR</button></div>{#if collaboratorSearchBusy}<div class="micro">SEARCHING TWITCH…</div>{/if}{#if collaboratorSearchError}<div class="auth-error">{collaboratorSearchError}<button class="secondary" onclick={() => searchCollaborators(collaboratorSearch)}>RETRY</button></div>{/if}{#if collaboratorResults.length}<div class="collaborator-results">{#each collaboratorResults as result}<div class="collaborator-result"><span><strong>{result.display_name}</strong><span class="micro"> @{result.login}</span></span><button class="primary" onclick={() => inviteCollaborator(result)} disabled={collaborationBusy}>INVITE</button></div>{/each}</div>{:else if collaboratorSearch && !collaboratorSearchBusy && !collaboratorSearchError}<div class="empty-row">No exact Twitch user found.</div>{/if}<div class="micro collaboration-subheading">FAVORITES &amp; RECENT</div>{#if collaborators.length}<div class="collaborator-results">{#each collaborators as collaborator}<div class="collaborator-result"><span><button class="secondary" onclick={() => toggleCollaboratorFavorite(collaborator)}>{collaborator.favorite ? '★' : '☆'}</button><strong>{collaborator.display_name}</strong></span><span class="micro">{collaborator.favorite ? 'FAVORITE' : 'RECENT'}</span></div>{/each}</div>{:else}<div class="empty-row">No saved collaborators yet.</div>{/if}</div>
<div class="collaboration-incoming-panel"><div class="micro">INCOMING INVITATIONS</div><div class="empty-state"><div class="empty-glyph">⌁</div><strong>Waiting for a collaboration invite</strong><span>Accept or decline invitations in Twitch. Trixie will reflect the authoritative status here when Twitch reports it.</span></div></div>
</div>
{#if collaborationNotice}<div class="collaboration-notice">{collaborationNotice}</div>{/if}{#if collaborationError}<div class="auth-error">{collaborationError}</div>{/if}
</section>
{/if}
{:else if activeView === 'Overlays'}
<section class="overlay-page panel"><div class="panel-header"><span>OVERLAYS</span><div class="overlay-toolbar"><button class="secondary" onclick={createOverlay}>NEW</button><button class="secondary" onclick={loadOverlays}>REFRESH</button></div></div><div class="overlay-layout"><aside class="overlay-list">{#each overlays as overlay}<button class:active={selectedOverlayId === overlay.id} class="overlay-list-item" onclick={() => selectOverlay(overlay)}><strong>{overlay.name}</strong><span>{overlay.published ? 'PUBLISHED' : 'DRAFT'}</span></button>{/each}{#if !overlays.length}<div class="empty-row">No overlays yet.</div>{/if}</aside><div class="overlay-editor">{#if selectedOverlayId || overlayName}<div class="overlay-fields"><input class="control" bind:value={overlayName} maxlength="100" placeholder="Overlay name" /><input class="control" bind:value={overlaySlug} maxlength="100" placeholder="stable-slug" /><label class="overlay-enabled"><input type="checkbox" bind:checked={overlayEnabled} /> ENABLED</label><input class="control" bind:value={overlayDisabledDesign} maxlength="10000" placeholder="Disabled design" /></div><div class="overlay-tabs"><label class="micro" for="overlay-html">HTML</label><textarea id="overlay-html" class="overlay-code" bind:value={overlayHtml} spellcheck="false"></textarea><label class="micro" for="overlay-css">CSS</label><textarea id="overlay-css" class="overlay-code" bind:value={overlayCss} spellcheck="false"></textarea><label class="micro" for="overlay-javascript">JAVASCRIPT</label><textarea id="overlay-javascript" class="overlay-code" bind:value={overlayJavascript} spellcheck="false"></textarea></div><div class="overlay-preview-label micro">LIVE PREVIEW</div><iframe class="overlay-preview" title="Overlay preview" sandbox="allow-scripts" srcdoc={`<style>${overlayCss}</style>${overlayEnabled ? overlayHtml : `<div>${overlayDisabledDesign}</div>`}\u003cscript\u003e${overlayJavascript}\u003c/script\u003e`}></iframe><div class="overlay-controls"><button class="primary" onclick={saveOverlay} disabled={overlayBusy || !overlayName || !overlaySlug}>SAVE DRAFT</button><button class="secondary" onclick={publishOverlay} disabled={overlayBusy || !selectedOverlayId}>PUBLISH</button><button class="secondary" onclick={undoOverlay} disabled={overlayBusy || !selectedOverlayId}>UNDO PUBLISHED</button><button class="secondary" onclick={copyOverlayUrl} disabled={!overlaySlug}>COPY OBS URL</button></div>{#if overlayError}<div class="auth-error">{overlayError}</div>{/if}{:else}<div class="empty-state"><strong>Select or create an overlay</strong><span>Drafts autosave when explicitly saved; publishing controls the browser source.</span></div>{/if}</div></div></section>
<section class="overlay-page panel"><div class="panel-header"><span>OVERLAYS</span><div class="overlay-toolbar"><button class="secondary" onclick={createOverlay}>NEW</button><button class="secondary" onclick={loadOverlays}>REFRESH</button></div></div><div class="overlay-layout"><aside class="overlay-list">{#each overlays as overlay}<button class:active={selectedOverlayId === overlay.id} class="overlay-list-item" onclick={() => selectOverlay(overlay)}><strong>{overlay.name}</strong><span>{overlay.published ? 'PUBLISHED' : 'DRAFT'}</span></button>{/each}{#if !overlays.length}<div class="empty-row">No overlays yet.</div>{/if}</aside><div class="overlay-editor">{#if selectedOverlayId || overlayName}<div class="overlay-fields"><input class="control" bind:value={overlayName} maxlength="100" placeholder="Overlay name" /><input class="control" bind:value={overlaySlug} maxlength="100" placeholder="stable-slug" /><label class="overlay-enabled"><input type="checkbox" bind:checked={overlayEnabled} /> ENABLED</label><input class="control" bind:value={overlayDisabledDesign} maxlength="10000" placeholder="Disabled design" /></div><div class="overlay-source-tabs" role="tablist" aria-label="Overlay source editors">{#each ['HTML', 'CSS', 'JAVASCRIPT'] as tab}<button class:active={overlayEditorTab === tab} class="tab" role="tab" aria-selected={overlayEditorTab === tab} onclick={() => (overlayEditorTab = tab as typeof overlayEditorTab)}>{tab}</button>{/each}</div>{#if overlayEditorTab === 'HTML'}<div class="overlay-code-wrap"><div class="overlay-line-numbers" aria-hidden="true">{#each overlayLineNumbers(overlayHtml) as line}<span>{line}</span>{/each}</div><textarea id="overlay-html" class="overlay-code" bind:value={overlayHtml} spellcheck="false" aria-label="Overlay HTML source"></textarea></div>{:else if overlayEditorTab === 'CSS'}<div class="overlay-code-wrap"><div class="overlay-line-numbers" aria-hidden="true">{#each overlayLineNumbers(overlayCss) as line}<span>{line}</span>{/each}</div><textarea id="overlay-css" class="overlay-code" bind:value={overlayCss} spellcheck="false" aria-label="Overlay CSS source"></textarea></div>{:else}<div class="overlay-code-wrap"><div class="overlay-line-numbers" aria-hidden="true">{#each overlayLineNumbers(overlayJavascript) as line}<span>{line}</span>{/each}</div><textarea id="overlay-javascript" class="overlay-code" bind:value={overlayJavascript} spellcheck="false" aria-label="Overlay JavaScript source"></textarea></div>{/if}<div class="overlay-preview-label micro">LIVE PREVIEW</div><iframe class="overlay-preview" title="Overlay preview" sandbox="allow-scripts" srcdoc={`<style>${overlayCss}</style>${overlayEnabled ? overlayHtml : `<div>${overlayDisabledDesign}</div>`}\u003cscript\u003e${overlayJavascript}\u003c/script\u003e`}></iframe><div class="overlay-controls"><button class="primary" onclick={saveOverlay} disabled={overlayBusy || !overlayName || !overlaySlug}>SAVE DRAFT</button><button class="secondary" onclick={publishOverlay} disabled={overlayBusy || !selectedOverlayId}>PUBLISH</button><button class="secondary" onclick={undoOverlay} disabled={overlayBusy || !selectedOverlayId}>UNDO PUBLISHED</button><button class="secondary" onclick={copyOverlayUrl} disabled={!overlaySlug}>COPY OBS URL</button></div>{#if overlayError}<div class="auth-error">{overlayError}</div>{/if}{:else}<div class="empty-state"><strong>Select or create an overlay</strong><span>Drafts autosave when explicitly saved; publishing controls the browser source.</span></div>{/if}</div></div></section>
<section class="panel overlay-alert-settings-panel"><div class="panel-header"><span>ALERT PLAYBACK</span></div><div class="settings-body"><p class="settings-note">Configure grouping and playback for the selected browser-source overlay. Changes are saved with the next draft save.</p><div class="command-form-grid"><label class="micro" for="overlay-alert-window">GROUP WINDOW (MS)</label><input id="overlay-alert-window" class="control" type="number" min="0" max="5000" bind:value={overlayAlertGroupWindowMs} /><label class="micro" for="overlay-alert-size">MAX GROUP SIZE</label><input id="overlay-alert-size" class="control" type="number" min="1" max="20" bind:value={overlayAlertGroupSize} /><label class="micro" for="overlay-alert-display">DISPLAY TIME (MS)</label><input id="overlay-alert-display" class="control" type="number" min="500" max="60000" bind:value={overlayAlertDisplayMs} /></div></div></section>
<section class="panel overlay-completion-panel"><div class="panel-header"><span>EDITOR COMPLETION</span><span class="micro">{overlayEditorTab}</span></div><div class="settings-body"><p class="settings-note">Choose a literal starter snippet for the active source tab. Snippets are inserted as text and never executed by the editor.</p><div class="chat-send-row"><select class="control" bind:value={overlayCompletion}><option value="">Choose a snippet…</option>{#each overlayCompletionOptions() as completion}<option value={completion}>{completion}</option>{/each}</select><button class="secondary" onclick={insertOverlayCompletion} disabled={!overlayCompletion}>INSERT</button></div></div></section>
{:else if activeView === 'Settings'}
<section class="settings-page panel"><div class="panel-header"><span>SERVER SETTINGS</span><button class="secondary" onclick={loadBackups}>REFRESH</button></div><div class="settings-body"><div class="micro">DATABASE BACKUPS</div><p class="settings-note">Daily backups use the server's local schedule. Restore remains a destructive recovery workflow and is not started from this list.</p><button class="primary control-button" onclick={runBackup} disabled={backupBusy}>{backupBusy ? 'RUNNING…' : 'RUN BACKUP NOW'}</button>{#if backupError}<div class="auth-error">{backupError}</div>{/if}{#if backups.length}<div class="backup-list">{#each backups as backup}<div class="backup-row"><span class="mono">{backup.filename}</span><span>{Math.round(backup.size_bytes / 1024)} KiB</span><span class="micro">{backup.modified_at ? new Date(backup.modified_at).toLocaleString() : 'UNKNOWN TIME'}</span></div>{/each}</div>{:else}<div class="empty-row">No backup archives are available.</div>{/if}</div></section>
<section class="settings-page panel"><div class="panel-header"><span>SERVER SETTINGS</span><button class="secondary" onclick={loadBackups}>REFRESH</button></div><div class="settings-body"><div class="micro">DATABASE BACKUPS</div><p class="settings-note">Daily backups use the server's local schedule. Restore replaces the current database and is permanently destructive.</p><button class="primary control-button" onclick={runBackup} disabled={backupBusy || restoreBusy}>{backupBusy ? 'RUNNING…' : 'RUN BACKUP NOW'}</button>{#if backupError}<div class="auth-error">{backupError}</div>{/if}{#if backups.length}<div class="backup-list">{#each backups as backup}<div class="backup-row"><span class="mono">{backup.filename}</span><span>{Math.round(backup.size_bytes / 1024)} KiB</span><span class="micro">{backup.modified_at ? new Date(backup.modified_at).toLocaleString() : 'UNKNOWN TIME'}</span><button class="danger" onclick={() => restoreBackup(backup.filename)} disabled={backupBusy || restoreBusy}>RESTORE</button></div>{/each}</div>{:else}<div class="empty-row">No backup archives are available.</div>{/if}</div></section>
{:else}
<div class="dashboard-grid">
<section class="panel preview-panel"><div class="panel-header"><span>STREAM PREVIEW</span><span class="panel-menu">⋮</span></div>{#if playerSrc}<iframe class="twitch-player" title={`Twitch player for ${streamerName}`} src={playerSrc} allowfullscreen></iframe>{:else}<div class="preview-stage"><div class="offline-mark">STREAM OFFLINE</div><div class="offline-subtitle">CONNECT TWITCH TO BEGIN</div></div>{/if}<div class="panel-footer"><span class="micro">VIDEO PLAYER</span><span class="badge">{playerSrc ? streamerName : 'OFFLINE'}</span></div></section>
@@ -1249,5 +1778,16 @@
</div>
</section>
{/if}
{#if authenticated && activeView === 'Settings'}
<section class="panel client-update-panel"><div class="panel-header"><span>CLIENT UPDATE</span><span class="micro">v{CLIENT_VERSION}</span></div><div class="settings-body"><p class="settings-note">Updates are manual. Check the fixed stable Gitea release source, review the release, then download and replace the AppImage yourself.</p><button class="secondary" onclick={checkClientUpdate} disabled={clientUpdateBusy}>{clientUpdateBusy ? 'CHECKING…' : 'CHECK FOR UPDATE'}</button>{#if clientUpdateStatus}<span class="settings-note">{clientUpdateStatus}</span>{/if}{#if clientUpdateUrl}<button class="secondary" onclick={() => window.open(clientUpdateUrl, '_blank', 'noopener,noreferrer')}>OPEN RELEASE PAGE</button>{/if}</div></section>
{/if}
</main>
{#if authenticated && sharedChatSession}
<section class="shared-chat-status-control" aria-label="Shared Chat details">
<button class="secondary shared-chat-status-button" onclick={() => (showSharedChatDetails = !showSharedChatDetails)} aria-expanded={showSharedChatDetails}>◈ SHARED CHAT · {sharedChatSession.participants.length}</button>
{#if showSharedChatDetails}
<div class="shared-chat-popover panel"><div class="panel-header"><span>SHARED CHAT</span><button class="secondary" onclick={() => (showSharedChatDetails = false)}>CLOSE</button></div><div class="settings-body"><div class="micro">HOST</div><div>{sharedChatSession.host_broadcaster_id}</div><div class="micro">PARTICIPATING CHANNELS</div>{#each sharedChatSession.participants as participant}<div class="collaboration-status">{participant.broadcaster_id}</div>{/each}<p class="settings-note">Chat remains in the main chat panel. This popover only shows Twitch-authoritative collaboration details.</p></div></div>
{/if}
</section>
{/if}
</div>
+14
View File
@@ -0,0 +1,14 @@
/** Classify authoritative rejection separately from temporary server failures.
* @param {number} status HTTP response status.
*/
export function sessionResponseState(status) {
if (status === 401 || status === 403) return 'revoked';
return status >= 200 && status < 300 ? 'valid' : 'unavailable';
}
/** Milliseconds before each retry; attempt zero is the immediate first retry.
* @param {number} attempt Zero-based retry index.
*/
export function sessionRetryDelay(attempt) {
return [0, 3000, 10000, 15000][attempt] ?? 30000;
}
+17
View File
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { sessionResponseState, sessionRetryDelay } from './session-recovery.js';
test('only explicit authentication rejection invalidates a saved session', () => {
for (const status of [401, 403]) assert.equal(sessionResponseState(status), 'revoked');
for (const status of [404, 429, 500, 502, 503])
assert.equal(sessionResponseState(status), 'unavailable');
assert.equal(sessionResponseState(200), 'valid');
});
test('saved session retries follow the shared recovery schedule', () => {
assert.deepEqual(
Array.from({ length: 7 }, (_, attempt) => sessionRetryDelay(attempt)),
[0, 3000, 10000, 15000, 30000, 30000, 30000],
);
});
+181 -26
View File
@@ -1,26 +1,3 @@
@font-face {
font-family: 'Rajdhani';
src: url('/fonts/Rajdhani-Regular.ttf') format('truetype');
font-weight: 400;
}
@font-face {
font-family: 'Rajdhani';
src: url('/fonts/Rajdhani-SemiBold.ttf') format('truetype');
font-weight: 600;
}
@font-face {
font-family: 'Rajdhani';
src: url('/fonts/Rajdhani-Bold.ttf') format('truetype');
font-weight: 700;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Variable.ttf') format('truetype');
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-Variable.ttf') format('truetype');
}
:root {
--ed-accent: #00b3ff;
--ed-accent2: #ff7f00;
@@ -34,9 +11,9 @@
--ed-danger: #ff5436;
--ed-info: #00b3ff;
--ed-exo: #a3ff5e;
--font-display: 'Rajdhani', 'Inter', sans-serif;
--font-data: 'JetBrains Mono', monospace;
--font-body: 'Inter', sans-serif;
--font-display: 'Rajdhani', 'Inter', system-ui, sans-serif;
--font-data: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace;
--font-body: 'Inter', system-ui, sans-serif;
--panel-radius: 2px;
--ui-line: color-mix(in srgb, var(--ed-accent) 28%, var(--ed-edge));
--ui-line-soft: color-mix(in srgb, var(--ed-accent) 16%, var(--ed-edge));
@@ -416,6 +393,26 @@ label {
color: var(--ed-warn);
font: 10px/1.4 var(--font-data);
}
.collaboration-notice {
margin-top: 8px;
color: var(--ed-good);
font: 10px/1.4 var(--font-data);
}
.shared-chat-status-control {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 20;
}
.shared-chat-status-button {
border-color: var(--ed-cyan);
}
.shared-chat-popover {
position: absolute;
right: 0;
bottom: 42px;
width: min(340px, calc(100vw - 32px));
}
.auth-gate {
min-height: 300px;
}
@@ -555,6 +552,28 @@ label {
color: var(--ed-dim);
font-size: 11px;
}
.command-simulation {
margin: 16px 0;
padding: 12px;
border: 1px solid var(--ui-divider);
background: var(--ui-panel);
}
.command-simulation-actions {
display: flex;
gap: 7px;
margin-top: 8px;
}
.command-simulation-result {
display: grid;
gap: 5px;
margin-top: 10px;
padding: 9px;
border-left: 2px solid var(--ed-cyan);
background: var(--ui-input);
color: var(--ed-text);
font: 11px/1.4 var(--font-data);
overflow-wrap: anywhere;
}
.secret-form {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
@@ -620,9 +639,35 @@ label {
display: grid;
gap: 7px;
}
.overlay-source-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--ui-divider);
}
.overlay-code-wrap {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
min-height: 190px;
border: 1px solid var(--ui-line);
background: var(--ui-surface-low);
}
.overlay-line-numbers {
display: grid;
align-content: start;
justify-items: end;
gap: 0;
padding: 9px 8px;
color: var(--ed-dim);
background: var(--ui-surface-hi);
font: 12px/1.4 var(--font-data);
user-select: none;
}
.overlay-code {
min-height: 110px;
resize: vertical;
width: 100%;
min-width: 0;
border: 0;
color: var(--ed-dim);
background: var(--ui-surface-low);
font: 12px/1.4 var(--font-data);
@@ -961,3 +1006,113 @@ label {
font-family: var(--font-mono);
font-size: 0.8rem;
}
.collaboration-page {
min-height: 0;
}
.collaboration-idle-grid,
.collaboration-active-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
gap: 1px;
background: var(--ui-divider);
}
.collaboration-search-panel,
.collaboration-incoming-panel,
.collaboration-call-placeholder,
.collaboration-state-panel {
min-height: 360px;
padding: 18px;
background: var(--ui-surface);
}
.collaboration-call-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.collaboration-call-placeholder .settings-note {
max-width: 560px;
}
.collaboration-state-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.collaboration-subheading {
margin-top: 18px;
}
.command-variable-picker {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px;
border: 1px solid var(--ui-line-focus);
background: var(--ui-surface-hi);
}
.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;
}
@media (max-width: 900px) {
.collaboration-idle-grid,
.collaboration-active-grid {
grid-template-columns: 1fr;
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,