Compare commits
67
Commits
main
...
0881bb9b0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0881bb9b0f | ||
|
|
f7d466eddb | ||
|
|
9167e04e77 | ||
|
|
9854cc4c4f | ||
|
|
761ff5305d | ||
|
|
d7dad3021f | ||
|
|
a422cc70f0 | ||
|
|
f986cbb1e9 | ||
|
|
3d2688c8be | ||
|
|
a387ea124a | ||
|
|
02fcad6a8c | ||
|
|
890c42e62f | ||
|
|
d095ddec03 | ||
|
|
fa7ee4d87f | ||
|
|
000af8bfb1 | ||
|
|
950db9eb8c | ||
|
|
53756caea3 | ||
|
|
87ceb8c87f | ||
|
|
953dfaaad0 | ||
|
|
46fb8e2cc3 | ||
|
|
8b8ba4b618 | ||
|
|
209f2ed7e0 | ||
|
|
9d16dcebd5 | ||
|
|
7e9f571df1 | ||
|
|
0fa40fc63d | ||
|
|
5d6af050ad | ||
|
|
c1df6938b5 | ||
|
|
450503afc3 | ||
|
|
85cd8aee00 | ||
|
|
ba84f9d778 | ||
|
|
c256ce36e5 | ||
|
|
1ba93cce8a | ||
|
|
0b2fb65af3 | ||
|
|
181ad46c29 | ||
|
|
d20af42053 | ||
|
|
ba3d3b2d80 | ||
|
|
1b7c41ea07 | ||
|
|
810589647b | ||
|
|
7f0d3fbb6d | ||
|
|
c6844fcc47 | ||
|
|
36b99968b6 | ||
|
|
aa16fc29d3 | ||
|
|
0c684d4831 | ||
|
|
2eb050294b | ||
|
|
93d34306bc | ||
|
|
625084cfa5 | ||
|
|
c81748da2c | ||
|
|
f48618f94b | ||
|
|
5cb893af62 | ||
|
|
c32582a1cd | ||
|
|
05d7947a29 | ||
|
|
8c8d632557 | ||
|
|
9ea2899ca7 | ||
|
|
e2076cb044 | ||
|
|
b5a6a61e2d | ||
|
|
71db966697 | ||
|
|
5d41473f47 | ||
|
|
35df7afe88 | ||
|
|
512757ca4a | ||
|
|
0003734bed | ||
|
|
694bfeabf3 | ||
|
|
fa3915bc5d | ||
|
|
998b3d3072 | ||
|
|
66fe3e4fea | ||
|
|
5298c6870b | ||
|
|
67a8a06268 | ||
|
|
51c4853d51 |
@@ -0,0 +1,17 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible client defect.
|
||||
title: 'bug: '
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction and expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Client version or commit
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Feature request
|
||||
description: Propose a client-side change.
|
||||
title: 'feature: '
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: behavior
|
||||
attributes:
|
||||
label: Desired behavior and affected states
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: plan
|
||||
attributes:
|
||||
label: Planning
|
||||
options:
|
||||
- label: I checked PLAN.md and this does not silently change an existing decision.
|
||||
required: true
|
||||
@@ -9,4 +9,17 @@ jobs:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: test -f README.md && test -f AGENTS.md && test -f .prettierrc.json
|
||||
- run: npm ci
|
||||
- run: npm run format:check
|
||||
- run: npm run check
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Client release check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
|
||||
jobs:
|
||||
release-check:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: test -s CHANGELOG.md && npm ci && npm run format:check && npm run check && npm run build
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Client dependency and security checks
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Audit JavaScript dependencies when a lockfile exists
|
||||
run: |
|
||||
if test -f package-lock.json; then
|
||||
npm audit --audit-level=high
|
||||
fi
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
native/target/
|
||||
src-tauri/target/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
@@ -7,4 +7,3 @@ Read the parent `AGENTS.md`, `PLAN.md`, `TODO.md`, and `docs/wiki/README.md` bef
|
||||
- Use Prettier for Svelte, JavaScript/TypeScript, JSON, YAML, and Markdown.
|
||||
- Never store passwords, tokens, or secrets in source, settings, fixtures, logs, or artifacts.
|
||||
- Do not edit the parent `PLAN.md` without explicit user permission.
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
Notable client changes are recorded here using the Keep a Changelog format.
|
||||
Stable release entries require explicit user authorization.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Add administrator backup inventory and manual backup controls.
|
||||
@@ -1,4 +1,3 @@
|
||||
# Contributing
|
||||
|
||||
Run formatting, linting, type checks, tests, and the AppImage build before submission. Use deterministic mocked server/API fixtures.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ReaperOfVeriod
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -2,9 +2,20 @@
|
||||
|
||||
This directory is the home of the standalone Linux client repository.
|
||||
|
||||
The current development shell connects to the server readiness endpoint and
|
||||
provides an administrator login gate. The browser/dev client keeps its bearer
|
||||
session in memory only. The `native/` crate defines the AppImage storage
|
||||
boundary: `settings.json` contains only the server address and username, while
|
||||
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.
|
||||
|
||||
Connection recovery follows the server policy: the first retry is immediate,
|
||||
then retries wait 3, 10, and 15 seconds, followed by an indefinite 30-second
|
||||
interval until the server reconnects. Manual connection checks do not create
|
||||
overlapping retry loops.
|
||||
|
||||
Implementation has not started. See the parent workspace's `PLAN.md`, `TODO.md`, and `AGENTS.md` before beginning work.
|
||||
|
||||
Consumes the versioned contracts from the sibling `trixie-api` repository.
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# Security
|
||||
|
||||
Report vulnerabilities privately through the hosting instance. Never include credentials, tokens, or real user data in issues or fixtures.
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Trixie</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
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,146 @@
|
||||
//! 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,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
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);
|
||||
keyctl(&["add", "user", &description, session, "@s"]).map(|_| ())
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
||||
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
+1785
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "trixy-linux-client",
|
||||
"private": true,
|
||||
"version": "0.1.0-dev",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"build": "vite build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"prettier": "^3.5.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.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": [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1285
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import './styles.css';
|
||||
import App from './App.svelte';
|
||||
import { mount } from 'svelte';
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
});
|
||||
|
||||
export default app;
|
||||
+963
@@ -0,0 +1,963 @@
|
||||
@font-face {
|
||||
font-family: 'Rajdhani';
|
||||
src: url('/fonts/Rajdhani-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rajdhani';
|
||||
src: url('/fonts/Rajdhani-SemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rajdhani';
|
||||
src: url('/fonts/Rajdhani-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/Inter-Variable.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono-Variable.ttf') format('truetype');
|
||||
}
|
||||
:root {
|
||||
--ed-accent: #00b3ff;
|
||||
--ed-accent2: #ff7f00;
|
||||
--ed-bg: #06080c;
|
||||
--ed-panel: #0d1117;
|
||||
--ed-edge: #1d2630;
|
||||
--ed-fg: #dde6ec;
|
||||
--ed-dim: #7d8b96;
|
||||
--ed-success: #35d073;
|
||||
--ed-warn: #ffb400;
|
||||
--ed-danger: #ff5436;
|
||||
--ed-info: #00b3ff;
|
||||
--ed-exo: #a3ff5e;
|
||||
--font-display: 'Rajdhani', 'Inter', sans-serif;
|
||||
--font-data: 'JetBrains Mono', monospace;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
--panel-radius: 2px;
|
||||
--ui-line: color-mix(in srgb, var(--ed-accent) 28%, var(--ed-edge));
|
||||
--ui-line-soft: color-mix(in srgb, var(--ed-accent) 16%, var(--ed-edge));
|
||||
--ui-line-focus: color-mix(in srgb, var(--ed-accent) 62%, var(--ed-edge));
|
||||
--ui-surface: color-mix(in srgb, var(--ed-accent) 8%, var(--ed-bg));
|
||||
--ui-surface-hi: color-mix(in srgb, var(--ed-accent) 14%, var(--ed-bg));
|
||||
--ui-divider: color-mix(in srgb, var(--ed-fg) 7%, transparent);
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--ed-bg);
|
||||
color: var(--ed-fg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid var(--ui-line);
|
||||
border-radius: var(--panel-radius);
|
||||
outline: none;
|
||||
background: var(--ed-bg);
|
||||
color: var(--ed-fg);
|
||||
font-family: var(--font-data);
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
border-color: var(--ui-line-focus);
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--ui-line-focus);
|
||||
background: var(--ui-surface-hi);
|
||||
}
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
background: var(--ed-panel);
|
||||
}
|
||||
.app-bar {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
background: var(--ed-bg);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 188px;
|
||||
color: var(--ed-accent);
|
||||
font: 600 15px var(--font-data);
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
.brand-mark {
|
||||
color: var(--ed-accent);
|
||||
font-size: 16px;
|
||||
}
|
||||
.top-nav {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
gap: 24px;
|
||||
}
|
||||
.nav-item {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: var(--ed-dim);
|
||||
background: transparent;
|
||||
font: 10px var(--font-data);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
color: var(--ed-fg);
|
||||
background: transparent;
|
||||
}
|
||||
.nav-item.active::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: var(--ed-accent);
|
||||
content: '';
|
||||
}
|
||||
.app-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.connection-state,
|
||||
.badge {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--ed-warn);
|
||||
color: var(--ed-warn);
|
||||
font: 10px var(--font-data);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.connection-state.online {
|
||||
border-color: var(--ed-success);
|
||||
color: var(--ed-success);
|
||||
}
|
||||
.connection-notice {
|
||||
color: var(--ed-dim);
|
||||
font: 9px var(--font-data);
|
||||
}
|
||||
.icon-button,
|
||||
.secondary {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--ed-dim);
|
||||
font-size: 18px;
|
||||
}
|
||||
.notification-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.notification-button {
|
||||
position: relative;
|
||||
}
|
||||
.notification-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ed-warn);
|
||||
}
|
||||
.notification-popover {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 34px;
|
||||
right: 0;
|
||||
width: 320px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ui-line);
|
||||
background: var(--ed-panel);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
|
||||
}
|
||||
.notification-heading,
|
||||
.notification-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.notification-heading {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
color: var(--ed-fg);
|
||||
font: 600 11px var(--font-data);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.notification-item {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
opacity: 0.65;
|
||||
}
|
||||
.notification-item.unseen {
|
||||
opacity: 1;
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
.notification-title {
|
||||
color: var(--ed-fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.notification-message {
|
||||
margin: 6px 0 9px;
|
||||
color: var(--ed-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.notification-empty {
|
||||
padding: 18px 10px;
|
||||
color: var(--ed-dim);
|
||||
font: 11px var(--font-data);
|
||||
}
|
||||
.chat-message-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.chat-user {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--ed-accent);
|
||||
font-family: var(--font-data);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chat-text {
|
||||
color: var(--ed-fg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.chat-emote {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 2px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
}
|
||||
.chat-reply {
|
||||
margin-left: auto;
|
||||
padding: 2px 4px;
|
||||
border-color: transparent;
|
||||
color: var(--ed-dim);
|
||||
font: 9px var(--font-data);
|
||||
}
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 3px;
|
||||
opacity: 0;
|
||||
}
|
||||
.chat-message:hover .chat-actions,
|
||||
.chat-message:focus-within .chat-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.chat-reply.danger {
|
||||
color: var(--ed-danger);
|
||||
}
|
||||
.message-pinned {
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
.pinned-messages {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
.pinned-message {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
color: var(--ed-fg);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-reply-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 6px;
|
||||
border-left: 2px solid var(--ed-accent);
|
||||
color: var(--ed-accent);
|
||||
font: 10px var(--font-data);
|
||||
}
|
||||
.chat-emote-picker {
|
||||
display: flex;
|
||||
max-height: 180px;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid var(--ui-line);
|
||||
background: var(--ed-bg);
|
||||
}
|
||||
.chat-emote-option {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
text-align: left;
|
||||
}
|
||||
.chat-emote-option img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 214px 1fr;
|
||||
min-height: calc(100vh - 42px);
|
||||
border-top: 1px solid var(--ui-divider);
|
||||
}
|
||||
.side-panel {
|
||||
border-right: 1px solid var(--ui-divider);
|
||||
background: color-mix(in srgb, var(--ed-panel) 80%, var(--ed-bg));
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 34px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
color: var(--ed-fg);
|
||||
font: 600 13px var(--font-display);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-kicker,
|
||||
.micro {
|
||||
color: var(--ed-dim);
|
||||
font: 10px var(--font-data);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.server-card,
|
||||
.side-section {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
}
|
||||
.server-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 13px 0 18px;
|
||||
font: 11px var(--font-data);
|
||||
}
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ed-danger);
|
||||
}
|
||||
.status-dot.good {
|
||||
background: var(--ed-success);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.control {
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.control-button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.auth-card {
|
||||
display: block;
|
||||
}
|
||||
.auth-card label {
|
||||
margin-top: 9px;
|
||||
}
|
||||
.auth-error {
|
||||
margin-top: 8px;
|
||||
color: var(--ed-warn);
|
||||
font: 10px/1.4 var(--font-data);
|
||||
}
|
||||
.auth-gate {
|
||||
min-height: 300px;
|
||||
}
|
||||
.auth-gate-body {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 16px;
|
||||
color: var(--ed-dim);
|
||||
text-align: center;
|
||||
}
|
||||
.auth-gate-body strong {
|
||||
color: var(--ed-fg);
|
||||
font: 600 16px var(--font-display);
|
||||
}
|
||||
.credential-form {
|
||||
justify-items: stretch;
|
||||
text-align: left;
|
||||
}
|
||||
.credential-form strong {
|
||||
text-align: center;
|
||||
}
|
||||
.primary {
|
||||
padding: 7px 10px;
|
||||
border-color: var(--ui-line-focus);
|
||||
background: var(--ui-surface);
|
||||
color: var(--ed-fg);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.primary:hover {
|
||||
background: var(--ui-surface-hi);
|
||||
}
|
||||
.empty-row,
|
||||
.mono {
|
||||
margin-top: 8px;
|
||||
color: var(--ed-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.oauth-actions {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.oauth-status-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid var(--ui-divider);
|
||||
}
|
||||
.oauth-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.oauth-status-row .good {
|
||||
color: var(--ed-green);
|
||||
}
|
||||
.secondary-action {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
color: var(--ed-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
.oauth-link-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--ui-divider);
|
||||
}
|
||||
.oauth-link {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ed-dim);
|
||||
font-size: 9px;
|
||||
}
|
||||
.oauth-hint {
|
||||
color: var(--ed-dim);
|
||||
font: 9px/1.4 var(--font-data);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-data);
|
||||
}
|
||||
.side-footer {
|
||||
margin-top: auto;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.bot-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
.bot-page-body {
|
||||
padding: 18px;
|
||||
}
|
||||
.bot-command-list {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--ui-divider);
|
||||
}
|
||||
.bot-command-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 13px 4px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
}
|
||||
.bot-command-row strong {
|
||||
display: block;
|
||||
color: var(--ed-cyan);
|
||||
font-family: var(--font-data);
|
||||
}
|
||||
.bot-command-description {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--ed-dim);
|
||||
}
|
||||
.bot-command-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
color: var(--ed-dim);
|
||||
font: 10px var(--font-data);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bot-secret-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--ui-divider);
|
||||
}
|
||||
.bot-secret-note {
|
||||
margin-top: 5px;
|
||||
color: var(--ed-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.secret-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr auto;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.bot-secret-list {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--ui-divider);
|
||||
}
|
||||
.bot-secret-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
}
|
||||
.bot-secret-row strong,
|
||||
.bot-secret-row span {
|
||||
display: block;
|
||||
}
|
||||
.overlay-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
.overlay-toolbar,
|
||||
.overlay-controls {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
.overlay-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
.overlay-list {
|
||||
border-right: 1px solid var(--ui-divider);
|
||||
padding: 10px;
|
||||
}
|
||||
.overlay-list-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
color: var(--ed-dim);
|
||||
text-align: left;
|
||||
}
|
||||
.overlay-list-item.active,
|
||||
.overlay-list-item:hover {
|
||||
color: var(--ed-cyan);
|
||||
background: var(--ui-surface-hi);
|
||||
}
|
||||
.overlay-list-item span {
|
||||
font: 9px var(--font-data);
|
||||
}
|
||||
.overlay-editor {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
.overlay-fields,
|
||||
.overlay-tabs {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.overlay-code {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
color: var(--ed-dim);
|
||||
background: var(--ui-surface-low);
|
||||
font: 12px/1.4 var(--font-data);
|
||||
}
|
||||
.overlay-preview-label {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.overlay-preview {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
border: 1px solid var(--ui-divider);
|
||||
background: #101217;
|
||||
}
|
||||
.bot-secret-row span {
|
||||
margin-top: 3px;
|
||||
color: var(--ed-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.version-label {
|
||||
display: block;
|
||||
margin-top: 13px;
|
||||
}
|
||||
.content-area {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) 168px;
|
||||
gap: 0;
|
||||
min-height: calc(100vh - 96px);
|
||||
border-top: 1px solid var(--ed-edge);
|
||||
border-left: 1px solid var(--ed-edge);
|
||||
}
|
||||
.panel {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--ed-edge);
|
||||
border-bottom: 1px solid var(--ed-edge);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: var(--ed-panel);
|
||||
}
|
||||
.preview-panel {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.chat-panel {
|
||||
grid-row: span 2;
|
||||
}
|
||||
.preview-stage {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
min-height: 220px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
background: color-mix(in srgb, var(--ed-accent) 3%, var(--ed-bg));
|
||||
text-align: center;
|
||||
}
|
||||
.twitch-player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: #000;
|
||||
}
|
||||
.offline-mark {
|
||||
color: var(--ed-fg);
|
||||
font: 700 24px var(--font-display);
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.offline-subtitle {
|
||||
margin-top: 5px;
|
||||
color: var(--ed-dim);
|
||||
font: 10px var(--font-data);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.panel-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
}
|
||||
.tab {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0 0 5px;
|
||||
color: var(--ed-dim);
|
||||
font: 10px var(--font-data);
|
||||
}
|
||||
.tab.active {
|
||||
border-bottom: 2px solid var(--ed-accent);
|
||||
color: var(--ed-accent);
|
||||
}
|
||||
.empty-state,
|
||||
.chat-empty {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
justify-items: center;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 18px 12px;
|
||||
color: var(--ed-dim);
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state strong,
|
||||
.chat-empty strong {
|
||||
color: var(--ed-fg);
|
||||
font: 600 14px var(--font-display);
|
||||
}
|
||||
.empty-state span,
|
||||
.chat-empty span {
|
||||
max-width: 260px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.empty-glyph {
|
||||
color: var(--ed-accent);
|
||||
font-size: 28px;
|
||||
}
|
||||
.activity-list {
|
||||
height: calc(100% - 70px);
|
||||
overflow: auto;
|
||||
}
|
||||
.activity-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
font: 10px var(--font-data);
|
||||
}
|
||||
.activity-type {
|
||||
color: var(--ed-fg);
|
||||
}
|
||||
.activity-time {
|
||||
color: var(--ed-dim);
|
||||
}
|
||||
.chat-composer {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--ui-divider);
|
||||
}
|
||||
.chat-identity-toggle,
|
||||
.chat-send-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.chat-identity {
|
||||
flex: 1;
|
||||
color: var(--ed-dim);
|
||||
font-size: 9px;
|
||||
}
|
||||
.chat-identity.active {
|
||||
border-color: var(--ui-line-focus);
|
||||
background: var(--ui-surface-hi);
|
||||
color: var(--ed-accent);
|
||||
}
|
||||
.chat-send-row .control {
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-composer .control {
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-composer .primary {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.quick-action {
|
||||
min-height: 52px;
|
||||
border: 1px solid var(--ui-line-soft);
|
||||
background: var(--ui-surface);
|
||||
color: var(--ed-dim);
|
||||
font: 10px var(--font-data);
|
||||
}
|
||||
.stream-editor {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--ui-divider);
|
||||
}
|
||||
.stream-editor label {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.category-options {
|
||||
display: grid;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ui-line);
|
||||
background: var(--ed-bg);
|
||||
}
|
||||
.category-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
border-radius: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.quick-action:hover,
|
||||
.quick-action.accent {
|
||||
border-color: var(--ui-line-focus);
|
||||
color: var(--ed-fg);
|
||||
background: var(--ui-surface-hi);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.workspace {
|
||||
grid-template-columns: 170px 1fr;
|
||||
}
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
.preview-panel {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.chat-panel {
|
||||
grid-row: span 1;
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.top-nav {
|
||||
display: none;
|
||||
}
|
||||
.workspace {
|
||||
display: block;
|
||||
}
|
||||
.side-panel {
|
||||
display: none;
|
||||
}
|
||||
.content-area {
|
||||
padding: 10px;
|
||||
}
|
||||
.dashboard-grid {
|
||||
display: block;
|
||||
}
|
||||
.panel {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.chat-panel {
|
||||
min-height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.collaboration-search-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.collaborator-results {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 8px 0 14px;
|
||||
}
|
||||
.collaborator-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--ui-divider);
|
||||
background: var(--ui-surface);
|
||||
}
|
||||
.collaborator-result .secondary {
|
||||
min-width: 28px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.chat-thread-panel,
|
||||
.thread-index-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.thread-index-row,
|
||||
.thread-reply {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ui-divider);
|
||||
background: transparent;
|
||||
color: var(--ed-fg);
|
||||
text-align: left;
|
||||
}
|
||||
.thread-index-row:hover {
|
||||
background: var(--ui-surface-hi);
|
||||
}
|
||||
.thread-root {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--ui-line-focus);
|
||||
}
|
||||
.thread-replies {
|
||||
margin: 6px 0 10px 18px;
|
||||
border-left: 2px solid var(--ui-line-focus);
|
||||
}
|
||||
.overlay-diagnostics-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.overlay-danger-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.danger {
|
||||
border: 1px solid var(--ed-danger);
|
||||
background: transparent;
|
||||
color: var(--ed-danger);
|
||||
padding: 7px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
.danger:hover {
|
||||
background: color-mix(in srgb, var(--ed-danger) 16%, transparent);
|
||||
}
|
||||
.diagnostics-toggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ui-error, #ff6b7a);
|
||||
font: inherit;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.diagnostics-details {
|
||||
padding: 10px;
|
||||
color: var(--ui-error, #ff6b7a);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
});
|
||||
Reference in New Issue
Block a user