Compare commits

...
42 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
ReaperOfVeriod 9167e04e77 Wire Tauri AppImage to native keyring
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s
2026-10-25 05:47:28 +01:00
29 changed files with 12064 additions and 64 deletions
+2
View File
@@ -16,7 +16,9 @@ jobs:
- run: test -f README.md && test -f AGENTS.md && test -f .prettierrc.json - run: test -f README.md && test -f AGENTS.md && test -f .prettierrc.json
- run: npm ci - run: npm ci
- run: npm run format:check - run: npm run format:check
- run: npm run lint
- run: npm run check - run: npm run check
- run: npm test
- run: npm run build - run: npm run build
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
- working-directory: native - working-directory: native
+2 -1
View File
@@ -14,4 +14,5 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: npm 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
+1
View File
@@ -1,5 +1,6 @@
node_modules/ node_modules/
native/target/ native/target/
src-tauri/target/
dist/ dist/
build/ build/
coverage/ coverage/
+6
View File
@@ -1,3 +1,9 @@
# Contributing # Contributing
Run formatting, linting, type checks, tests, and the AppImage build before submission. Use deterministic mocked server/API fixtures. 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 interval until the server reconnects. Manual connection checks do not create
overlapping retry loops. 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. 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 sha2::{Digest, Sha256};
use std::{ use std::{
fs, io, fs, io,
io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::{Command, Stdio},
}; };
pub const KEYRING_SERVICE: &str = "trixie.server.session"; 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")); return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty session"));
} }
let description = key_description(settings); 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>> { pub fn load_session(settings: &Settings) -> io::Result<Option<String>> {
@@ -117,6 +145,23 @@ pub fn clear_session(settings: &Settings) -> io::Result<()> {
mod tests { mod tests {
use super::*; 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 { fn settings() -> Settings {
Settings { Settings {
server_address: "https://trixie.example".into(), server_address: "https://trixie.example".into(),
+231
View File
@@ -7,8 +7,12 @@
"": { "": {
"name": "trixy-linux-client", "name": "trixy-linux-client",
"version": "0.1.0-dev", "version": "0.1.0-dev",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2.0.0",
"@tsconfig/svelte": "^5.0.0", "@tsconfig/svelte": "^5.0.0",
"prettier": "^3.5.0", "prettier": "^3.5.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
@@ -936,6 +940,233 @@
"vite": "^6.0.0" "vite": "^6.0.0"
} }
}, },
"node_modules/@tauri-apps/api": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.4.tgz",
"integrity": "sha512-pVmfis+4zT8H0Qhp3xPZx9Ju7kpzvA6rr/PSJPyuwfvucTQ8sVt61NYU9KMkK703UFlYadFmjKA4HhybOtN0gg==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.4.tgz",
"integrity": "sha512-JI9bHfudSRJikGOrjbXEm6RjzzvmKuLc+JixB1NE8hNzQkNlCCIC3pTES3uypkR9lxHrsbxzcnSwhI2IxBQphw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.12.4",
"@tauri-apps/cli-darwin-x64": "2.12.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.12.4",
"@tauri-apps/cli-linux-arm64-musl": "2.12.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.4",
"@tauri-apps/cli-linux-x64-gnu": "2.12.4",
"@tauri-apps/cli-linux-x64-musl": "2.12.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.12.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.12.4",
"@tauri-apps/cli-win32-x64-msvc": "2.12.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.4.tgz",
"integrity": "sha512-OxqKSckIkoJ6vX2AjifIJpTasFC0Hbdoo7gEiCmAjauABO3U8m2MRsLWe+QTiBwDx1mIrvgA+rvI7tvDnbnHVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.4.tgz",
"integrity": "sha512-fz76/77wMMJOaaUN6n3CybRG0P/MTEjjqYk81mGPyhi9m6Ue5T+LWBnJP/AkPooNatBPI2vtulGqomyMJ44lCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.4.tgz",
"integrity": "sha512-K0w8Bof6Lt8QKLAYhRy0yy36ZH5iA/35VuSOvnxyjvCUZ+A3JKsUTlALviH/sVgAlisaPzzmeJSH8XOn4Q2HBA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.4.tgz",
"integrity": "sha512-j5rOnjmRUwWrm1oosf0zBYl4D+fqydgUROFCk0hvKZxvbTT8SOERes0QB7rBV/XWNy+kM6pCKzb3oSm2sRglzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.4.tgz",
"integrity": "sha512-aOue3Fy6Jb7+oWcfAVUsVHfeTybSCt2CZoU06aO4jv4d2Ks/KnaTPwMAK3CLEduGOvBLY1lbBBrIyomqLLH5Sg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.4.tgz",
"integrity": "sha512-Fx6GShY/RvST+t6LMlTHAwd66+1tEaAAGrnU7cysVfaiRP1BKng5Vl6rVmO6rSLOA1Sp1dRzNYM4f0OWQGb7qA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.4.tgz",
"integrity": "sha512-tNzZSa79Z1nRpHxEpQ38lsAFpKlFlYvoXzjTV+lRRn/zSf3wrdUC3wnLcq2XEadchPu4ttK0zivhYzr+XGD19Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.4.tgz",
"integrity": "sha512-jmZ1pr0xEqUhuEusWtt9Yt+GF6lCnGHUmIL+KIknpbjK/XQ2qkhDBhaxK/JMHM2A8dAacVXCxXJk2EXpwhQUfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.4.tgz",
"integrity": "sha512-fGo8pgEHZxdSYgZ/J98m1usJ7NHKmKbq7k6uT0x1rpOq6Bg2YVzzpaAblWk710f5LHYBJIL5QIFS5IrD+4gmTg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.4.tgz",
"integrity": "sha512-ezl8sQIcjYsye3TUnTH5VcWmWzccZASlQm7hlZt0dYyj1XD0qpVW47u2xYVrIKui0F8uQv/7QGZixdWiClBsbg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.12.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.4.tgz",
"integrity": "sha512-DJhDeCqjLDNMWYFy7ZS8cfeAHX2RK0RbQMLiA3bMVVkXRDJ4NBYiHriGqsUhsr9iG41aTb+dJGkFPmPw1gWwVw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tsconfig/svelte": { "node_modules/@tsconfig/svelte": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
+9
View File
@@ -4,15 +4,24 @@
"version": "0.1.0-dev", "version": "0.1.0-dev",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "node --test src/*.test.js",
"dev": "vite", "dev": "vite",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"build": "vite build", "build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json", "check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "svelte-check --tsconfig ./tsconfig.json",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check ."
}, },
"dependencies": {
"@tauri-apps/api": "^2.0.0"
},
"devDependencies": { "devDependencies": {
"@tsconfig/svelte": "^5.0.0", "@tsconfig/svelte": "^5.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2.0.0",
"prettier": "^3.5.0", "prettier": "^3.5.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
+1
View File
@@ -0,0 +1 @@
target/
+4305
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "trixy-linux-client"
version = "0.1.0"
description = "Trixie Linux client"
authors = ["ReaperOfVeriod"]
license = "MIT"
edition = "2021"
[lib]
name = "trixy_linux_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
trixy-native = { path = "../native" }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="20" fill="#081018"/>
<path d="M64 18 104 64 64 110 24 64Z" fill="none" stroke="#00b9f2" stroke-width="8"/>
<path d="M64 36 88 64 64 92 40 64Z" fill="#182b3a" stroke="#00b9f2" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 305 B

+65
View File
@@ -0,0 +1,65 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::path::PathBuf;
use trixy_native::{clear_session, default_settings_path, load_session, store_session, Settings};
fn error_message(error: std::io::Error) -> String {
error.to_string()
}
#[tauri::command]
fn load_client_settings() -> Result<Settings, String> {
let path = default_settings_path();
if !path.exists() {
return Ok(Settings {
server_address: "http://127.0.0.1:8787".to_string(),
username: String::new(),
});
}
Settings::load(path).map_err(error_message)
}
#[tauri::command]
fn save_client_settings(settings: Settings) -> Result<(), String> {
if settings.server_address.trim().is_empty() || settings.username.trim().is_empty() {
return Err("Server address and username are required.".to_string());
}
Settings::save(&settings, default_settings_path()).map_err(error_message)
}
#[tauri::command]
fn load_server_session(settings: Settings) -> Result<Option<String>, String> {
load_session(&settings).map_err(error_message)
}
#[tauri::command]
fn store_server_session(settings: Settings, session: String) -> Result<(), String> {
store_session(&settings, &session).map_err(error_message)
}
#[tauri::command]
fn clear_server_session(settings: Settings) -> Result<(), String> {
clear_session(&settings).map_err(error_message)
}
#[tauri::command]
fn client_settings_path() -> String {
PathBuf::from(default_settings_path())
.to_string_lossy()
.into_owned()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
load_client_settings,
save_client_settings,
load_server_session,
store_server_session,
clear_server_session,
client_settings_path
])
.run(tauri::generate_context!())
.expect("error while running Trixie client");
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
trixy_linux_client_lib::run();
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Trixie",
"version": "0.1.0",
"identifier": "nl.reaperofveriod.trixie",
"build": {
"beforeDevCommand": "npm run dev -- --host 127.0.0.1",
"devUrl": "http://127.0.0.1:5173",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Trixie",
"width": 1366,
"height": 768,
"minWidth": 1024,
"minHeight": 640,
"resizable": true
}
],
"security": {
"csp": "default-src 'self' http://127.0.0.1:8787 ws://127.0.0.1:8787 https://*.twitch.tv https://*.twitchcdn.net https://cdn.7tv.app; connect-src 'self' http: https: ws: wss:; img-src 'self' data: https:; frame-src https://player.twitch.tv; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"
}
},
"bundle": {
"active": true,
"icon": ["icons/icon.png", "icons/icon.svg"],
"targets": ["appimage"],
"linux": {
"deb": { "depends": [] }
}
}
}
+610 -34
View File
@@ -1,14 +1,134 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 };
function nativeClientAvailable() {
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: validatedServerAddress(serverAddress), username };
await invoke('save_client_settings', { settings });
await invoke('store_server_session', { settings, session: sessionToken });
}
async function clearNativeSession() {
if (!nativeClientAvailable() || !username || !serverAddress) return;
await invoke('clear_server_session', { settings: { server_address: serverAddress, username } satisfies ClientSettings });
}
async function loadNativeSettings() {
if (!nativeClientAvailable()) return;
try {
const settings = await invoke<ClientSettings>('load_client_settings');
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 serverAddress = 'http://127.0.0.1:8787';
let connectionState = 'OFFLINE'; let connectionState = 'OFFLINE';
let serverVersion = '—'; let serverVersion = '—';
let serverCompatibility = ''; 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 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 backups: Array<{ filename: string; size_bytes: number; modified_at: string | null }> = [];
let backupError = ''; let backupError = '';
let backupBusy = false; let backupBusy = false;
let restoreBusy = false;
let activeView = 'Live dashboard'; let activeView = 'Live dashboard';
let sessionToken = ''; let sessionToken = '';
let authenticated = false; let authenticated = false;
@@ -23,6 +143,7 @@
let authBusy = false; let authBusy = false;
let connectionNotice = ''; let connectionNotice = '';
let connectionCheckRunning = false; let connectionCheckRunning = false;
let connectionCheckGeneration = 0;
let realtimeState = 'DISCONNECTED'; let realtimeState = 'DISCONNECTED';
let realtimeSocket: WebSocket | undefined; let realtimeSocket: WebSocket | undefined;
let activityEvents: Array<{ event_id: string; event_type: string; occurred_at: string }> = []; let activityEvents: Array<{ event_id: string; event_type: string; occurred_at: string }> = [];
@@ -60,10 +181,11 @@
let moderationReason = ''; let moderationReason = '';
let moderationBusy = false; let moderationBusy = false;
let moderationError = ''; 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 collaborationInvites: Array<{ user_id: string; invited_at: string; status: string }> = [];
let collaborationBusy = false; let collaborationBusy = false;
let collaborationError = ''; let collaborationError = '';
let collaborationNotice = '';
let showCollaboration = false; let showCollaboration = false;
let collaborationGuestId = ''; let collaborationGuestId = '';
let collaborators: Array<{ twitch_user_id: string; display_name: string; favorite: boolean; last_invited_at: string | null }> = []; let collaborators: Array<{ twitch_user_id: string; display_name: string; favorite: boolean; last_invited_at: string | null }> = [];
@@ -73,11 +195,14 @@
let collaboratorSearchError = ''; let collaboratorSearchError = '';
let collaboratorSearchTimer: number | undefined; let collaboratorSearchTimer: number | undefined;
let sharedChatSession: { session_id: string; host_broadcaster_id: string; participants: Array<{ broadcaster_id: string }> } | null = null; 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 oauthLink = '';
let oauthIdentity: 'streamer' | 'bot' = 'streamer'; let oauthIdentity: 'streamer' | 'bot' = 'streamer';
let oauthBusy = false; let oauthBusy = false;
let oauthError = ''; 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 publicBotCommands: Array<{ id: string; name: string; description: string; permission_level: string; aliases: string[]; tags: string[] }> = [];
let botCommandError = ''; let botCommandError = '';
let commandFormId = ''; let commandFormId = '';
@@ -91,13 +216,32 @@
let commandFormResponse = ''; let commandFormResponse = '';
let commandFormErrorResponse = ''; let commandFormErrorResponse = '';
let commandFormEnabled = true; 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 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 botSecrets: Array<{ id: string; name: string; description: string; used_by: string[] }> = [];
let secretName = ''; let secretName = '';
let secretDescription = ''; let secretDescription = '';
let secretValue = ''; let secretValue = '';
let secretError = ''; 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 selectedOverlayId = '';
let overlayName = ''; let overlayName = '';
let overlaySlug = ''; let overlaySlug = '';
@@ -106,12 +250,17 @@
let overlayJavascript = ''; let overlayJavascript = '';
let overlayEnabled = true; let overlayEnabled = true;
let overlayDisabledDesign = 'transparent'; let overlayDisabledDesign = 'transparent';
let overlayAlertGroupWindowMs = 200;
let overlayAlertGroupSize = 5;
let overlayAlertDisplayMs = 4000;
let overlayError = ''; let overlayError = '';
let overlayBusy = false; let overlayBusy = false;
let overlaySavedSignature = ''; let overlaySavedSignature = '';
let overlayAutosaveAttemptedSignature = ''; 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 overlayDiagnosticsOpen = false;
let overlayEditorTab: 'HTML' | 'CSS' | 'JAVASCRIPT' = 'HTML';
let overlayCompletion = '';
let overlayAutosaveTimer: number | undefined; let overlayAutosaveTimer: number | undefined;
let oauthScopes: Record<'streamer' | 'bot', { connected: boolean; missing_scopes: string[]; reauthorization_required: boolean }> = { let oauthScopes: Record<'streamer' | 'bot', { connected: boolean; missing_scopes: string[]; reauthorization_required: boolean }> = {
streamer: { connected: false, missing_scopes: [], reauthorization_required: false }, streamer: { connected: false, missing_scopes: [], reauthorization_required: false },
@@ -125,6 +274,13 @@
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined; 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) { function isClientCompatible(minimum: string, maximum: string) {
const client = versionParts(CLIENT_VERSION); const client = versionParts(CLIENT_VERSION);
const minimumParts = versionParts(minimum); const minimumParts = versionParts(minimum);
@@ -149,7 +305,7 @@
} }
async function checkServer() { async function checkServer() {
if (connectionCheckRunning) return; const generation = ++connectionCheckGeneration;
connectionCheckRunning = true; connectionCheckRunning = true;
connectionState = 'CONNECTING'; connectionState = 'CONNECTING';
let attempt = 0; let attempt = 0;
@@ -162,6 +318,7 @@
} else { } else {
connectionNotice = 'Connecting to server'; connectionNotice = 'Connecting to server';
} }
if (generation !== connectionCheckGeneration) return;
try { try {
const response = await fetch(`${serverAddress}/api/v1/server/info`); const response = await fetch(`${serverAddress}/api/v1/server/info`);
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -187,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() { async function login() {
sessionRestoreGeneration += 1;
clearTimeout(sessionRestoreTimer);
if (connectionState === 'INCOMPATIBLE') { if (connectionState === 'INCOMPATIBLE') {
authError = 'This client version is not compatible with the server.'; authError = 'This client version is not compatible with the server.';
return; return;
@@ -195,29 +372,30 @@
authBusy = true; authBusy = true;
authError = ''; authError = '';
try { 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', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }), 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(); const result: { session_token: string; must_replace_credentials: boolean } = await response.json();
sessionToken = result.session_token; sessionToken = result.session_token;
authenticated = true; authenticated = true;
mustReplaceCredentials = result.must_replace_credentials; mustReplaceCredentials = result.must_replace_credentials;
await saveNativeSession().catch(() => {
authError = 'Login succeeded, but the session could not be stored in the Linux keyring.';
});
connectionState = 'CONNECTED'; connectionState = 'CONNECTED';
await loadActivity(); try {
await loadNotifications(); await loadAuthenticatedData();
await loadDiagnostics(); } catch {
await loadOAuthStatus(); authError = 'Login succeeded, but some dashboard data could not be loaded.';
await loadCollaboration(); }
await loadCollaborators();
await loadBotCommands();
await loadBotSecrets();
await loadOverlays();
await loadBackups();
await loadStreamInfo();
connectRealtime();
if (!mustReplaceCredentials) password = ''; if (!mustReplaceCredentials) password = '';
if (mustReplaceCredentials) newUsername = username; if (mustReplaceCredentials) newUsername = username;
} catch (error) { } catch (error) {
@@ -254,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() { async function replaceCredentials() {
authBusy = true; authBusy = true;
authError = ''; authError = '';
@@ -267,6 +465,7 @@
sessionToken = ''; sessionToken = '';
authenticated = false; authenticated = false;
mustReplaceCredentials = false; mustReplaceCredentials = false;
await clearNativeSession().catch(() => undefined);
username = newUsername; username = newUsername;
password = ''; password = '';
newPassword = ''; newPassword = '';
@@ -279,12 +478,15 @@
} }
async function logout() { async function logout() {
sessionRestoreGeneration += 1;
clearTimeout(sessionRestoreTimer);
realtimeSocket?.close(); realtimeSocket?.close();
realtimeSocket = undefined; realtimeSocket = undefined;
realtimeState = 'DISCONNECTED'; realtimeState = 'DISCONNECTED';
if (sessionToken) { if (sessionToken) {
await fetch(`${serverAddress}/api/v1/auth/logout`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } }).catch(() => undefined); await fetch(`${serverAddress}/api/v1/auth/logout`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } }).catch(() => undefined);
} }
await clearNativeSession().catch(() => undefined);
sessionToken = ''; sessionToken = '';
authenticated = false; authenticated = false;
mustReplaceCredentials = false; mustReplaceCredentials = false;
@@ -295,15 +497,18 @@
chatMessages = []; chatMessages = [];
collaborationSession = null; collaborationSession = null;
collaborationInvites = []; collaborationInvites = [];
collaborationSettings = null;
showCollaboration = false; showCollaboration = false;
chatReplyParent = undefined; chatReplyParent = undefined;
chatThreadRoot = undefined; chatThreadRoot = undefined;
pinnedMessageIds = []; pinnedMessageIds = [];
plugins = [];
} }
function connectRealtime() { function connectRealtime() {
realtimeSocket?.close(); 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'; realtimeState = 'CONNECTING';
const socket = new WebSocket(socketUrl); const socket = new WebSocket(socketUrl);
realtimeSocket = socket; realtimeSocket = socket;
@@ -322,6 +527,9 @@
if (activity.event_type === 'twitch.channel.chat.message' && activity.payload?.message?.text) { 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); 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 { } catch {
realtimeState = 'DISCONNECTED'; realtimeState = 'DISCONNECTED';
@@ -375,6 +583,8 @@
async function loadCollaboration() { async function loadCollaboration() {
try { 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}` } }); const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/session`, { headers: { authorization: `Bearer ${sessionToken}` } });
if (!response.ok) return; if (!response.ok) return;
collaborationSession = await response.json(); collaborationSession = await response.json();
@@ -384,9 +594,14 @@
} }
const sharedChat = await fetch(`${serverAddress}/api/v1/twitch/collaboration/shared-chat`, { headers: { authorization: `Bearer ${sessionToken}` } }); const sharedChat = await fetch(`${serverAddress}/api/v1/twitch/collaboration/shared-chat`, { headers: { authorization: `Bearer ${sessionToken}` } });
if (sharedChat.ok) sharedChatSession = await sharedChat.json(); 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 { } catch {
collaborationSession = null; collaborationSession = null;
sharedChatSession = null; sharedChatSession = null;
showSharedChatDetails = false;
collaborationSettings = null;
collaborationCapabilities = null;
} }
} }
@@ -446,6 +661,7 @@
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/session`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } }); 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.'); if (!response.ok) throw new Error('Twitch could not start a Guest Star session.');
collaborationSession = await response.json(); collaborationSession = await response.json();
collaborationNotice = 'Guest Star session started.';
await loadCollaboration(); await loadCollaboration();
} catch (error) { } catch (error) {
collaborationError = error instanceof Error ? error.message : 'Collaboration session failed.'; collaborationError = error instanceof Error ? error.message : 'Collaboration session failed.';
@@ -456,6 +672,7 @@
async function endCollaboration() { async function endCollaboration() {
if (!collaborationSession) return; if (!collaborationSession) return;
if (!window.confirm('End the current Guest Star collaboration? Twitch will remove the active session.')) return;
collaborationBusy = true; collaborationBusy = true;
collaborationError = ''; collaborationError = '';
try { try {
@@ -463,6 +680,7 @@
if (!response.ok) throw new Error('Twitch could not end the Guest Star session.'); if (!response.ok) throw new Error('Twitch could not end the Guest Star session.');
collaborationSession = null; collaborationSession = null;
collaborationInvites = []; collaborationInvites = [];
collaborationNotice = 'Guest Star session ended.';
} catch (error) { } catch (error) {
collaborationError = error instanceof Error ? error.message : 'Collaboration session could not end.'; collaborationError = error instanceof Error ? error.message : 'Collaboration session could not end.';
} finally { } finally {
@@ -479,6 +697,7 @@
const response = await fetch(`${serverAddress}/api/v1/twitch/collaboration/invites/send?${query}`, { method: 'POST', headers: { authorization: `Bearer ${sessionToken}` } }); 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.'); if (!response.ok) throw new Error('Twitch could not send the collaboration invite.');
collaborationGuestId = ''; collaborationGuestId = '';
collaborationNotice = 'Collaboration invite sent.';
await loadCollaboration(); await loadCollaboration();
} catch (error) { } catch (error) {
collaborationError = error instanceof Error ? error.message : 'Invite could not be sent.'; collaborationError = error instanceof Error ? error.message : 'Invite could not be sent.';
@@ -489,12 +708,14 @@
async function cancelCollaborationInvite(guestId: string) { async function cancelCollaborationInvite(guestId: string) {
if (!collaborationSession) return; if (!collaborationSession) return;
if (!window.confirm(`Cancel the collaboration invite for ${guestId}?`)) return;
collaborationBusy = true; collaborationBusy = true;
collaborationError = ''; collaborationError = '';
try { try {
const query = `session_id=${encodeURIComponent(collaborationSession.id)}&guest_id=${encodeURIComponent(guestId)}`; 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}` } }); 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.'); if (!response.ok) throw new Error('Twitch could not cancel the collaboration invite.');
collaborationNotice = 'Collaboration invite cancelled.';
await loadCollaboration(); await loadCollaboration();
} catch (error) { } catch (error) {
collaborationError = error instanceof Error ? error.message : 'Invite could not be cancelled.'; collaborationError = error instanceof Error ? error.message : 'Invite could not be cancelled.';
@@ -503,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) { async function markNotificationSeen(notificationId: string) {
const response = await fetch(`${serverAddress}/api/v1/admin/notifications/${notificationId}/seen`, { const response = await fetch(`${serverAddress}/api/v1/admin/notifications/${notificationId}/seen`, {
method: 'POST', method: 'POST',
@@ -720,7 +975,36 @@
commandFormCooldownScope = 'global'; commandFormCooldownScope = 'global';
commandFormResponse = ''; commandFormResponse = '';
commandFormErrorResponse = ''; commandFormErrorResponse = '';
commandVariableSuggestions = [];
commandSimulationError = '';
commandSimulationResult = null;
commandFormEnabled = true; 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]) { function editCommand(command: (typeof botCommands)[number]) {
@@ -735,6 +1019,59 @@
commandFormResponse = command.response_template; commandFormResponse = command.response_template;
commandFormErrorResponse = command.error_template ?? ''; commandFormErrorResponse = command.error_template ?? '';
commandFormEnabled = command.enabled; 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() { async function saveCommand() {
@@ -742,11 +1079,21 @@
botCommandError = ''; botCommandError = '';
const aliases = commandFormAliases.split(',').map((value) => value.trim()).filter(Boolean); const aliases = commandFormAliases.split(',').map((value) => value.trim()).filter(Boolean);
const tags = commandFormTags.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 { try {
const response = await fetch(commandFormId ? `${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandFormId)}` : `${serverAddress}/api/v1/bot/commands`, { const response = await fetch(commandFormId ? `${serverAddress}/api/v1/bot/commands/${encodeURIComponent(commandFormId)}` : `${serverAddress}/api/v1/bot/commands`, {
method: commandFormId ? 'PATCH' : 'POST', method: commandFormId ? 'PATCH' : 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${sessionToken}` }, 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.'); if (!response.ok) throw new Error('The command could not be saved. Check its name, aliases, and fields.');
resetCommandForm(); resetCommandForm();
@@ -759,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) { async function deleteCommand(id: string) {
if (!window.confirm('Delete this command permanently?')) return; if (!window.confirm('Delete this command permanently?')) return;
botCommandError = ''; botCommandError = '';
@@ -818,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() { async function loadOverlays() {
try { try {
const response = await fetch(`${serverAddress}/api/v1/overlays`, { headers: { authorization: `Bearer ${sessionToken}` } }); const response = await fetch(`${serverAddress}/api/v1/overlays`, { headers: { authorization: `Bearer ${sessionToken}` } });
@@ -840,13 +1343,16 @@
overlayJavascript = overlay.draft_javascript; overlayJavascript = overlay.draft_javascript;
overlayEnabled = overlay.enabled; overlayEnabled = overlay.enabled;
overlayDisabledDesign = overlay.disabled_design; 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(); overlaySavedSignature = overlayDraftSignature();
overlayAutosaveAttemptedSignature = overlaySavedSignature; overlayAutosaveAttemptedSignature = overlaySavedSignature;
restoreOverlayDraft(overlay.id); restoreOverlayDraft(overlay.id);
} }
function overlayDraftSignature() { 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() { function overlayVariableWarnings() {
@@ -864,6 +1370,24 @@
return warnings; 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) { function overlayDraftCacheKey(id: string) {
return `trixie.overlay.draft.${id}`; return `trixie.overlay.draft.${id}`;
} }
@@ -871,7 +1395,7 @@
function cacheOverlayDraft() { function cacheOverlayDraft() {
if (!selectedOverlayId) return; if (!selectedOverlayId) return;
try { 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 { } catch {
// Local storage is an optional recovery aid; server persistence remains authoritative. // Local storage is an optional recovery aid; server persistence remains authoritative.
} }
@@ -879,7 +1403,7 @@
function restoreOverlayDraft(id: string) { function restoreOverlayDraft(id: string) {
try { 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; if (!cached || typeof cached.overlayName !== 'string') return;
const cachedSignature = JSON.stringify(cached); const cachedSignature = JSON.stringify(cached);
if (cachedSignature !== overlayDraftSignature()) pendingOverlayDraft = { if (cachedSignature !== overlayDraftSignature()) pendingOverlayDraft = {
@@ -890,6 +1414,9 @@
overlayJavascript: cached.overlayJavascript ?? overlayJavascript, overlayJavascript: cached.overlayJavascript ?? overlayJavascript,
overlayEnabled: cached.overlayEnabled ?? overlayEnabled, overlayEnabled: cached.overlayEnabled ?? overlayEnabled,
overlayDisabledDesign: cached.overlayDisabledDesign ?? overlayDisabledDesign, overlayDisabledDesign: cached.overlayDisabledDesign ?? overlayDisabledDesign,
overlayAlertGroupWindowMs: cached.overlayAlertGroupWindowMs ?? overlayAlertGroupWindowMs,
overlayAlertGroupSize: cached.overlayAlertGroupSize ?? overlayAlertGroupSize,
overlayAlertDisplayMs: cached.overlayAlertDisplayMs ?? overlayAlertDisplayMs,
}; };
} catch { } catch {
// Ignore malformed or unavailable local cache entries. // Ignore malformed or unavailable local cache entries.
@@ -905,16 +1432,19 @@
selectedOverlayId = ''; selectedOverlayId = '';
overlayName = 'New Overlay'; overlayName = 'New Overlay';
overlaySlug = `overlay-${Date.now()}`; overlaySlug = `overlay-${Date.now()}`;
overlayHtml = '<div class="alert">Hello ${username}</div>'; overlayHtml = '<div id="trixie-follow-alert" class="alert" role="status" aria-live="polite"><span id="trixie-follow-name"></span> followed!</div>';
overlayCss = '.alert { color: white; font: 32px sans-serif; }'; 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 = ''; 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; overlayEnabled = true;
overlayDisabledDesign = 'transparent'; overlayDisabledDesign = 'transparent';
overlayAlertGroupWindowMs = 200;
overlayAlertGroupSize = 5;
overlayAlertDisplayMs = 4000;
} }
function restorePendingOverlayDraft() { function restorePendingOverlayDraft() {
if (!pendingOverlayDraft) return; if (!pendingOverlayDraft) return;
({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign } = pendingOverlayDraft); ({ overlayName, overlaySlug, overlayHtml, overlayCss, overlayJavascript, overlayEnabled, overlayDisabledDesign, overlayAlertGroupWindowMs, overlayAlertGroupSize, overlayAlertDisplayMs } = pendingOverlayDraft);
pendingOverlayDraft = null; pendingOverlayDraft = null;
overlaySavedSignature = ''; overlaySavedSignature = '';
overlayAutosaveAttemptedSignature = ''; overlayAutosaveAttemptedSignature = '';
@@ -930,7 +1460,7 @@
overlayBusy = true; overlayBusy = true;
overlayError = ''; overlayError = '';
try { 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 }); 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.'); if (!response.ok) throw new Error('The overlay draft could not be saved.');
const saved = await response.json(); const saved = await response.json();
@@ -1026,9 +1556,11 @@
}); });
} }
onMount(() => { onMount(async () => {
await loadNativeSettings();
void checkServer(); void checkServer();
void loadPublicCommands(); void loadPublicCommands();
void restoreNativeSession();
}); });
</script> </script>
@@ -1038,7 +1570,7 @@
<header class="app-bar"> <header class="app-bar">
<div class="brand"><span class="brand-mark">◇</span><span>TRIXIE</span></div> <div class="brand"><span class="brand-mark">◇</span><span>TRIXIE</span></div>
<nav class="top-nav" aria-label="Primary navigation"> <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> <button class:active={activeView === item} class="nav-item" onclick={() => (activeView = item)}>{item}</button>
{/each} {/each}
</nav> </nav>
@@ -1112,20 +1644,53 @@
<div class="panel-header"><span>FIRST-RUN SETUP</span></div> <div class="panel-header"><span>FIRST-RUN SETUP</span></div>
<div class="auth-gate-body credential-form"> <div class="auth-gate-body credential-form">
<strong>Replace the generated administrator credentials</strong> <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-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" /> <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> <button class="primary" onclick={replaceCredentials} disabled={authBusy}>SAVE AND CONTINUE</button>
{#if authError}<div class="auth-error">{authError}</div>{/if} {#if authError}<div class="auth-error">{authError}</div>{/if}
</div> </div>
</section> </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'} {: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="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-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'} {: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'} {: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} {:else}
<div class="dashboard-grid"> <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> <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>
@@ -1213,5 +1778,16 @@
</div> </div>
</section> </section>
{/if} {/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> </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> </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 { :root {
--ed-accent: #00b3ff; --ed-accent: #00b3ff;
--ed-accent2: #ff7f00; --ed-accent2: #ff7f00;
@@ -34,9 +11,9 @@
--ed-danger: #ff5436; --ed-danger: #ff5436;
--ed-info: #00b3ff; --ed-info: #00b3ff;
--ed-exo: #a3ff5e; --ed-exo: #a3ff5e;
--font-display: 'Rajdhani', 'Inter', sans-serif; --font-display: 'Rajdhani', 'Inter', system-ui, sans-serif;
--font-data: 'JetBrains Mono', monospace; --font-data: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace;
--font-body: 'Inter', sans-serif; --font-body: 'Inter', system-ui, sans-serif;
--panel-radius: 2px; --panel-radius: 2px;
--ui-line: color-mix(in srgb, var(--ed-accent) 28%, var(--ed-edge)); --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)); --ui-line-soft: color-mix(in srgb, var(--ed-accent) 16%, var(--ed-edge));
@@ -416,6 +393,26 @@ label {
color: var(--ed-warn); color: var(--ed-warn);
font: 10px/1.4 var(--font-data); 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 { .auth-gate {
min-height: 300px; min-height: 300px;
} }
@@ -555,6 +552,28 @@ label {
color: var(--ed-dim); color: var(--ed-dim);
font-size: 11px; 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 { .secret-form {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr auto; grid-template-columns: 1fr 1fr 1fr auto;
@@ -620,9 +639,35 @@ label {
display: grid; display: grid;
gap: 7px; 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 { .overlay-code {
min-height: 110px; min-height: 110px;
resize: vertical; resize: vertical;
width: 100%;
min-width: 0;
border: 0;
color: var(--ed-dim); color: var(--ed-dim);
background: var(--ui-surface-low); background: var(--ui-surface-low);
font: 12px/1.4 var(--font-data); font: 12px/1.4 var(--font-data);
@@ -961,3 +1006,113 @@ label {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.8rem; 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", "extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true,
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,