Compare commits
44
Commits
d7dad3021f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3648ca6c | ||
|
|
0c60409874 | ||
|
|
8d8474c349 | ||
|
|
7aef4171af | ||
|
|
58df8d06e1 | ||
|
|
bc06d323c7 | ||
|
|
092b58cdc2 | ||
|
|
b54c7b23fd | ||
|
|
3dfeb98840 | ||
|
|
d81094861e | ||
|
|
9bc0db7b2a | ||
|
|
aeae58d0d8 | ||
|
|
313535fe70 | ||
|
|
9cf297613d | ||
|
|
8a3ac54204 | ||
|
|
d58d435fde | ||
|
|
ed30446136 | ||
|
|
5920055473 | ||
|
|
7fabf936b8 | ||
|
|
8879897e06 | ||
|
|
8a28596f47 | ||
|
|
027e7434f6 | ||
|
|
9f8a5a5ecc | ||
|
|
d1636a3a2a | ||
|
|
b4bc860533 | ||
|
|
3d29bd5544 | ||
|
|
7682abdc9f | ||
|
|
a48cd5bc22 | ||
|
|
52a5ce895f | ||
|
|
dc3985ee00 | ||
|
|
1707731029 | ||
|
|
cc5a36c086 | ||
|
|
d458f6b9e4 | ||
|
|
1043d4057b | ||
|
|
733660e684 | ||
|
|
7df188b3fd | ||
|
|
6792544c16 | ||
|
|
1325415085 | ||
|
|
1cf940c7dd | ||
|
|
0881bb9b0f | ||
|
|
f7d466eddb | ||
|
|
9167e04e77 | ||
|
|
9854cc4c4f | ||
|
|
761ff5305d |
@@ -16,5 +16,12 @@ 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
|
||||||
|
- working-directory: native
|
||||||
|
run: cargo fmt -- --check
|
||||||
|
- working-directory: native
|
||||||
|
run: cargo test
|
||||||
|
|||||||
@@ -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,4 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
native/target/
|
||||||
|
src-tauri/target/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ This directory is the home of the standalone Linux client repository.
|
|||||||
|
|
||||||
The current development shell connects to the server readiness endpoint and
|
The current development shell connects to the server readiness endpoint and
|
||||||
provides an administrator login gate. The browser/dev client keeps its bearer
|
provides an administrator login gate. The browser/dev client keeps its bearer
|
||||||
session in memory only; the native AppImage integration must replace that
|
session in memory only. The `native/` crate defines the AppImage storage
|
||||||
temporary behavior with the Linux kernel keyring before persistent client
|
boundary: `settings.json` contains only the server address and username, while
|
||||||
authentication is considered complete.
|
the session credential is stored in the Linux kernel keyring with no plaintext
|
||||||
|
fallback.
|
||||||
|
|
||||||
The client is a Svelte-based control surface distributed as an x86-64 AppImage. It communicates with `trixie-server` through the documented versioned HTTP and authenticated WebSocket interfaces.
|
The client is a Svelte-based control surface distributed as an x86-64 AppImage. It communicates with `trixie-server` through the documented versioned HTTP and authenticated WebSocket interfaces.
|
||||||
|
|
||||||
@@ -15,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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Generated
+284
@@ -0,0 +1,284 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.191"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trixy-native"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "trixy-native"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
description = "Native Linux storage boundary for the Trixie client"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Native credential boundary
|
||||||
|
|
||||||
|
This crate is the storage boundary used by the future Linux AppImage shell.
|
||||||
|
`Settings` writes only `server_address` and `username` to the client
|
||||||
|
`settings.json`. Session credentials are stored under the Linux kernel
|
||||||
|
keyring service `trixie.server.session`, scoped by server address and
|
||||||
|
username. `load_session` returns `None` when the keyring is unavailable;
|
||||||
|
callers must return to credential entry and must not create a plaintext
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
The crate uses `keyctl` with argument arrays, never a shell command. It does
|
||||||
|
not log or serialize session values.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! Native persistence boundary for the Linux client.
|
||||||
|
//!
|
||||||
|
//! Settings are deliberately limited to non-secret connection metadata. The
|
||||||
|
//! session credential is stored as a `user` key in the Linux kernel keyring
|
||||||
|
//! through the `keyctl` utility. If keyring access fails, callers receive an
|
||||||
|
//! error and must ask for credentials again; no plaintext fallback is allowed.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
fs, io,
|
||||||
|
io::Write,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{Command, Stdio},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const KEYRING_SERVICE: &str = "trixie.server.session";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub server_address: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn load(path: impl AsRef<Path>) -> io::Result<Self> {
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: impl AsRef<Path>) -> io::Result<()> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let bytes = serde_json::to_vec_pretty(self)
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||||
|
let temporary = path.with_extension("json.tmp");
|
||||||
|
fs::write(&temporary, bytes)?;
|
||||||
|
fs::rename(temporary, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_settings_path() -> PathBuf {
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
base.join("trixie").join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_description(settings: &Settings) -> String {
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
digest.update(settings.server_address.as_bytes());
|
||||||
|
digest.update([0]);
|
||||||
|
digest.update(settings.username.as_bytes());
|
||||||
|
let digest = digest.finalize();
|
||||||
|
format!("{KEYRING_SERVICE}:{}", hex_encode(&digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut result = String::with_capacity(bytes.len() * 2);
|
||||||
|
for byte in bytes {
|
||||||
|
result.push(HEX[(byte >> 4) as usize] as char);
|
||||||
|
result.push(HEX[(byte & 0x0f) as usize] as char);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyctl(args: &[&str]) -> io::Result<Vec<u8>> {
|
||||||
|
let output = Command::new("keyctl").args(args).output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::PermissionDenied,
|
||||||
|
"Linux kernel keyring access failed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(output.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_session(settings: &Settings, session: &str) -> io::Result<()> {
|
||||||
|
if session.is_empty() {
|
||||||
|
return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty session"));
|
||||||
|
}
|
||||||
|
let description = key_description(settings);
|
||||||
|
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>> {
|
||||||
|
let description = key_description(settings);
|
||||||
|
let id = match keyctl(&["search", "@s", "user", &description]) {
|
||||||
|
Ok(value) => String::from_utf8_lossy(&value).trim().to_string(),
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
if id.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let value = keyctl(&["pipe", &id])?;
|
||||||
|
String::from_utf8(value)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_session(settings: &Settings) -> io::Result<()> {
|
||||||
|
let description = key_description(settings);
|
||||||
|
let id = String::from_utf8_lossy(&keyctl(&["search", "@s", "user", &description])?)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if id.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
keyctl(&["unlink", &id, "@s"]).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_write_arguments_contain_only_metadata() {
|
||||||
|
let command = key_write_command("test-description");
|
||||||
|
let args: Vec<_> = command.get_args().collect();
|
||||||
|
assert_eq!(args, ["padd", "user", "test-description", "@s"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_is_delivered_via_stdin_and_failures_are_reported() {
|
||||||
|
let mut receiver = Command::new("sh");
|
||||||
|
receiver.args(["-c", "test \"$(cat)\" = fixture-payload"]);
|
||||||
|
write_key_payload(receiver, b"fixture-payload").unwrap();
|
||||||
|
let mut failure = Command::new("sh");
|
||||||
|
failure.args(["-c", "cat >/dev/null; exit 1"]);
|
||||||
|
assert!(write_key_payload(failure, b"fixture-payload").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings() -> Settings {
|
||||||
|
Settings {
|
||||||
|
server_address: "https://trixie.example".into(),
|
||||||
|
username: "admin".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_round_trip_contains_no_password_field() {
|
||||||
|
let directory = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let path = directory.path().join("settings.json");
|
||||||
|
settings().save(&path).expect("save settings");
|
||||||
|
let text = fs::read_to_string(&path).expect("read settings");
|
||||||
|
assert!(!text.contains("password"));
|
||||||
|
assert_eq!(Settings::load(path).expect("load settings"), settings());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_description_is_stable_but_scoped_to_connection_and_user() {
|
||||||
|
let first = key_description(&settings());
|
||||||
|
assert_eq!(first, key_description(&settings()));
|
||||||
|
let mut different = settings();
|
||||||
|
different.username = "moderator".into();
|
||||||
|
assert_ne!(first, key_description(&different));
|
||||||
|
assert!(first.starts_with(KEYRING_SERVICE));
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+231
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
Generated
+4305
File diff suppressed because it is too large
Load Diff
@@ -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" }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
trixy_linux_client_lib::run();
|
||||||
|
}
|
||||||
@@ -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
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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,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,
|
||||||
|
|||||||
Reference in New Issue
Block a user