Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e967cce1a1 | ||
| 16586c4a1b | |||
| 96f9c0ab45 | |||
| 5ee6675337 | |||
| cef1928379 | |||
| 4e80573cbd | |||
| ffca7f0cee | |||
| 97780304e7 | |||
| afbf0d81b8 | |||
| b45c683a51 | |||
| 5637e4c1f9 | |||
| b3e8f32734 | |||
| 92bc522c3a | |||
| df51d40094 | |||
| 7be8f454e0 | |||
| c98692e424 | |||
| 761bae5d01 | |||
| 8119292bcd | |||
| 9f44807230 | |||
| a062a825ea | |||
| b1862800a1 | |||
| 442eecefc0 | |||
| 5e2325507f | |||
| c736a710a1 | |||
| 786d3e47af | |||
| 03f62d413f | |||
| 7ab87384a7 | |||
| 65eff5cf50 | |||
| 008d2bf30b | |||
| 0eb38520ed | |||
| cdc182f0fb | |||
| f8bd4d1dab | |||
| ee900c6395 | |||
| abf499cb23 | |||
| abc55abb0b | |||
| 96b4fd7721 | |||
| 664f33d5ab | |||
| 67f3722b3c | |||
| 6ecb937eb6 | |||
| 43a9432b81 | |||
| f9bdecbfdb | |||
| bb73ba667f | |||
| d0de888dd1 | |||
| fbf9e26f5a | |||
| 9082e11490 | |||
| 8cb0b5b16b | |||
| 5d5cd26572 | |||
| 21189423f2 | |||
| bfcdbb5379 | |||
| 8a0193577b | |||
| a453e7984e | |||
| 0f258788f9 | |||
| 41691bfb2c | |||
| fef8111ff3 | |||
| 81e4b99a34 | |||
| 5c60a105c0 | |||
| 486debfc52 | |||
| ccc6ba9c02 |
@@ -57,11 +57,10 @@ jobs:
|
|||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: cd server && cargo fmt --all -- --check
|
run: cd server && cargo fmt --all -- --check
|
||||||
|
|
||||||
# Informational (warn-only) for now. The pre-spec codebase has ~65 lint warnings,
|
# Hard gate: clippy must pass with zero warnings (-D warnings). Dead-code that is
|
||||||
# mostly dead-code for API the integration spec (native-remote-control) will wire.
|
# future API surface for native-remote-control carries targeted #[allow(dead_code)].
|
||||||
# Re-tighten to `-- -D warnings` during the GC re-spec once that API is in use.
|
- name: Run Clippy
|
||||||
- name: Run Clippy (informational)
|
run: cd server && cargo clippy --all-targets --all-features -- -D warnings
|
||||||
run: cd server && cargo clippy --all-targets --all-features
|
|
||||||
|
|
||||||
- name: Build server
|
- name: Build server
|
||||||
run: |
|
run: |
|
||||||
@@ -143,12 +142,18 @@ jobs:
|
|||||||
- name: Install cargo-audit
|
- name: Install cargo-audit
|
||||||
run: cargo install cargo-audit
|
run: cargo install cargo-audit
|
||||||
|
|
||||||
# Informational (warn-only) for now, like clippy. GuruConnect is a single Cargo workspace,
|
# Hard gate: cargo audit must pass. GuruConnect is a single Cargo workspace, so one
|
||||||
# so one `cargo audit` at the root covers all members (agent + server) via the shared
|
# `cargo audit` at the root covers all members (agent + server) via the shared Cargo.lock.
|
||||||
# Cargo.lock. The pre-spec dependency tree has known advisories; re-tighten to a hard gate
|
# The advisories below are explicitly ignored with documented justifications; any NEW
|
||||||
# during the GC re-spec after a dependency refresh.
|
# advisory fails the build.
|
||||||
- name: Run security audit (informational)
|
# RUSTSEC-2023-0071 (rsa) ............. no fixed upgrade; optional/unreachable in active tree
|
||||||
run: cargo audit || echo "[WARNING] cargo audit reported advisories (informational; address in GC re-spec)"
|
# RUSTSEC-2024-0413/-0416/-0412/-0418/
|
||||||
|
# -0415/-0420/-0419 (gtk-rs GTK3) ..... Linux-only tray-icon backend, not compiled into shipping Windows agent
|
||||||
|
# RUSTSEC-2024-0429 (glib) ............ Linux-only tray-icon backend, not compiled into shipping Windows agent
|
||||||
|
# RUSTSEC-2024-0370 (proc-macro-error) build-time proc-macro dependency, no runtime impact
|
||||||
|
- name: Run security audit
|
||||||
|
run: |
|
||||||
|
cargo audit --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2024-0413 --ignore RUSTSEC-2024-0416 --ignore RUSTSEC-2024-0412 --ignore RUSTSEC-2024-0418 --ignore RUSTSEC-2024-0415 --ignore RUSTSEC-2024-0420 --ignore RUSTSEC-2024-0419 --ignore RUSTSEC-2024-0429 --ignore RUSTSEC-2024-0370
|
||||||
|
|
||||||
build-summary:
|
build-summary:
|
||||||
name: Build Summary
|
name: Build Summary
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ vendor/
|
|||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
*.generated.*
|
*.generated.*
|
||||||
|
|
||||||
|
# Built SPA (Vite build output served by the server; rebuilt from dashboard/)
|
||||||
|
/server/static/app/
|
||||||
|
|||||||
75
CHANGELOG.md
75
CHANGELOG.md
@@ -6,20 +6,66 @@ All notable changes to GuruConnect are documented here. Format follows
|
|||||||
Per-version entries below are generated from conventional commits (`feat:`, `fix:`, `perf:`)
|
Per-version entries below are generated from conventional commits (`feat:`, `fix:`, `perf:`)
|
||||||
by the release workflow; per-component changelogs are also written to
|
by the release workflow; per-component changelogs are also written to
|
||||||
`changelogs/<component>/v<version>.md` and served at `/api/changelog/...`.
|
`changelogs/<component>/v<version>.md` and served at `/api/changelog/...`.
|
||||||
|
## [0.3.0] - 2026-06-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
|
||||||
|
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
|
||||||
|
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
|
||||||
|
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
|
||||||
|
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
|
||||||
|
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
|
||||||
|
- GuruConnect v2 Users admin view (96b4fd77)
|
||||||
|
- GuruConnect v2 Support Codes view (664f33d5)
|
||||||
|
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
|
||||||
|
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
|
||||||
|
- GuruConnect v2 operator console (pass 1) (43a9432b)
|
||||||
|
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
|
||||||
|
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
|
||||||
|
- V2 secure-session-core Task 5 - attended consent (9082e114)
|
||||||
|
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
|
||||||
|
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
|
||||||
|
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
|
||||||
|
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
|
||||||
|
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Make native H.264 viewer render live frames (97780304)
|
||||||
|
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
|
||||||
|
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
|
||||||
|
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
|
||||||
|
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
|
||||||
|
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
|
||||||
|
- Clippy fixes for Task 4 (CI green) (21189423)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
|
||||||
|
|
||||||
|
### Spec
|
||||||
|
|
||||||
|
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
|
||||||
|
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
|
||||||
|
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
|
||||||
|
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
|
||||||
|
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
|
||||||
|
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
|
||||||
|
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
|
||||||
|
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
|
||||||
|
- Add SPEC-009 feature-rich documented API (7ab87384)
|
||||||
|
- Add SPEC-008 valuable error messages (65eff5cf)
|
||||||
|
- Add SPEC-007 managed-agent installer builder (008d2bf3)
|
||||||
|
- Add SPEC-006 universal machine search (0eb38520)
|
||||||
|
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
|
||||||
|
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
|
||||||
|
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
|
||||||
|
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
|
||||||
|
- Add v2-secure-session-core shape spec (81e4b99a)
|
||||||
|
|
||||||
## [0.2.2] - 2026-05-29
|
## [0.2.2] - 2026-05-29
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Drop broken jsign --info verify step in release (5727ccf3)
|
|
||||||
|
|
||||||
## [0.2.1] - 2026-05-29
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
|
|
||||||
|
|
||||||
## [0.2.0] - 2026-05-29
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
||||||
@@ -28,6 +74,11 @@ by the release workflow; per-component changelogs are also written to
|
|||||||
|
|
||||||
- Use Self:: for static method calls (cc35d111)
|
- Use Self:: for static method calls (cc35d111)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Drop broken jsign --info verify step in release (5727ccf3)
|
||||||
|
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Require authentication for all WebSocket and API endpoints (4614df04)
|
- Require authentication for all WebSocket and API endpoints (4614df04)
|
||||||
|
|||||||
150
Cargo.lock
generated
150
Cargo.lock
generated
@@ -735,19 +735,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dashmap"
|
|
||||||
version = "5.5.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"hashbrown 0.14.5",
|
|
||||||
"lock_api",
|
|
||||||
"once_cell",
|
|
||||||
"parking_lot_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -1075,31 +1062,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "forwarded-header-value"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
|
||||||
dependencies = [
|
|
||||||
"nonempty",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
|
||||||
dependencies = [
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-executor",
|
|
||||||
"futures-io",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
|
||||||
"futures-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1167,19 +1129,12 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-timer"
|
|
||||||
version = "3.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
@@ -1398,26 +1353,6 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "governor"
|
|
||||||
version = "0.6.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"dashmap",
|
|
||||||
"futures",
|
|
||||||
"futures-timer",
|
|
||||||
"no-std-compat",
|
|
||||||
"nonzero_ext",
|
|
||||||
"parking_lot",
|
|
||||||
"portable-atomic",
|
|
||||||
"quanta",
|
|
||||||
"rand 0.8.6",
|
|
||||||
"smallvec",
|
|
||||||
"spinning_top",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gtk"
|
name = "gtk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -1472,13 +1407,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guruconnect"
|
name = "guruconnect"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"hex",
|
||||||
"hostname",
|
"hostname",
|
||||||
"image",
|
"image",
|
||||||
"muda",
|
"muda",
|
||||||
@@ -1511,7 +1447,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guruconnect-server"
|
name = "guruconnect-server"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1534,18 +1470,11 @@ dependencies = [
|
|||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower_governor",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -2362,24 +2291,6 @@ dependencies = [
|
|||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "no-std-compat"
|
|
||||||
version = "0.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nonempty"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nonzero_ext"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -3035,12 +2946,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic"
|
|
||||||
version = "1.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -3218,21 +3123,6 @@ version = "0.1.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quanta"
|
|
||||||
version = "0.12.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"raw-cpuid",
|
|
||||||
"wasi",
|
|
||||||
"web-sys",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -3377,15 +3267,6 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "raw-cpuid"
|
|
||||||
version = "11.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3960,15 +3841,6 @@ dependencies = [
|
|||||||
"lock_api",
|
"lock_api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spinning_top"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
|
||||||
dependencies = [
|
|
||||||
"lock_api",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spki"
|
name = "spki"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -4653,22 +4525,6 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tower_governor"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "aea939ea6cfa7c4880f3e7422616624f97a567c16df67b53b11f0d03917a8e46"
|
|
||||||
dependencies = [
|
|
||||||
"axum",
|
|
||||||
"forwarded-header-value",
|
|
||||||
"governor",
|
|
||||||
"http",
|
|
||||||
"pin-project",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tower",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["AZ Computer Guru"]
|
authors = ["AZ Computer Guru"]
|
||||||
license = "Proprietary"
|
license = "Proprietary"
|
||||||
@@ -25,3 +25,8 @@ anyhow = "1"
|
|||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "guruconnect"
|
name = "guruconnect"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["AZ Computer Guru"]
|
authors = ["AZ Computer Guru"]
|
||||||
description = "GuruConnect Remote Desktop - Agent and Viewer"
|
description = "GuruConnect Remote Desktop - Agent and Viewer"
|
||||||
@@ -47,6 +47,7 @@ toml = "0.8"
|
|||||||
# Crypto
|
# Crypto
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
# HTTP client for updates
|
# HTTP client for updates
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
@@ -95,6 +96,13 @@ windows = { version = "0.58", features = [
|
|||||||
"Win32_System_Pipes",
|
"Win32_System_Pipes",
|
||||||
"Win32_System_SystemServices",
|
"Win32_System_SystemServices",
|
||||||
"Win32_System_IO",
|
"Win32_System_IO",
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_System_Com_StructuredStorage",
|
||||||
|
"Win32_System_Ole",
|
||||||
|
"Win32_System_Variant",
|
||||||
|
"Win32_Media_MediaFoundation",
|
||||||
|
"Win32_Media_KernelStreaming",
|
||||||
|
"Win32_Media_DirectShow",
|
||||||
]}
|
]}
|
||||||
|
|
||||||
# Windows service support
|
# Windows service support
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
//! The agent communicates with this service via named pipe IPC.
|
//! The agent communicates with this service via named pipe IPC.
|
||||||
|
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::io::{Read, Write as IoWrite};
|
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -37,7 +36,19 @@ const PIPE_READMODE_MESSAGE: u32 = 0x00000002;
|
|||||||
const PIPE_WAIT: u32 = 0x00000000;
|
const PIPE_WAIT: u32 = 0x00000000;
|
||||||
const PIPE_UNLIMITED_INSTANCES: u32 = 255;
|
const PIPE_UNLIMITED_INSTANCES: u32 = 255;
|
||||||
const INVALID_HANDLE_VALUE: isize = -1;
|
const INVALID_HANDLE_VALUE: isize = -1;
|
||||||
const SECURITY_DESCRIPTOR_REVISION: u32 = 1;
|
/// SDDL revision passed to `ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||||
|
/// (`SDDL_REVISION_1`).
|
||||||
|
const SDDL_REVISION_1: u32 = 1;
|
||||||
|
|
||||||
|
/// Restrictive DACL for the SAS named pipe, in SDDL form.
|
||||||
|
///
|
||||||
|
/// `D:` introduces the DACL; `(A;;GA;;;AU)` is an ACE granting GENERIC_ALL (`GA`) to
|
||||||
|
/// Authenticated Users (`AU`). Anonymous / null-session callers are NOT authenticated and
|
||||||
|
/// are therefore denied — closing the original NULL-DACL hole where any local process
|
||||||
|
/// (Everyone) could connect and make this SYSTEM service raise the secure-attention
|
||||||
|
/// screen. The agent runs in the interactive logon session and IS an authenticated user,
|
||||||
|
/// so it can still connect and request a SAS.
|
||||||
|
const PIPE_SDDL: &str = "D:(A;;GA;;;AU)";
|
||||||
|
|
||||||
// FFI declarations for named pipe operations
|
// FFI declarations for named pipe operations
|
||||||
#[link(name = "kernel32")]
|
#[link(name = "kernel32")]
|
||||||
@@ -71,19 +82,23 @@ extern "system" {
|
|||||||
lpOverlapped: *mut std::ffi::c_void,
|
lpOverlapped: *mut std::ffi::c_void,
|
||||||
) -> i32;
|
) -> i32;
|
||||||
fn FlushFileBuffers(hFile: isize) -> i32;
|
fn FlushFileBuffers(hFile: isize) -> i32;
|
||||||
|
fn LocalFree(hMem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[link(name = "advapi32")]
|
#[link(name = "advapi32")]
|
||||||
extern "system" {
|
extern "system" {
|
||||||
fn InitializeSecurityDescriptor(pSecurityDescriptor: *mut u8, dwRevision: u32) -> i32;
|
/// Build a self-relative security descriptor from an SDDL string. The descriptor is
|
||||||
fn SetSecurityDescriptorDacl(
|
/// allocated with `LocalAlloc` and must be released with `LocalFree`.
|
||||||
pSecurityDescriptor: *mut u8,
|
fn ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
bDaclPresent: i32,
|
StringSecurityDescriptor: *const u16,
|
||||||
pDacl: *mut std::ffi::c_void,
|
StringSDRevision: u32,
|
||||||
bDaclDefaulted: i32,
|
SecurityDescriptor: *mut *mut std::ffi::c_void,
|
||||||
|
SecurityDescriptorSize: *mut u32,
|
||||||
) -> i32;
|
) -> i32;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Field names mirror the Win32 SECURITY_ATTRIBUTES ABI struct.
|
||||||
|
#[allow(non_snake_case)]
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
struct SECURITY_ATTRIBUTES {
|
struct SECURITY_ATTRIBUTES {
|
||||||
nLength: u32,
|
nLength: u32,
|
||||||
@@ -280,26 +295,31 @@ fn run_pipe_server() -> Result<()> {
|
|||||||
tracing::info!("Starting pipe server on {}", PIPE_NAME);
|
tracing::info!("Starting pipe server on {}", PIPE_NAME);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Create security descriptor that allows everyone
|
// Build a restrictive security descriptor from SDDL: grant access only to
|
||||||
let mut sd = [0u8; 256];
|
// Authenticated Users (excludes anonymous / null-session callers). See PIPE_SDDL.
|
||||||
unsafe {
|
let sddl: Vec<u16> = PIPE_SDDL.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
if InitializeSecurityDescriptor(sd.as_mut_ptr(), SECURITY_DESCRIPTOR_REVISION) == 0 {
|
let mut sd_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
|
||||||
tracing::error!("Failed to initialize security descriptor");
|
let converted = unsafe {
|
||||||
|
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
|
sddl.as_ptr(),
|
||||||
|
SDDL_REVISION_1,
|
||||||
|
&mut sd_ptr,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if converted == 0 || sd_ptr.is_null() {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to build pipe security descriptor from SDDL: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
std::thread::sleep(Duration::from_secs(1));
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set NULL DACL = allow everyone
|
|
||||||
if SetSecurityDescriptorDacl(sd.as_mut_ptr(), 1, std::ptr::null_mut(), 0) == 0 {
|
|
||||||
tracing::error!("Failed to set security descriptor DACL");
|
|
||||||
std::thread::sleep(Duration::from_secs(1));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sa = SECURITY_ATTRIBUTES {
|
let mut sa = SECURITY_ATTRIBUTES {
|
||||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
lpSecurityDescriptor: sd.as_mut_ptr(),
|
lpSecurityDescriptor: sd_ptr as *mut u8,
|
||||||
bInheritHandle: 0,
|
bInheritHandle: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -320,6 +340,12 @@ fn run_pipe_server() -> Result<()> {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CreateNamedPipeW copies the descriptor into the kernel object, so the SDDL-built
|
||||||
|
// copy can be freed now regardless of success.
|
||||||
|
unsafe {
|
||||||
|
LocalFree(sd_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
if pipe == INVALID_HANDLE_VALUE {
|
if pipe == INVALID_HANDLE_VALUE {
|
||||||
tracing::error!("Failed to create named pipe");
|
tracing::error!("Failed to create named pipe");
|
||||||
std::thread::sleep(Duration::from_secs(1));
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
@@ -403,6 +429,69 @@ fn run_pipe_server() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable the `SoftwareSASGeneration` Winlogon policy so `SendSAS` is permitted.
|
||||||
|
///
|
||||||
|
/// Without this policy, `sas.dll!SendSAS` is a silent no-op even when called from
|
||||||
|
/// SYSTEM. The value lives at
|
||||||
|
/// `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\SoftwareSASGeneration`
|
||||||
|
/// and is a DWORD bitmask:
|
||||||
|
/// 0 = none, 1 = services, 2 = ease-of-access apps, 3 = both.
|
||||||
|
///
|
||||||
|
/// We set `1` (services) because the GuruConnect SAS helper runs as a SYSTEM service.
|
||||||
|
/// This is invoked from the SAS service installer; the broader agent installer should
|
||||||
|
/// ensure this runs (see `// TODO(installer)` below).
|
||||||
|
fn set_software_sas_policy() -> Result<()> {
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::System::Registry::{
|
||||||
|
RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_LOCAL_MACHINE, KEY_SET_VALUE,
|
||||||
|
REG_DWORD, REG_OPTION_NON_VOLATILE,
|
||||||
|
};
|
||||||
|
|
||||||
|
let subkey: Vec<u16> = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
|
||||||
|
.encode_utf16()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let value_name: Vec<u16> = "SoftwareSASGeneration"
|
||||||
|
.encode_utf16()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
// DWORD 1 = allow services to generate a software SAS.
|
||||||
|
let data: u32 = 1;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut hkey = HKEY::default();
|
||||||
|
let status = RegCreateKeyExW(
|
||||||
|
HKEY_LOCAL_MACHINE,
|
||||||
|
PCWSTR(subkey.as_ptr()),
|
||||||
|
0,
|
||||||
|
PCWSTR::null(),
|
||||||
|
REG_OPTION_NON_VOLATILE,
|
||||||
|
KEY_SET_VALUE,
|
||||||
|
None,
|
||||||
|
&mut hkey,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
if status.is_err() {
|
||||||
|
anyhow::bail!("RegCreateKeyExW(Policies\\System) failed: {:?}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
let set = RegSetValueExW(
|
||||||
|
hkey,
|
||||||
|
PCWSTR(value_name.as_ptr()),
|
||||||
|
0,
|
||||||
|
REG_DWORD,
|
||||||
|
Some(&data.to_ne_bytes()),
|
||||||
|
);
|
||||||
|
let _ = RegCloseKey(hkey);
|
||||||
|
|
||||||
|
if set.is_err() {
|
||||||
|
anyhow::bail!("RegSetValueExW(SoftwareSASGeneration) failed: {:?}", set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Call SendSAS via sas.dll
|
/// Call SendSAS via sas.dll
|
||||||
fn send_sas() -> Result<()> {
|
fn send_sas() -> Result<()> {
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -505,6 +594,19 @@ fn install_service() -> Result<()> {
|
|||||||
])
|
])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
|
// Enable the SoftwareSASGeneration policy so SendSAS actually works from the
|
||||||
|
// SYSTEM service. TODO(installer): the top-level managed agent installer should
|
||||||
|
// also ensure this policy is set (and that this SAS service is installed) as part
|
||||||
|
// of unattended deployment, rather than relying on a manual SAS-service install.
|
||||||
|
match set_software_sas_policy() {
|
||||||
|
Ok(()) => println!("Enabled SoftwareSASGeneration policy (services)"),
|
||||||
|
Err(e) => println!(
|
||||||
|
"Warning: failed to set SoftwareSASGeneration policy: {}. \
|
||||||
|
Ctrl+Alt+Del may not reach the secure desktop until this is set.",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
println!("\n** GuruConnect SAS Service installed successfully!");
|
println!("\n** GuruConnect SAS Service installed successfully!");
|
||||||
println!("\nBinary: {:?}", binary_dest);
|
println!("\nBinary: {:?}", binary_dest);
|
||||||
println!("\nStarting service...");
|
println!("\nStarting service...");
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub struct Display {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Display info for protocol messages
|
/// Display info for protocol messages
|
||||||
|
// Future use: multi-display protocol negotiation.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DisplayInfo {
|
pub struct DisplayInfo {
|
||||||
pub displays: Vec<Display>,
|
pub displays: Vec<Display>,
|
||||||
@@ -40,11 +42,13 @@ pub struct DisplayInfo {
|
|||||||
|
|
||||||
impl Display {
|
impl Display {
|
||||||
/// Total pixels in the display
|
/// Total pixels in the display
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn pixel_count(&self) -> u32 {
|
pub fn pixel_count(&self) -> u32 {
|
||||||
self.width * self.height
|
self.width * self.height
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bytes needed for BGRA frame buffer
|
/// Bytes needed for BGRA frame buffer
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn buffer_size(&self) -> usize {
|
pub fn buffer_size(&self) -> usize {
|
||||||
(self.width * self.height * 4) as usize
|
(self.width * self.height * 4) as usize
|
||||||
}
|
}
|
||||||
@@ -60,7 +64,6 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut displays = Vec::new();
|
let mut displays = Vec::new();
|
||||||
let mut display_id = 0u32;
|
|
||||||
|
|
||||||
// Callback for EnumDisplayMonitors
|
// Callback for EnumDisplayMonitors
|
||||||
unsafe extern "system" fn enum_callback(
|
unsafe extern "system" fn enum_callback(
|
||||||
@@ -148,6 +151,8 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get display info for protocol
|
/// Get display info for protocol
|
||||||
|
// Future use: multi-display protocol negotiation.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_display_info() -> Result<DisplayInfo> {
|
pub fn get_display_info() -> Result<DisplayInfo> {
|
||||||
let displays = enumerate_displays()?;
|
let displays = enumerate_displays()?;
|
||||||
let primary_id = displays
|
let primary_id = displays
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub struct DxgiCapturer {
|
|||||||
staging_texture: Option<ID3D11Texture2D>,
|
staging_texture: Option<ID3D11Texture2D>,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
// Future use: frame diffing against the previously captured frame.
|
||||||
|
#[allow(dead_code)]
|
||||||
last_frame: Option<Vec<u8>>,
|
last_frame: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ impl GdiCapturer {
|
|||||||
|
|
||||||
let bitmap = CreateCompatibleBitmap(screen_dc, self.width as i32, self.height as i32);
|
let bitmap = CreateCompatibleBitmap(screen_dc, self.width as i32, self.height as i32);
|
||||||
if bitmap.is_invalid() {
|
if bitmap.is_invalid() {
|
||||||
DeleteDC(mem_dc);
|
let _ = DeleteDC(mem_dc);
|
||||||
ReleaseDC(HWND::default(), screen_dc);
|
ReleaseDC(HWND::default(), screen_dc);
|
||||||
anyhow::bail!("Failed to create compatible bitmap");
|
anyhow::bail!("Failed to create compatible bitmap");
|
||||||
}
|
}
|
||||||
@@ -69,8 +69,8 @@ impl GdiCapturer {
|
|||||||
SRCCOPY,
|
SRCCOPY,
|
||||||
) {
|
) {
|
||||||
SelectObject(mem_dc, old_bitmap);
|
SelectObject(mem_dc, old_bitmap);
|
||||||
DeleteObject(bitmap);
|
let _ = DeleteObject(bitmap);
|
||||||
DeleteDC(mem_dc);
|
let _ = DeleteDC(mem_dc);
|
||||||
ReleaseDC(HWND::default(), screen_dc);
|
ReleaseDC(HWND::default(), screen_dc);
|
||||||
anyhow::bail!("BitBlt failed: {}", e);
|
anyhow::bail!("BitBlt failed: {}", e);
|
||||||
}
|
}
|
||||||
@@ -110,8 +110,8 @@ impl GdiCapturer {
|
|||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
SelectObject(mem_dc, old_bitmap);
|
SelectObject(mem_dc, old_bitmap);
|
||||||
DeleteObject(bitmap);
|
let _ = DeleteObject(bitmap);
|
||||||
DeleteDC(mem_dc);
|
let _ = DeleteDC(mem_dc);
|
||||||
ReleaseDC(HWND::default(), screen_dc);
|
ReleaseDC(HWND::default(), screen_dc);
|
||||||
|
|
||||||
if lines == 0 {
|
if lines == 0 {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ mod dxgi;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod gdi;
|
mod gdi;
|
||||||
|
|
||||||
pub use display::{Display, DisplayInfo};
|
pub use display::Display;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -33,6 +33,8 @@ pub struct CapturedFrame {
|
|||||||
pub display_id: u32,
|
pub display_id: u32,
|
||||||
|
|
||||||
/// Regions that changed since last frame (if available)
|
/// Regions that changed since last frame (if available)
|
||||||
|
// Populated by capturers; not yet consumed by the encoder pipeline.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub dirty_rects: Option<Vec<DirtyRect>>,
|
pub dirty_rects: Option<Vec<DirtyRect>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +55,11 @@ pub trait Capturer: Send {
|
|||||||
fn capture(&mut self) -> Result<Option<CapturedFrame>>;
|
fn capture(&mut self) -> Result<Option<CapturedFrame>>;
|
||||||
|
|
||||||
/// Get the current display info
|
/// Get the current display info
|
||||||
|
#[allow(dead_code)]
|
||||||
fn display(&self) -> &Display;
|
fn display(&self) -> &Display;
|
||||||
|
|
||||||
/// Check if capturer is still valid (display may have changed)
|
/// Check if capturer is still valid (display may have changed)
|
||||||
|
#[allow(dead_code)]
|
||||||
fn is_valid(&self) -> bool;
|
fn is_valid(&self) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,13 @@
|
|||||||
use std::sync::mpsc::{self, Receiver, Sender};
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tracing::{error, info, warn};
|
use tracing::info;
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::Foundation::*;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use windows::Win32::Graphics::Gdi::*;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||||
|
|
||||||
/// A chat message
|
/// A chat message
|
||||||
@@ -29,11 +25,15 @@ pub struct ChatMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Commands that can be sent to the chat window
|
/// Commands that can be sent to the chat window
|
||||||
|
// Show/Hide/Close are part of the chat control API but not yet driven by the session loop.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ChatCommand {
|
pub enum ChatCommand {
|
||||||
|
#[allow(dead_code)]
|
||||||
Show,
|
Show,
|
||||||
|
#[allow(dead_code)]
|
||||||
Hide,
|
Hide,
|
||||||
AddMessage(ChatMessage),
|
AddMessage(ChatMessage),
|
||||||
|
#[allow(dead_code)]
|
||||||
Close,
|
Close,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +69,13 @@ impl ChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Show the chat window
|
/// Show the chat window
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn show(&self) {
|
pub fn show(&self) {
|
||||||
let _ = self.command_tx.send(ChatCommand::Show);
|
let _ = self.command_tx.send(ChatCommand::Show);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hide the chat window
|
/// Hide the chat window
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn hide(&self) {
|
pub fn hide(&self) {
|
||||||
let _ = self.command_tx.send(ChatCommand::Hide);
|
let _ = self.command_tx.send(ChatCommand::Hide);
|
||||||
}
|
}
|
||||||
@@ -93,16 +95,14 @@ impl ChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Close the chat window
|
/// Close the chat window
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn close(&self) {
|
pub fn close(&self) {
|
||||||
let _ = self.command_tx.send(ChatCommand::Close);
|
let _ = self.command_tx.send(ChatCommand::Close);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn run_chat_window(command_rx: Receiver<ChatCommand>, message_tx: Sender<ChatMessage>) {
|
fn run_chat_window(command_rx: Receiver<ChatCommand>, _message_tx: Sender<ChatMessage>) {
|
||||||
use std::ffi::OsStr;
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
|
||||||
|
|
||||||
info!("Starting chat window thread");
|
info!("Starting chat window thread");
|
||||||
|
|
||||||
// For now, we'll use a simple message box approach
|
// For now, we'll use a simple message box approach
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use anyhow::{anyhow, Context, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing::{info, warn};
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Magic marker for embedded configuration (10 bytes)
|
/// Magic marker for embedded configuration (10 bytes)
|
||||||
@@ -196,7 +196,7 @@ impl Config {
|
|||||||
/// Extract 6-digit support code from filename
|
/// Extract 6-digit support code from filename
|
||||||
fn extract_support_code(filename: &str) -> Option<String> {
|
fn extract_support_code(filename: &str) -> Option<String> {
|
||||||
// Look for patterns like "GuruConnect-123456" or "GuruConnect_123456"
|
// Look for patterns like "GuruConnect-123456" or "GuruConnect_123456"
|
||||||
for part in filename.split(|c| c == '-' || c == '_' || c == '.') {
|
for part in filename.split(['-', '_', '.']) {
|
||||||
let trimmed = part.trim();
|
let trimmed = part.trim();
|
||||||
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
|
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
|
||||||
return Some(trimmed.to_string());
|
return Some(trimmed.to_string());
|
||||||
@@ -435,6 +435,8 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Example configuration file content
|
/// Example configuration file content
|
||||||
|
// Retained for documentation / config-template generation.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn example_config() -> &'static str {
|
pub fn example_config() -> &'static str {
|
||||||
r#"# GuruConnect Agent Configuration
|
r#"# GuruConnect Agent Configuration
|
||||||
|
|
||||||
|
|||||||
157
agent/src/consent/mod.rs
Normal file
157
agent/src/consent/mod.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
//! Attended-mode consent prompt (Task 5).
|
||||||
|
//!
|
||||||
|
//! For an attended (support-code) session, the GuruConnect server sends the
|
||||||
|
//! agent a `ConsentRequest` before the technician's session is allowed to go
|
||||||
|
//! live. The agent shows the end user a native dialog ("Allow <technician> to
|
||||||
|
//! VIEW/CONTROL this computer?") and returns the user's choice as a
|
||||||
|
//! `ConsentResponse`. The server holds the session in `consent_state = pending`
|
||||||
|
//! and tears it down on a denial or timeout.
|
||||||
|
//!
|
||||||
|
//! v1 uses a Windows `MessageBox` (Yes/No, top-most, foreground). It is
|
||||||
|
//! synchronous and reliable on every supported Windows version (7 SP1+), needs
|
||||||
|
//! no extra windowing, and cannot be dismissed into an ambiguous state — the
|
||||||
|
//! only outcomes are Yes (allow), No (deny), or the box being closed (treated
|
||||||
|
//! as deny). A nicer custom branded dialog (countdown, technician avatar) is a
|
||||||
|
//! possible future refinement; it is not required for correctness.
|
||||||
|
//!
|
||||||
|
//! The decision is the end user's and is purely advisory to the agent: the
|
||||||
|
//! server is the enforcement point (it will not surface the session to the
|
||||||
|
//! technician until it receives a `granted` response). The agent simply relays
|
||||||
|
//! the human's choice.
|
||||||
|
|
||||||
|
/// Whether the technician requested view-only or full control, used only to
|
||||||
|
/// phrase the prompt. Mirrors `proto::ConsentAccessMode`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConsentAccessMode {
|
||||||
|
View,
|
||||||
|
Control,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsentAccessMode {
|
||||||
|
/// Decode the proto enum value (defaults to the more conservative `Control`
|
||||||
|
/// wording on an unknown value so the prompt never under-states access).
|
||||||
|
pub fn from_proto(value: i32) -> Self {
|
||||||
|
match crate::proto::ConsentAccessMode::try_from(value) {
|
||||||
|
Ok(crate::proto::ConsentAccessMode::ConsentView) => ConsentAccessMode::View,
|
||||||
|
Ok(crate::proto::ConsentAccessMode::ConsentControl) => ConsentAccessMode::Control,
|
||||||
|
Err(_) => ConsentAccessMode::Control,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verb(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ConsentAccessMode::View => "VIEW",
|
||||||
|
ConsentAccessMode::Control => "VIEW and CONTROL",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the consent prompt body shown to the end user.
|
||||||
|
fn prompt_body(technician_name: &str, access: ConsentAccessMode) -> String {
|
||||||
|
let who = if technician_name.trim().is_empty() {
|
||||||
|
"A support technician"
|
||||||
|
} else {
|
||||||
|
technician_name
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{who} is requesting a remote support session.\n\n\
|
||||||
|
If you allow this, they will be able to {verb} this computer.\n\n\
|
||||||
|
Do you want to allow this remote support session?",
|
||||||
|
who = who,
|
||||||
|
verb = access.verb()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the consent dialog and return the end user's decision.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the user ALLOWED the session, `false` if they denied it or
|
||||||
|
/// the dialog was closed/could not be shown. Blocking — callers should run this
|
||||||
|
/// off the async runtime (e.g. `tokio::task::spawn_blocking`).
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn prompt_consent(technician_name: &str, access: ConsentAccessMode) -> bool {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
|
MessageBoxW, IDYES, MB_ICONQUESTION, MB_SETFOREGROUND, MB_SYSTEMMODAL, MB_TOPMOST, MB_YESNO,
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = "GuruConnect - Remote Support Request";
|
||||||
|
let body = prompt_body(technician_name, access);
|
||||||
|
|
||||||
|
let title_wide: Vec<u16> = OsStr::new(title)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let body_wide: Vec<u16> = OsStr::new(&body)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// MB_YESNO - explicit Allow (Yes) / Deny (No)
|
||||||
|
// MB_ICONQUESTION - prompt styling
|
||||||
|
// MB_TOPMOST - sit above other windows so it cannot be hidden
|
||||||
|
// MB_SETFOREGROUND - bring to the foreground
|
||||||
|
// MB_SYSTEMMODAL - ensure visibility even from a service/elevated context
|
||||||
|
let result = unsafe {
|
||||||
|
MessageBoxW(
|
||||||
|
None,
|
||||||
|
PCWSTR(body_wide.as_ptr()),
|
||||||
|
PCWSTR(title_wide.as_ptr()),
|
||||||
|
MB_YESNO | MB_ICONQUESTION | MB_TOPMOST | MB_SETFOREGROUND | MB_SYSTEMMODAL,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Any outcome other than an explicit "Yes" is a denial (including the box
|
||||||
|
// being closed, which returns IDNO/IDCANCEL-style values).
|
||||||
|
result == IDYES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-Windows stub. The agent is Windows-first; on other platforms there is no
|
||||||
|
/// native end-user consent surface yet, so we fail CLOSED (deny) rather than
|
||||||
|
/// silently allowing an unattended session.
|
||||||
|
///
|
||||||
|
// TODO(platform): provide a real consent dialog on macOS/Linux when the agent
|
||||||
|
// is ported there (e.g. a GTK/Cocoa modal). Until then, deny so a non-Windows
|
||||||
|
// build can never grant an attended session without an explicit human prompt.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn prompt_consent(_technician_name: &str, _access: ConsentAccessMode) -> bool {
|
||||||
|
tracing::warn!(
|
||||||
|
"Consent prompt requested on a non-Windows build; no native dialog available — denying"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_body_uses_control_wording() {
|
||||||
|
let body = prompt_body("Mike", ConsentAccessMode::Control);
|
||||||
|
assert!(body.contains("Mike"));
|
||||||
|
assert!(body.contains("VIEW and CONTROL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_body_uses_view_wording() {
|
||||||
|
let body = prompt_body("Mike", ConsentAccessMode::View);
|
||||||
|
assert!(body.contains("VIEW"));
|
||||||
|
assert!(!body.contains("CONTROL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_body_falls_back_on_empty_name() {
|
||||||
|
let body = prompt_body(" ", ConsentAccessMode::Control);
|
||||||
|
assert!(body.contains("A support technician"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_mode_from_proto_defaults_to_control() {
|
||||||
|
// An out-of-range proto value must not under-state access.
|
||||||
|
assert_eq!(
|
||||||
|
ConsentAccessMode::from_proto(999),
|
||||||
|
ConsentAccessMode::Control
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
agent/src/encoder/capability.rs
Normal file
97
agent/src/encoder/capability.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
//! Hardware video-encode capability detection (Task 7).
|
||||||
|
//!
|
||||||
|
//! Probes Windows Media Foundation for a HARDWARE H.264 encoder MFT at startup.
|
||||||
|
//! The result is cached and advertised to the server in `AgentStatus.supports_h264`
|
||||||
|
//! so the server can negotiate the codec (see `StartStream.video_codec`).
|
||||||
|
//!
|
||||||
|
//! Detection is intentionally cheap and side-effect-free: it only ENUMERATES the
|
||||||
|
//! available encoder MFTs (it does not create or initialize one). A `true` result
|
||||||
|
//! means a hardware H.264 encoder was advertised by the OS; it does NOT guarantee
|
||||||
|
//! the encoder will successfully initialize at stream time — the H.264 encoder
|
||||||
|
//! still falls back to raw on any init/feed failure.
|
||||||
|
//!
|
||||||
|
//! On non-Windows targets, or if MF is unavailable, this reports `false`.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Cached capability result. Detection runs at most once per process.
|
||||||
|
static SUPPORTS_H264: OnceLock<bool> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Return whether this machine has a hardware H.264 encoder, detecting once and
|
||||||
|
/// caching the result. Safe to call repeatedly and from any thread.
|
||||||
|
pub fn supports_hardware_h264() -> bool {
|
||||||
|
*SUPPORTS_H264.get_or_init(detect_hardware_h264)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the actual detection. Separated so the cached accessor stays trivial.
|
||||||
|
fn detect_hardware_h264() -> bool {
|
||||||
|
let supported = detect_inner();
|
||||||
|
if supported {
|
||||||
|
tracing::info!("Hardware H.264 encoder detected (Media Foundation)");
|
||||||
|
} else {
|
||||||
|
tracing::info!("No hardware H.264 encoder detected; raw+Zstd only");
|
||||||
|
}
|
||||||
|
supported
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn detect_inner() -> bool {
|
||||||
|
// Enumerate hardware H.264 encoder MFTs. This is a read-only probe; it does
|
||||||
|
// not init D3D, COM apartments persistently, or create the encoder.
|
||||||
|
match unsafe { enumerate_hardware_h264() } {
|
||||||
|
Ok(found) => found,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("H.264 capability probe failed: {e:#}; assuming no HW encoder");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn detect_inner() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn enumerate_hardware_h264() -> anyhow::Result<bool> {
|
||||||
|
use windows::Win32::Media::MediaFoundation::{
|
||||||
|
MFMediaType_Video, MFTEnumEx, MFVideoFormat_H264, MFT_CATEGORY_VIDEO_ENCODER,
|
||||||
|
MFT_ENUM_FLAG_HARDWARE, MFT_ENUM_FLAG_SORTANDFILTER, MFT_ENUM_FLAG_TRANSCODE_ONLY,
|
||||||
|
MFT_REGISTER_TYPE_INFO,
|
||||||
|
};
|
||||||
|
|
||||||
|
// We only specify the OUTPUT type (H.264); input is left unconstrained so the
|
||||||
|
// probe matches encoders regardless of their preferred input subtype.
|
||||||
|
let output_type = MFT_REGISTER_TYPE_INFO {
|
||||||
|
guidMajorType: MFMediaType_Video,
|
||||||
|
guidSubtype: MFVideoFormat_H264,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut activate_ptr: *mut Option<windows::Win32::Media::MediaFoundation::IMFActivate> =
|
||||||
|
std::ptr::null_mut();
|
||||||
|
let mut count: u32 = 0;
|
||||||
|
|
||||||
|
// MFTEnumEx does not itself require MFStartup for a pure enumeration, but we
|
||||||
|
// guard with a Result so any HRESULT failure degrades to "no HW encoder".
|
||||||
|
MFTEnumEx(
|
||||||
|
MFT_CATEGORY_VIDEO_ENCODER,
|
||||||
|
MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_SORTANDFILTER | MFT_ENUM_FLAG_TRANSCODE_ONLY,
|
||||||
|
None, // input type: any
|
||||||
|
Some(&output_type as *const _),
|
||||||
|
&mut activate_ptr,
|
||||||
|
&mut count,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Release every returned IMFActivate, then free the array CoTaskMemAlloc'd by MF.
|
||||||
|
let found = count > 0;
|
||||||
|
if !activate_ptr.is_null() {
|
||||||
|
let slice = std::slice::from_raw_parts_mut(activate_ptr, count as usize);
|
||||||
|
for entry in slice.iter_mut() {
|
||||||
|
// Dropping the Option<IMFActivate> releases the COM reference.
|
||||||
|
entry.take();
|
||||||
|
}
|
||||||
|
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
269
agent/src/encoder/color.rs
Normal file
269
agent/src/encoder/color.rs
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
//! Color-space conversion for the H.264 encode path (Task 7).
|
||||||
|
//!
|
||||||
|
//! Screen capture produces BGRA (4 bytes/pixel, B,G,R,A order — the DXGI/GDI
|
||||||
|
//! native layout). Media Foundation hardware H.264 encoders want NV12: a full-
|
||||||
|
//! resolution 8-bit Y (luma) plane followed by an interleaved half-resolution
|
||||||
|
//! U/V (chroma) plane. This module does that conversion in software.
|
||||||
|
//!
|
||||||
|
//! NV12 memory layout for a `width x height` frame (width/height assumed even):
|
||||||
|
//! - Y plane: `width * height` bytes, row-major.
|
||||||
|
//! - UV plane: `width * (height / 2)` bytes — for each 2x2 luma block one
|
||||||
|
//! (U, V) pair, so the plane is `(width/2)` (U,V) pairs per row over
|
||||||
|
//! `height/2` rows, i.e. `width` bytes per chroma row.
|
||||||
|
//!
|
||||||
|
//! Total size = `width * height * 3 / 2`.
|
||||||
|
//!
|
||||||
|
//! The coefficients are BT.601 "studio swing" (limited range, 16..235 luma),
|
||||||
|
//! which is what MF H.264 encoders expect by default. Chroma is computed by
|
||||||
|
//! averaging the 2x2 BGRA block before conversion (box downsample) to reduce
|
||||||
|
//! aliasing.
|
||||||
|
|
||||||
|
/// Size in bytes of an NV12 buffer for `width` x `height` (both even).
|
||||||
|
#[inline]
|
||||||
|
pub fn nv12_size(width: u32, height: u32) -> usize {
|
||||||
|
(width as usize * height as usize) * 3 / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BT.601 limited-range luma from 8-bit R,G,B.
|
||||||
|
#[inline]
|
||||||
|
fn rgb_to_y(r: i32, g: i32, b: i32) -> u8 {
|
||||||
|
// Y = 16 + (65.481*R + 128.553*G + 24.966*B) / 255, fixed-point.
|
||||||
|
// Using the common integer approximation:
|
||||||
|
// Y = ((66*R + 129*G + 25*B + 128) >> 8) + 16
|
||||||
|
let y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
|
||||||
|
y.clamp(0, 255) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BT.601 limited-range Cb (U) from 8-bit R,G,B.
|
||||||
|
#[inline]
|
||||||
|
fn rgb_to_u(r: i32, g: i32, b: i32) -> u8 {
|
||||||
|
let u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
|
||||||
|
u.clamp(0, 255) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BT.601 limited-range Cr (V) from 8-bit R,G,B.
|
||||||
|
#[inline]
|
||||||
|
fn rgb_to_v(r: i32, g: i32, b: i32) -> u8 {
|
||||||
|
let v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
|
||||||
|
v.clamp(0, 255) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a tightly-packed BGRA frame into NV12, writing into `out`.
|
||||||
|
///
|
||||||
|
/// `bgra` must be at least `width * height * 4` bytes; `out` must be at least
|
||||||
|
/// `nv12_size(width, height)` bytes. `width` and `height` MUST be even (H.264
|
||||||
|
/// 4:2:0 requires even dimensions — the caller pads odd capture sizes). Returns
|
||||||
|
/// an error rather than panicking on a short buffer or odd dimension so the
|
||||||
|
/// encoder can fall back to raw.
|
||||||
|
pub fn bgra_to_nv12(
|
||||||
|
bgra: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
out: &mut [u8],
|
||||||
|
) -> Result<(), ColorConvertError> {
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return Err(ColorConvertError::ZeroDimension);
|
||||||
|
}
|
||||||
|
if !width.is_multiple_of(2) || !height.is_multiple_of(2) {
|
||||||
|
return Err(ColorConvertError::OddDimension { width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
let w = width as usize;
|
||||||
|
let h = height as usize;
|
||||||
|
let expected_src = w * h * 4;
|
||||||
|
if bgra.len() < expected_src {
|
||||||
|
return Err(ColorConvertError::SrcTooSmall {
|
||||||
|
got: bgra.len(),
|
||||||
|
need: expected_src,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let need_out = nv12_size(width, height);
|
||||||
|
if out.len() < need_out {
|
||||||
|
return Err(ColorConvertError::DstTooSmall {
|
||||||
|
got: out.len(),
|
||||||
|
need: need_out,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let (y_plane, uv_plane) = out.split_at_mut(w * h);
|
||||||
|
|
||||||
|
// Luma: one sample per pixel.
|
||||||
|
for row in 0..h {
|
||||||
|
let src_row = row * w * 4;
|
||||||
|
let dst_row = row * w;
|
||||||
|
for col in 0..w {
|
||||||
|
let px = src_row + col * 4;
|
||||||
|
// BGRA order.
|
||||||
|
let b = bgra[px] as i32;
|
||||||
|
let g = bgra[px + 1] as i32;
|
||||||
|
let r = bgra[px + 2] as i32;
|
||||||
|
y_plane[dst_row + col] = rgb_to_y(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chroma: one (U,V) pair per 2x2 block, box-averaged.
|
||||||
|
let chroma_rows = h / 2;
|
||||||
|
let chroma_cols = w / 2;
|
||||||
|
for cy in 0..chroma_rows {
|
||||||
|
for cx in 0..chroma_cols {
|
||||||
|
let x0 = cx * 2;
|
||||||
|
let y0 = cy * 2;
|
||||||
|
|
||||||
|
let mut r_sum = 0i32;
|
||||||
|
let mut g_sum = 0i32;
|
||||||
|
let mut b_sum = 0i32;
|
||||||
|
for dy in 0..2 {
|
||||||
|
for dx in 0..2 {
|
||||||
|
let px = ((y0 + dy) * w + (x0 + dx)) * 4;
|
||||||
|
b_sum += bgra[px] as i32;
|
||||||
|
g_sum += bgra[px + 1] as i32;
|
||||||
|
r_sum += bgra[px + 2] as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let r = r_sum / 4;
|
||||||
|
let g = g_sum / 4;
|
||||||
|
let b = b_sum / 4;
|
||||||
|
|
||||||
|
let uv_idx = (cy * chroma_cols + cx) * 2;
|
||||||
|
uv_plane[uv_idx] = rgb_to_u(r, g, b);
|
||||||
|
uv_plane[uv_idx + 1] = rgb_to_v(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from BGRA->NV12 conversion. Surfaced (not panicked) so the H.264
|
||||||
|
/// encoder can downgrade to raw.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ColorConvertError {
|
||||||
|
#[error("frame dimension is zero")]
|
||||||
|
ZeroDimension,
|
||||||
|
#[error("NV12 requires even dimensions, got {width}x{height}")]
|
||||||
|
OddDimension { width: u32, height: u32 },
|
||||||
|
#[error("source BGRA buffer too small: {got} < {need}")]
|
||||||
|
SrcTooSmall { got: usize, need: usize },
|
||||||
|
#[error("destination NV12 buffer too small: {got} < {need}")]
|
||||||
|
DstTooSmall { got: usize, need: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nv12_size_is_3half() {
|
||||||
|
assert_eq!(nv12_size(2, 2), 6);
|
||||||
|
assert_eq!(nv12_size(4, 4), 24);
|
||||||
|
assert_eq!(nv12_size(1920, 1080), 1920 * 1080 * 3 / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_odd_dimensions() {
|
||||||
|
let bgra = vec![0u8; 3 * 3 * 4];
|
||||||
|
let mut out = vec![0u8; nv12_size(4, 4)];
|
||||||
|
assert!(matches!(
|
||||||
|
bgra_to_nv12(&bgra, 3, 2, &mut out),
|
||||||
|
Err(ColorConvertError::OddDimension { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
bgra_to_nv12(&bgra, 2, 3, &mut out),
|
||||||
|
Err(ColorConvertError::OddDimension { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_short_source() {
|
||||||
|
let bgra = vec![0u8; 4]; // way too small for 2x2
|
||||||
|
let mut out = vec![0u8; nv12_size(2, 2)];
|
||||||
|
assert!(matches!(
|
||||||
|
bgra_to_nv12(&bgra, 2, 2, &mut out),
|
||||||
|
Err(ColorConvertError::SrcTooSmall { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_short_dest() {
|
||||||
|
let bgra = vec![0u8; 2 * 2 * 4];
|
||||||
|
let mut out = vec![0u8; 1];
|
||||||
|
assert!(matches!(
|
||||||
|
bgra_to_nv12(&bgra, 2, 2, &mut out),
|
||||||
|
Err(ColorConvertError::DstTooSmall { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pure-black BGRA frame -> Y = 16 (limited-range black), U = V = 128.
|
||||||
|
#[test]
|
||||||
|
fn black_frame_maps_to_limited_range_black() {
|
||||||
|
let bgra = vec![0u8; 4 * 4 * 4]; // all zero => black, alpha 0
|
||||||
|
let mut out = vec![0u8; nv12_size(4, 4)];
|
||||||
|
bgra_to_nv12(&bgra, 4, 4, &mut out).unwrap();
|
||||||
|
|
||||||
|
// Y plane (first 16 bytes) all 16.
|
||||||
|
for &y in &out[..16] {
|
||||||
|
assert_eq!(y, 16, "black luma must be 16 (limited range)");
|
||||||
|
}
|
||||||
|
// UV plane all 128 (neutral chroma).
|
||||||
|
for &c in &out[16..] {
|
||||||
|
assert_eq!(c, 128, "black chroma must be neutral 128");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pure-white BGRA frame -> Y = 235 (limited-range white), U = V = 128.
|
||||||
|
#[test]
|
||||||
|
fn white_frame_maps_to_limited_range_white() {
|
||||||
|
// B=255, G=255, R=255, A=255 for every pixel.
|
||||||
|
let bgra = vec![255u8; 2 * 2 * 4];
|
||||||
|
let mut out = vec![0u8; nv12_size(2, 2)];
|
||||||
|
bgra_to_nv12(&bgra, 2, 2, &mut out).unwrap();
|
||||||
|
|
||||||
|
// Y = ((66+129+25)*255 + 128) >> 8 + 16 = 235.
|
||||||
|
for &y in &out[..4] {
|
||||||
|
assert_eq!(y, 235, "white luma must be 235 (limited range)");
|
||||||
|
}
|
||||||
|
// Neutral chroma for a gray/white pixel.
|
||||||
|
assert_eq!(out[4], 128);
|
||||||
|
assert_eq!(out[5], 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pure-red frame: luma below mid, V (Cr) well above 128, U (Cb) below 128.
|
||||||
|
#[test]
|
||||||
|
fn red_frame_has_high_cr_low_cb() {
|
||||||
|
// BGRA red: B=0, G=0, R=255, A=255.
|
||||||
|
let mut bgra = vec![0u8; 2 * 2 * 4];
|
||||||
|
for px in bgra.chunks_mut(4) {
|
||||||
|
px[0] = 0; // B
|
||||||
|
px[1] = 0; // G
|
||||||
|
px[2] = 255; // R
|
||||||
|
px[3] = 255; // A
|
||||||
|
}
|
||||||
|
let mut out = vec![0u8; nv12_size(2, 2)];
|
||||||
|
bgra_to_nv12(&bgra, 2, 2, &mut out).unwrap();
|
||||||
|
|
||||||
|
let u = out[4];
|
||||||
|
let v = out[5];
|
||||||
|
assert!(v > 200, "red must have high Cr (V), got {v}");
|
||||||
|
assert!(u < 128, "red must have Cb (U) below neutral, got {u}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conversion fills the whole NV12 buffer (no leftover zeros where data is
|
||||||
|
/// expected) for a non-trivial gradient — a sanity check on plane indexing.
|
||||||
|
#[test]
|
||||||
|
fn plane_indexing_covers_full_buffer() {
|
||||||
|
let w = 8u32;
|
||||||
|
let h = 8u32;
|
||||||
|
let mut bgra = vec![0u8; (w * h * 4) as usize];
|
||||||
|
for (i, px) in bgra.chunks_mut(4).enumerate() {
|
||||||
|
let v = (i % 256) as u8;
|
||||||
|
px[0] = v;
|
||||||
|
px[1] = v;
|
||||||
|
px[2] = v;
|
||||||
|
px[3] = 255;
|
||||||
|
}
|
||||||
|
let mut out = vec![0xAAu8; nv12_size(w, h)];
|
||||||
|
bgra_to_nv12(&bgra, w, h, &mut out).unwrap();
|
||||||
|
// Y plane should be fully written (gray ramp -> non-constant).
|
||||||
|
let y_plane = &out[..(w * h) as usize];
|
||||||
|
assert!(y_plane.windows(2).any(|p| p[0] != p[1]), "Y plane varies");
|
||||||
|
}
|
||||||
|
}
|
||||||
515
agent/src/encoder/h264.rs
Normal file
515
agent/src/encoder/h264.rs
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
//! Hardware H.264 encoder via Windows Media Foundation (Task 7).
|
||||||
|
//!
|
||||||
|
//! FIRST-CUT / COMPILE-VERIFIED ONLY. This encoder is wired end-to-end (init ->
|
||||||
|
//! feed -> drain -> emit `EncodedFrame{h264}`) and is selected only when the
|
||||||
|
//! agent advertised hardware support AND the server negotiated H.264. It has NOT
|
||||||
|
//! been validated on real hardware with live frames — that is plan Task 8. On
|
||||||
|
//! ANY initialization or per-frame failure it surfaces an error; the encoder
|
||||||
|
//! factory (`create_encoder_for`) downgrades to the raw+Zstd encoder so a
|
||||||
|
//! session never breaks because of H.264.
|
||||||
|
//!
|
||||||
|
//! Pipeline:
|
||||||
|
//! BGRA capture --(color::bgra_to_nv12)--> NV12 sample --> MFT(H.264) --> H.264
|
||||||
|
//! Annex-B/length-prefixed elementary stream --> proto EncodedFrame.
|
||||||
|
//!
|
||||||
|
//! Design notes:
|
||||||
|
//! - The MFT is enumerated with `MFTEnumEx(MFT_CATEGORY_VIDEO_ENCODER,
|
||||||
|
//! MFT_ENUM_FLAG_HARDWARE, …, MFVideoFormat_H264)` (same probe as
|
||||||
|
//! `capability`). We `ActivateObject` the first match.
|
||||||
|
//! - Input is configured as NV12, output as H.264, with frame size, frame rate
|
||||||
|
//! and an average bitrate derived from `quality`.
|
||||||
|
//! - Both the SYNCHRONOUS MFT model (ProcessInput/ProcessOutput) and the
|
||||||
|
//! ASYNCHRONOUS hardware-MFT model (METransformNeedInput / METransformHaveOutput
|
||||||
|
//! events) exist. To keep this first cut bounded and predictable we DRAIN the
|
||||||
|
//! MFT synchronously after each input and treat `MF_E_TRANSFORM_NEED_MORE_INPUT`
|
||||||
|
//! as "no output this tick". A fully async event-driven loop is a Task-8
|
||||||
|
//! refinement (documented below).
|
||||||
|
//! - `MFT_MESSAGE_SET_D3D_MANAGER` is intentionally NOT set — we feed CPU NV12
|
||||||
|
//! buffers (software input samples), which every HW H.264 MFT accepts. D3D11
|
||||||
|
//! zero-copy is a later optimization.
|
||||||
|
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
use super::{EncodedFrame, Encoder};
|
||||||
|
use crate::capture::CapturedFrame;
|
||||||
|
use crate::encoder::color;
|
||||||
|
use crate::proto::{video_frame, EncodedFrame as ProtoEncodedFrame, VideoFrame};
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use windows::Win32::Media::MediaFoundation::{
|
||||||
|
IMFActivate, IMFMediaType, IMFSample, IMFTransform, MFCreateMediaType, MFCreateMemoryBuffer,
|
||||||
|
MFCreateSample, MFMediaType_Video, MFShutdown, MFStartup, MFTEnumEx, MFVideoFormat_H264,
|
||||||
|
MFVideoFormat_NV12, MFVideoInterlace_Progressive, MFSTARTUP_LITE, MFT_CATEGORY_VIDEO_ENCODER,
|
||||||
|
MFT_ENUM_FLAG_HARDWARE, MFT_ENUM_FLAG_SORTANDFILTER, MFT_ENUM_FLAG_TRANSCODE_ONLY,
|
||||||
|
MFT_MESSAGE_COMMAND_FLUSH, MFT_MESSAGE_NOTIFY_BEGIN_STREAMING,
|
||||||
|
MFT_MESSAGE_NOTIFY_END_OF_STREAM, MFT_MESSAGE_NOTIFY_END_STREAMING,
|
||||||
|
MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_OUTPUT_DATA_BUFFER, MFT_OUTPUT_STREAM_INFO,
|
||||||
|
MFT_REGISTER_TYPE_INFO, MF_E_TRANSFORM_NEED_MORE_INPUT, MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE,
|
||||||
|
MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, MF_MT_PIXEL_ASPECT_RATIO,
|
||||||
|
MF_MT_SUBTYPE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Encoder-internal state, created once and reused per frame.
|
||||||
|
pub struct H264Encoder {
|
||||||
|
/// The activated encoder transform.
|
||||||
|
transform: IMFTransform,
|
||||||
|
/// Configured frame dimensions; a capture-size change forces re-init.
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
/// Quality (1-100) used to derive the bitrate; kept for re-init on resize.
|
||||||
|
quality: u32,
|
||||||
|
/// Frame sequence counter (mirrors RawEncoder).
|
||||||
|
sequence: u32,
|
||||||
|
/// Force the next frame to request a keyframe.
|
||||||
|
force_keyframe: bool,
|
||||||
|
/// Whether `MFT_MESSAGE_NOTIFY_BEGIN_STREAMING` was sent.
|
||||||
|
streaming: bool,
|
||||||
|
/// Reusable NV12 staging buffer (resized on dimension change).
|
||||||
|
nv12: Vec<u8>,
|
||||||
|
/// Input/output stream identifiers (most encoders use 0/0).
|
||||||
|
input_stream_id: u32,
|
||||||
|
output_stream_id: u32,
|
||||||
|
/// True if MF was started by THIS encoder and must be shut down on drop.
|
||||||
|
mf_started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMFTransform is a COM interface; it is not auto-Send. We only ever touch the
|
||||||
|
// encoder from the single capture/encode thread (the session owns it behind a
|
||||||
|
// &mut), so it is safe to move between threads as long as it is not shared.
|
||||||
|
unsafe impl Send for H264Encoder {}
|
||||||
|
|
||||||
|
impl H264Encoder {
|
||||||
|
/// Construct and fully initialize a hardware H.264 encoder. Returns an error
|
||||||
|
/// (so the factory can fall back to raw) if MF is unavailable, no hardware
|
||||||
|
/// encoder exists, or media-type negotiation fails. A default frame size is
|
||||||
|
/// used and re-negotiated on the first frame if the real capture differs.
|
||||||
|
pub fn new(quality: u32) -> Result<Self> {
|
||||||
|
// 1920x1080 default; re-init on the first frame if the capture differs.
|
||||||
|
Self::with_dimensions(quality, 1920, 1080)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_dimensions(quality: u32, width: u32, height: u32) -> Result<Self> {
|
||||||
|
unsafe {
|
||||||
|
// MF must be initialized on this thread. MFSTARTUP_LITE avoids the
|
||||||
|
// sockets/network stack we don't need.
|
||||||
|
MFStartup(mf_version(), MFSTARTUP_LITE).context("MFStartup failed")?;
|
||||||
|
let mf_started = true;
|
||||||
|
|
||||||
|
let transform = match Self::activate_hw_encoder() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
// Balance the MFStartup we just did before bailing.
|
||||||
|
let _ = MFShutdown();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut enc = Self {
|
||||||
|
transform,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
quality,
|
||||||
|
sequence: 0,
|
||||||
|
force_keyframe: true,
|
||||||
|
streaming: false,
|
||||||
|
nv12: Vec::new(),
|
||||||
|
input_stream_id: 0,
|
||||||
|
output_stream_id: 0,
|
||||||
|
mf_started,
|
||||||
|
};
|
||||||
|
|
||||||
|
// `enc`'s Drop will shut MF down and release the transform on error.
|
||||||
|
enc.configure_media_types()?;
|
||||||
|
|
||||||
|
Ok(enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate hardware H.264 encoder MFTs and activate the first one.
|
||||||
|
unsafe fn activate_hw_encoder() -> Result<IMFTransform> {
|
||||||
|
let output_type = MFT_REGISTER_TYPE_INFO {
|
||||||
|
guidMajorType: MFMediaType_Video,
|
||||||
|
guidSubtype: MFVideoFormat_H264,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut activate_ptr: *mut Option<IMFActivate> = std::ptr::null_mut();
|
||||||
|
let mut count: u32 = 0;
|
||||||
|
|
||||||
|
MFTEnumEx(
|
||||||
|
MFT_CATEGORY_VIDEO_ENCODER,
|
||||||
|
MFT_ENUM_FLAG_HARDWARE | MFT_ENUM_FLAG_SORTANDFILTER | MFT_ENUM_FLAG_TRANSCODE_ONLY,
|
||||||
|
None,
|
||||||
|
Some(&output_type as *const _),
|
||||||
|
&mut activate_ptr,
|
||||||
|
&mut count,
|
||||||
|
)
|
||||||
|
.context("MFTEnumEx (hardware H.264) failed")?;
|
||||||
|
|
||||||
|
if count == 0 || activate_ptr.is_null() {
|
||||||
|
if !activate_ptr.is_null() {
|
||||||
|
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
|
||||||
|
}
|
||||||
|
return Err(anyhow!("no hardware H.264 encoder MFT available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = std::slice::from_raw_parts_mut(activate_ptr, count as usize);
|
||||||
|
|
||||||
|
// Activate the first usable encoder; release every IMFActivate.
|
||||||
|
let mut chosen: Option<IMFTransform> = None;
|
||||||
|
for entry in slice.iter_mut() {
|
||||||
|
if chosen.is_none() {
|
||||||
|
if let Some(activate) = entry.as_ref() {
|
||||||
|
if let Ok(transform) = activate.ActivateObject::<IMFTransform>() {
|
||||||
|
chosen = Some(transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Release this IMFActivate reference.
|
||||||
|
entry.take();
|
||||||
|
}
|
||||||
|
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
|
||||||
|
|
||||||
|
chosen.ok_or_else(|| anyhow!("failed to activate any hardware H.264 encoder MFT"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the H.264 output type and NV12 input type, in the order MF requires
|
||||||
|
/// (output type FIRST for encoders, then the matching input type).
|
||||||
|
unsafe fn configure_media_types(&mut self) -> Result<()> {
|
||||||
|
// Discover the real stream identifiers (most encoders report 0/0).
|
||||||
|
let mut input_ids = [0u32; 1];
|
||||||
|
let mut output_ids = [0u32; 1];
|
||||||
|
// GetStreamIDs may return E_NOTIMPL meaning "ids are 0..n-1"; ignore err.
|
||||||
|
let _ = self.transform.GetStreamIDs(&mut input_ids, &mut output_ids);
|
||||||
|
// If GetStreamIDs populated nonzero ids use them, else default 0/0.
|
||||||
|
if input_ids[0] != 0 {
|
||||||
|
self.input_stream_id = input_ids[0];
|
||||||
|
}
|
||||||
|
if output_ids[0] != 0 {
|
||||||
|
self.output_stream_id = output_ids[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let fps_num = 30u32;
|
||||||
|
let fps_den = 1u32;
|
||||||
|
let bitrate = quality_to_bitrate(self.quality, self.width, self.height);
|
||||||
|
|
||||||
|
// ---- OUTPUT (H.264) ----
|
||||||
|
let out_type: IMFMediaType = MFCreateMediaType().context("MFCreateMediaType(out)")?;
|
||||||
|
out_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
|
||||||
|
out_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?;
|
||||||
|
out_type.SetUINT32(&MF_MT_AVG_BITRATE, bitrate)?;
|
||||||
|
set_attr_size(&out_type, &MF_MT_FRAME_SIZE, self.width, self.height)?;
|
||||||
|
set_attr_ratio(&out_type, &MF_MT_FRAME_RATE, fps_num, fps_den)?;
|
||||||
|
set_attr_ratio(&out_type, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?;
|
||||||
|
out_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
|
||||||
|
self.transform
|
||||||
|
.SetOutputType(self.output_stream_id, &out_type, 0)
|
||||||
|
.context("SetOutputType(H264)")?;
|
||||||
|
|
||||||
|
// ---- INPUT (NV12) ----
|
||||||
|
let in_type: IMFMediaType = MFCreateMediaType().context("MFCreateMediaType(in)")?;
|
||||||
|
in_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
|
||||||
|
in_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?;
|
||||||
|
set_attr_size(&in_type, &MF_MT_FRAME_SIZE, self.width, self.height)?;
|
||||||
|
set_attr_ratio(&in_type, &MF_MT_FRAME_RATE, fps_num, fps_den)?;
|
||||||
|
set_attr_ratio(&in_type, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?;
|
||||||
|
in_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?;
|
||||||
|
self.transform
|
||||||
|
.SetInputType(self.input_stream_id, &in_type, 0)
|
||||||
|
.context("SetInputType(NV12)")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin streaming if not already started (idempotent).
|
||||||
|
unsafe fn ensure_streaming(&mut self) -> Result<()> {
|
||||||
|
if !self.streaming {
|
||||||
|
self.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)
|
||||||
|
.context("NOTIFY_BEGIN_STREAMING")?;
|
||||||
|
self.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)
|
||||||
|
.context("NOTIFY_START_OF_STREAM")?;
|
||||||
|
self.streaming = true;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-initialize the encoder for a new frame size (capture resolution change).
|
||||||
|
unsafe fn reinit_for_size(&mut self, width: u32, height: u32) -> Result<()> {
|
||||||
|
if self.streaming {
|
||||||
|
let _ = self.transform.ProcessMessage(MFT_MESSAGE_COMMAND_FLUSH, 0);
|
||||||
|
let _ = self
|
||||||
|
.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);
|
||||||
|
let _ = self
|
||||||
|
.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
|
||||||
|
self.streaming = false;
|
||||||
|
}
|
||||||
|
self.width = width;
|
||||||
|
self.height = height;
|
||||||
|
self.force_keyframe = true;
|
||||||
|
self.configure_media_types()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap an NV12 byte buffer into an `IMFSample` with the given timestamp.
|
||||||
|
/// A free associated fn (does not borrow `self`) so the caller can pass
|
||||||
|
/// `&self.nv12` without a clone while `self` is mutably borrowed elsewhere.
|
||||||
|
unsafe fn make_input_sample(nv12: &[u8], pts_100ns: i64) -> Result<IMFSample> {
|
||||||
|
let sample: IMFSample = MFCreateSample().context("MFCreateSample")?;
|
||||||
|
let buffer = MFCreateMemoryBuffer(nv12.len() as u32).context("MFCreateMemoryBuffer")?;
|
||||||
|
|
||||||
|
// Lock, copy NV12 in, set current length, unlock.
|
||||||
|
let mut data_ptr: *mut u8 = std::ptr::null_mut();
|
||||||
|
let mut max_len: u32 = 0;
|
||||||
|
buffer
|
||||||
|
.Lock(&mut data_ptr, Some(&mut max_len), None)
|
||||||
|
.context("IMFMediaBuffer::Lock")?;
|
||||||
|
if (max_len as usize) < nv12.len() || data_ptr.is_null() {
|
||||||
|
let _ = buffer.Unlock();
|
||||||
|
return Err(anyhow!("MF buffer too small for NV12 frame"));
|
||||||
|
}
|
||||||
|
std::ptr::copy_nonoverlapping(nv12.as_ptr(), data_ptr, nv12.len());
|
||||||
|
buffer.SetCurrentLength(nv12.len() as u32)?;
|
||||||
|
buffer.Unlock()?;
|
||||||
|
|
||||||
|
sample.AddBuffer(&buffer)?;
|
||||||
|
sample.SetSampleTime(pts_100ns)?;
|
||||||
|
// 33.367ms per frame at ~30fps, in 100ns units.
|
||||||
|
sample.SetSampleDuration(333_667)?;
|
||||||
|
Ok(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain one available output sample, if any. Returns the encoded bytes and
|
||||||
|
/// whether the MFT flagged it a keyframe (clean point). `Ok(None)` means the
|
||||||
|
/// MFT needs more input before it can produce output this tick.
|
||||||
|
unsafe fn drain_one_output(&mut self) -> Result<Option<(Vec<u8>, bool)>> {
|
||||||
|
let stream_info: MFT_OUTPUT_STREAM_INFO = self
|
||||||
|
.transform
|
||||||
|
.GetOutputStreamInfo(self.output_stream_id)
|
||||||
|
.context("GetOutputStreamInfo")?;
|
||||||
|
|
||||||
|
// If the MFT does not allocate its own output samples we must provide one.
|
||||||
|
const MFT_OUTPUT_STREAM_PROVIDES_SAMPLES: u32 = 0x100;
|
||||||
|
let mft_provides = stream_info.dwFlags & MFT_OUTPUT_STREAM_PROVIDES_SAMPLES != 0;
|
||||||
|
|
||||||
|
let mut out_buffer = MFT_OUTPUT_DATA_BUFFER {
|
||||||
|
dwStreamID: self.output_stream_id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !mft_provides {
|
||||||
|
let alloc_size = stream_info.cbSize.max(1);
|
||||||
|
let sample: IMFSample = MFCreateSample().context("MFCreateSample(out)")?;
|
||||||
|
let buffer = MFCreateMemoryBuffer(alloc_size).context("MFCreateMemoryBuffer(out)")?;
|
||||||
|
sample.AddBuffer(&buffer)?;
|
||||||
|
out_buffer.pSample = std::mem::ManuallyDrop::new(Some(sample));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut status: u32 = 0;
|
||||||
|
let mut bufs = [out_buffer];
|
||||||
|
let hr = self.transform.ProcessOutput(0, &mut bufs, &mut status);
|
||||||
|
|
||||||
|
// Take ownership of whatever sample is now in the buffer (ours or MFT's).
|
||||||
|
let produced = std::mem::ManuallyDrop::take(&mut bufs[0].pSample);
|
||||||
|
|
||||||
|
match hr {
|
||||||
|
Ok(()) => {
|
||||||
|
let Some(sample) = produced else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let bytes = sample_to_vec(&sample)?;
|
||||||
|
let keyframe = sample_is_keyframe(&sample);
|
||||||
|
Ok(Some((bytes, keyframe)))
|
||||||
|
}
|
||||||
|
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => Ok(None),
|
||||||
|
Err(e) => Err(anyhow!("ProcessOutput failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder for H264Encoder {
|
||||||
|
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame> {
|
||||||
|
self.sequence = self.sequence.wrapping_add(1);
|
||||||
|
|
||||||
|
// H.264 4:2:0 needs even dimensions. Reject odd captures up front so we
|
||||||
|
// surface a clean error (the factory already fell back to raw if HW was
|
||||||
|
// missing; a per-frame error here lets the session log + continue).
|
||||||
|
if !frame.width.is_multiple_of(2) || !frame.height.is_multiple_of(2) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"H.264 requires even dimensions, got {}x{}",
|
||||||
|
frame.width,
|
||||||
|
frame.height
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// Re-init on a resolution change.
|
||||||
|
if frame.width != self.width || frame.height != self.height {
|
||||||
|
self.reinit_for_size(frame.width, frame.height)
|
||||||
|
.context("H.264 re-init for new frame size")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ensure_streaming()?;
|
||||||
|
|
||||||
|
// BGRA -> NV12 into the reusable staging buffer.
|
||||||
|
let need = color::nv12_size(frame.width, frame.height);
|
||||||
|
if self.nv12.len() != need {
|
||||||
|
self.nv12.resize(need, 0);
|
||||||
|
}
|
||||||
|
color::bgra_to_nv12(&frame.data, frame.width, frame.height, &mut self.nv12)
|
||||||
|
.map_err(|e| anyhow!("BGRA->NV12 failed: {e}"))?;
|
||||||
|
|
||||||
|
// PTS in 100ns units derived from the frame's capture instant.
|
||||||
|
let pts_100ns = (frame.timestamp.elapsed().as_nanos() / 100) as i64;
|
||||||
|
let sample = Self::make_input_sample(&self.nv12, pts_100ns)?;
|
||||||
|
|
||||||
|
// Feed the encoder. NEED_MORE_INPUT is normal back-pressure handling;
|
||||||
|
// for the synchronous first cut we only push one frame per tick.
|
||||||
|
match self
|
||||||
|
.transform
|
||||||
|
.ProcessInput(self.input_stream_id, &sample, 0)
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => {}
|
||||||
|
Err(e) => return Err(anyhow!("ProcessInput failed: {e:#}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain whatever output is ready.
|
||||||
|
let Some((data, mft_keyframe)) = self.drain_one_output()? else {
|
||||||
|
// No compressed output yet (encoder latency / GOP buffering).
|
||||||
|
// Emit an empty frame so the session skips sending this tick.
|
||||||
|
return Ok(EncodedFrame {
|
||||||
|
frame: VideoFrame::default(),
|
||||||
|
size: 0,
|
||||||
|
is_keyframe: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_keyframe = mft_keyframe || self.force_keyframe;
|
||||||
|
self.force_keyframe = false;
|
||||||
|
|
||||||
|
let size = data.len();
|
||||||
|
let encoded = ProtoEncodedFrame {
|
||||||
|
data,
|
||||||
|
keyframe: is_keyframe,
|
||||||
|
pts: pts_100ns,
|
||||||
|
dts: pts_100ns,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(EncodedFrame {
|
||||||
|
frame: VideoFrame {
|
||||||
|
timestamp: frame.timestamp.elapsed().as_millis() as i64,
|
||||||
|
display_id: frame.display_id as i32,
|
||||||
|
sequence: self.sequence as i32,
|
||||||
|
encoding: Some(video_frame::Encoding::H264(encoded)),
|
||||||
|
},
|
||||||
|
size,
|
||||||
|
is_keyframe,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_keyframe(&mut self) {
|
||||||
|
// A precise force-IDR uses the MFT codec API
|
||||||
|
// (CODECAPI_AVEncVideoForceKeyFrame); for the first cut we flag the next
|
||||||
|
// emitted frame as a keyframe so the viewer treats it as a clean point.
|
||||||
|
self.force_keyframe = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"h264-mediafoundation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for H264Encoder {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
if self.streaming {
|
||||||
|
let _ = self
|
||||||
|
.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);
|
||||||
|
let _ = self
|
||||||
|
.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
|
||||||
|
}
|
||||||
|
// The IMFTransform releases when `self.transform` drops.
|
||||||
|
if self.mf_started {
|
||||||
|
let _ = MFShutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MF version word expected by `MFStartup` (MF_VERSION = (MF_API_VERSION<<16)|MF_SDK_VERSION).
|
||||||
|
fn mf_version() -> u32 {
|
||||||
|
// MF_SDK_VERSION = 0x0002, MF_API_VERSION = 0x0070 -> 0x00020070.
|
||||||
|
0x0002_0070
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a target average bitrate (bps) from the 1-100 quality knob and the
|
||||||
|
/// frame area. Tuned conservatively for desktop content (mostly static).
|
||||||
|
fn quality_to_bitrate(quality: u32, width: u32, height: u32) -> u32 {
|
||||||
|
let q = quality.clamp(1, 100) as u64;
|
||||||
|
let pixels = (width as u64) * (height as u64);
|
||||||
|
// Base ~0.06 bits/pixel/frame at 30fps for q=100, scaled by quality.
|
||||||
|
// bps = pixels * 30 * bpp; bpp scales 0.01..0.10 with quality.
|
||||||
|
let bpp_milli = 10 + (q * 90 / 100); // 0.010 .. 0.100 in milli-bits
|
||||||
|
let bps = pixels.saturating_mul(30).saturating_mul(bpp_milli) / 1000;
|
||||||
|
bps.clamp(500_000, 50_000_000) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack (width, height) into the 64-bit MF_MT_FRAME_SIZE attribute.
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn set_attr_size(
|
||||||
|
media_type: &IMFMediaType,
|
||||||
|
key: &windows::core::GUID,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let packed = ((width as u64) << 32) | (height as u64);
|
||||||
|
media_type.SetUINT64(key, packed)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack (numerator, denominator) into a 64-bit ratio MF attribute.
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn set_attr_ratio(
|
||||||
|
media_type: &IMFMediaType,
|
||||||
|
key: &windows::core::GUID,
|
||||||
|
num: u32,
|
||||||
|
den: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let packed = ((num as u64) << 32) | (den as u64);
|
||||||
|
media_type.SetUINT64(key, packed)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy all bytes out of an `IMFSample` (single contiguous buffer) into a Vec.
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn sample_to_vec(sample: &IMFSample) -> Result<Vec<u8>> {
|
||||||
|
let buffer = sample
|
||||||
|
.ConvertToContiguousBuffer()
|
||||||
|
.context("ConvertToContiguousBuffer")?;
|
||||||
|
let mut ptr: *mut u8 = std::ptr::null_mut();
|
||||||
|
let mut len: u32 = 0;
|
||||||
|
buffer
|
||||||
|
.Lock(&mut ptr, None, Some(&mut len))
|
||||||
|
.context("output buffer Lock")?;
|
||||||
|
let out = if ptr.is_null() || len == 0 {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
std::slice::from_raw_parts(ptr, len as usize).to_vec()
|
||||||
|
};
|
||||||
|
let _ = buffer.Unlock();
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the "clean point" (keyframe) flag off a sample, if present.
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn sample_is_keyframe(sample: &IMFSample) -> bool {
|
||||||
|
use windows::Win32::Media::MediaFoundation::MFSampleExtension_CleanPoint;
|
||||||
|
sample
|
||||||
|
.GetUINT32(&MFSampleExtension_CleanPoint)
|
||||||
|
.map(|v| v != 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
//! Frame encoding module
|
//! Frame encoding module
|
||||||
//!
|
//!
|
||||||
//! Encodes captured frames for transmission. Supports:
|
//! Encodes captured frames for transmission. Supports:
|
||||||
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode)
|
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode; the guaranteed
|
||||||
//! - VP9 software encoding (universal fallback)
|
//! fallback and the current default).
|
||||||
//! - H264 hardware encoding (when GPU available)
|
//! - H.264 hardware encoding via Windows Media Foundation (Task 7) — the
|
||||||
|
//! negotiated upgrade. Compile-verified; validated on real hardware in plan
|
||||||
|
//! Task 8. On any init/feed failure the factory or encoder falls back to raw.
|
||||||
|
//!
|
||||||
|
//! Codec selection is driven by the negotiated `VideoCodec` the server sends on
|
||||||
|
//! `StartStream` (see `select_codec` / `create_encoder_for`). The capability the
|
||||||
|
//! agent advertises to the server is detected by `capability::supports_hardware_h264`.
|
||||||
|
|
||||||
|
mod capability;
|
||||||
|
pub(crate) mod color;
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod h264;
|
||||||
mod raw;
|
mod raw;
|
||||||
|
|
||||||
|
pub use capability::supports_hardware_h264;
|
||||||
pub use raw::RawEncoder;
|
pub use raw::RawEncoder;
|
||||||
|
|
||||||
use crate::capture::CapturedFrame;
|
use crate::capture::CapturedFrame;
|
||||||
use crate::proto::{DirtyRect as ProtoDirtyRect, RawFrame, VideoFrame};
|
use crate::proto::{video_frame, VideoCodec, VideoFrame};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// Encoded frame ready for transmission
|
/// Encoded frame ready for transmission
|
||||||
@@ -23,30 +34,191 @@ pub struct EncodedFrame {
|
|||||||
pub size: usize,
|
pub size: usize,
|
||||||
|
|
||||||
/// Whether this is a keyframe (full frame)
|
/// Whether this is a keyframe (full frame)
|
||||||
|
// Set by encoders; not yet read by the transmit path.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub is_keyframe: bool,
|
pub is_keyframe: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frame encoder trait
|
/// Frame encoder trait.
|
||||||
|
///
|
||||||
|
/// Every implementor turns a `CapturedFrame` (BGRA) into a wire `VideoFrame`
|
||||||
|
/// using one `video_frame::Encoding` variant. `RawEncoder` emits the `Raw`
|
||||||
|
/// variant; the H.264 encoder emits the `H264` variant. The factory
|
||||||
|
/// (`create_encoder_for`) selects the implementor from the negotiated codec.
|
||||||
pub trait Encoder: Send {
|
pub trait Encoder: Send {
|
||||||
/// Encode a captured frame
|
/// Encode a captured frame
|
||||||
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame>;
|
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame>;
|
||||||
|
|
||||||
/// Request a keyframe on next encode
|
/// Request a keyframe on next encode
|
||||||
|
#[allow(dead_code)]
|
||||||
fn request_keyframe(&mut self);
|
fn request_keyframe(&mut self);
|
||||||
|
|
||||||
/// Get encoder name/type
|
/// Get encoder name/type
|
||||||
|
#[allow(dead_code)]
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an encoder based on configuration
|
/// Map a configured/negotiated codec string to a `VideoCodec`.
|
||||||
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> {
|
///
|
||||||
|
/// Used when constructing an encoder from the agent's own `EncodingConfig`
|
||||||
|
/// (before any server negotiation). Unknown / "auto" / "raw" all resolve to raw
|
||||||
|
/// — the safe default. "h264" resolves to H.264 (which itself falls back to raw
|
||||||
|
/// if MF init fails).
|
||||||
|
///
|
||||||
|
/// Retained as the config-string entry point (used by `create_encoder` and the
|
||||||
|
/// unit tests); the live session negotiates via `select_codec` on a `VideoCodec`.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn codec_from_str(codec: &str) -> VideoCodec {
|
||||||
match codec.to_lowercase().as_str() {
|
match codec.to_lowercase().as_str() {
|
||||||
"raw" | "zstd" => Ok(Box::new(RawEncoder::new(quality)?)),
|
"h264" => VideoCodec::H264,
|
||||||
// "vp9" => Ok(Box::new(Vp9Encoder::new(quality)?)),
|
// "h265"/"hevc" are future opt-in (TODO) — treat as raw for now so we
|
||||||
// "h264" => Ok(Box::new(H264Encoder::new(quality)?)),
|
// never select an unimplemented codec.
|
||||||
"auto" | _ => {
|
_ => VideoCodec::Raw,
|
||||||
// Default to raw for now (best for LAN)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose the codec the agent will actually use for a stream, given the codec
|
||||||
|
/// the server negotiated and the agent's own hardware capability.
|
||||||
|
///
|
||||||
|
/// This is the agent-side guard that keeps the raw fallback authoritative:
|
||||||
|
/// - The server only negotiates H.264 when the agent advertised support, but we
|
||||||
|
/// re-check `supports_hardware_h264()` here so a stale/misconfigured server
|
||||||
|
/// selection can never force an unsupported codec.
|
||||||
|
/// - H.265 is not implemented; it degrades to raw.
|
||||||
|
/// - Anything else is raw.
|
||||||
|
pub fn select_codec(negotiated: VideoCodec, hardware_h264_available: bool) -> VideoCodec {
|
||||||
|
match negotiated {
|
||||||
|
VideoCodec::H264 if hardware_h264_available => VideoCodec::H264,
|
||||||
|
// Server asked for H.264 but we have no HW encoder -> raw.
|
||||||
|
VideoCodec::H264 => VideoCodec::Raw,
|
||||||
|
// HEVC not implemented yet (TODO: Task 7 opt-in / future).
|
||||||
|
VideoCodec::H265 => VideoCodec::Raw,
|
||||||
|
VideoCodec::Raw => VideoCodec::Raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an encoder for an explicit `VideoCodec`, with a transparent fallback
|
||||||
|
/// to raw if a hardware encoder cannot be constructed.
|
||||||
|
///
|
||||||
|
/// `quality` is the 1-100 quality knob (mapped per-codec). On H.264 init failure
|
||||||
|
/// this logs and returns a raw encoder so the session keeps working.
|
||||||
|
pub fn create_encoder_for(codec: VideoCodec, quality: u32) -> Result<Box<dyn Encoder>> {
|
||||||
|
match codec {
|
||||||
|
VideoCodec::H264 => {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
match h264::H264Encoder::new(quality) {
|
||||||
|
Ok(enc) => {
|
||||||
|
tracing::info!("Using hardware H.264 encoder (Media Foundation)");
|
||||||
|
Ok(Box::new(enc))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"H.264 encoder init failed ({e:#}); falling back to raw+Zstd"
|
||||||
|
);
|
||||||
Ok(Box::new(RawEncoder::new(quality)?))
|
Ok(Box::new(RawEncoder::new(quality)?))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
tracing::warn!("H.264 unsupported on this platform; using raw+Zstd");
|
||||||
|
Ok(Box::new(RawEncoder::new(quality)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Raw (and anything that resolved to raw) uses the salvaged encoder.
|
||||||
|
VideoCodec::Raw | VideoCodec::H265 => Ok(Box::new(RawEncoder::new(quality)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an encoder based on a codec string (agent config path).
|
||||||
|
///
|
||||||
|
/// Backwards-compatible entry point that builds an encoder from a codec STRING
|
||||||
|
/// (e.g. `EncodingConfig.codec`). Resolves the string to a `VideoCodec`, applies
|
||||||
|
/// the hardware-availability guard, then builds the encoder. The live session
|
||||||
|
/// uses `select_codec` + `create_encoder_for` (negotiated `VideoCodec`) instead;
|
||||||
|
/// this remains for the config path and is covered by unit tests.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> {
|
||||||
|
let requested = codec_from_str(codec);
|
||||||
|
let chosen = select_codec(requested, supports_hardware_h264());
|
||||||
|
create_encoder_for(chosen, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an `EncodedFrame` carrying a single `video_frame::Encoding` payload.
|
||||||
|
/// Shared helper so encoders don't each repeat the `VideoFrame` wrapper.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn wrap_video_frame(
|
||||||
|
timestamp_ms: i64,
|
||||||
|
display_id: i32,
|
||||||
|
sequence: i32,
|
||||||
|
encoding: video_frame::Encoding,
|
||||||
|
size: usize,
|
||||||
|
is_keyframe: bool,
|
||||||
|
) -> EncodedFrame {
|
||||||
|
EncodedFrame {
|
||||||
|
frame: VideoFrame {
|
||||||
|
timestamp: timestamp_ms,
|
||||||
|
display_id,
|
||||||
|
sequence,
|
||||||
|
encoding: Some(encoding),
|
||||||
|
},
|
||||||
|
size,
|
||||||
|
is_keyframe,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_from_str_maps_known_and_unknown() {
|
||||||
|
assert_eq!(codec_from_str("h264"), VideoCodec::H264);
|
||||||
|
assert_eq!(codec_from_str("H264"), VideoCodec::H264);
|
||||||
|
assert_eq!(codec_from_str("raw"), VideoCodec::Raw);
|
||||||
|
assert_eq!(codec_from_str("zstd"), VideoCodec::Raw);
|
||||||
|
assert_eq!(codec_from_str("auto"), VideoCodec::Raw);
|
||||||
|
assert_eq!(codec_from_str("vp9"), VideoCodec::Raw);
|
||||||
|
// HEVC not implemented -> raw, never H265.
|
||||||
|
assert_eq!(codec_from_str("h265"), VideoCodec::Raw);
|
||||||
|
assert_eq!(codec_from_str("hevc"), VideoCodec::Raw);
|
||||||
|
assert_eq!(codec_from_str(""), VideoCodec::Raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_codec_honors_hardware_guard() {
|
||||||
|
// Server negotiated H.264 and HW is present -> H.264.
|
||||||
|
assert_eq!(select_codec(VideoCodec::H264, true), VideoCodec::H264);
|
||||||
|
// Server negotiated H.264 but no HW -> raw (never forced).
|
||||||
|
assert_eq!(select_codec(VideoCodec::H264, false), VideoCodec::Raw);
|
||||||
|
// Raw stays raw regardless of HW.
|
||||||
|
assert_eq!(select_codec(VideoCodec::Raw, true), VideoCodec::Raw);
|
||||||
|
assert_eq!(select_codec(VideoCodec::Raw, false), VideoCodec::Raw);
|
||||||
|
// HEVC always degrades to raw (unimplemented).
|
||||||
|
assert_eq!(select_codec(VideoCodec::H265, true), VideoCodec::Raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_factory_always_succeeds() {
|
||||||
|
// Raw must always construct (the guaranteed fallback).
|
||||||
|
let enc = create_encoder_for(VideoCodec::Raw, 75).unwrap();
|
||||||
|
assert_eq!(enc.name(), "raw+zstd");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_encoder_string_path_resolves_to_raw_without_hw() {
|
||||||
|
// On a machine without a HW encoder (CI / non-Windows), "h264" must
|
||||||
|
// resolve to a working raw encoder, not an error.
|
||||||
|
let enc = create_encoder("h264", 75).unwrap();
|
||||||
|
// Without HW it is raw; with HW it would be the H.264 encoder. We only
|
||||||
|
// assert it constructed.
|
||||||
|
let _ = enc.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_encoder_auto_is_raw() {
|
||||||
|
let enc = create_encoder("auto", 75).unwrap();
|
||||||
|
assert_eq!(enc.name(), "raw+zstd");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ impl RawEncoder {
|
|||||||
let mut dirty_rects = Vec::new();
|
let mut dirty_rects = Vec::new();
|
||||||
let stride = (width * 4) as usize;
|
let stride = (width * 4) as usize;
|
||||||
|
|
||||||
let blocks_x = (width + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
let blocks_x = width.div_ceil(BLOCK_SIZE);
|
||||||
let blocks_y = (height + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
let blocks_y = height.div_ceil(BLOCK_SIZE);
|
||||||
|
|
||||||
for by in 0..blocks_y {
|
for by in 0..blocks_y {
|
||||||
for bx in 0..blocks_x {
|
for bx in 0..blocks_x {
|
||||||
|
|||||||
300
agent/src/identity.rs
Normal file
300
agent/src/identity.rs
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
//! Deterministic, recomputable machine identity (`machine_uid`).
|
||||||
|
//!
|
||||||
|
//! SPEC-004 / v2-stable-identity Task 1.
|
||||||
|
//!
|
||||||
|
//! `machine_uid()` returns a stable, opaque identifier for *this physical
|
||||||
|
//! machine*. Unlike `agent_id` (a random UUID persisted in the config file,
|
||||||
|
//! which mints a fresh value — and thus a duplicate server row — whenever the
|
||||||
|
//! config is lost), `machine_uid` is **derived from the hardware/OS** and is
|
||||||
|
//! **recomputable**: the same machine yields the same id on every call with no
|
||||||
|
//! persistence required.
|
||||||
|
//!
|
||||||
|
//! - **Windows:** SHA-256 hash of the OS machine GUID read from
|
||||||
|
//! `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (a `REG_SZ`). The raw
|
||||||
|
//! GUID is never returned — only the opaque `muid_<hex>` derived from it.
|
||||||
|
//! - **Non-Windows (and Windows registry failure):** a random UUID persisted in
|
||||||
|
//! the agent's data directory, read back on subsequent runs so it is stable
|
||||||
|
//! across calls and process restarts.
|
||||||
|
//!
|
||||||
|
//! This module deliberately does NOT change `agent_id`/`generate_agent_id`.
|
||||||
|
//! `machine_uid` is reported *alongside* `agent_id`; the server-side dedup that
|
||||||
|
//! consumes it is a separate task.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Prefix marking the value as an opaque machine-uid (vs. a raw GUID/UUID).
|
||||||
|
const MUID_PREFIX: &str = "muid_";
|
||||||
|
|
||||||
|
/// Cached value — `machine_uid()` reads the registry / a file, so compute once
|
||||||
|
/// and reuse for the lifetime of the process.
|
||||||
|
static MACHINE_UID: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Return a deterministic, recomputable opaque machine identifier.
|
||||||
|
///
|
||||||
|
/// The result is non-empty and prefixed with [`MUID_PREFIX`]. It is cached after
|
||||||
|
/// the first call. On Windows it is derived purely from the OS machine GUID (no
|
||||||
|
/// persistence). If the Windows registry read fails — or on any non-Windows
|
||||||
|
/// platform — it degrades to a persisted random UUID (today's-behavior-equivalent
|
||||||
|
/// stability) rather than panicking.
|
||||||
|
pub fn machine_uid() -> String {
|
||||||
|
MACHINE_UID.get_or_init(compute_machine_uid).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the opaque id from a raw machine-identity string via SHA-256.
|
||||||
|
///
|
||||||
|
/// Returns `muid_<first-16-bytes-of-sha256, hex>`. Hashing makes the value
|
||||||
|
/// opaque (the raw `MachineGuid` is never exposed) while staying fully
|
||||||
|
/// deterministic for a given input.
|
||||||
|
fn derive_uid(raw: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(raw.as_bytes());
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
format!("{}{}", MUID_PREFIX, hex::encode(&hash[..16]))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn compute_machine_uid() -> String {
|
||||||
|
match read_machine_guid() {
|
||||||
|
Ok(guid) if !guid.trim().is_empty() => derive_uid(guid.trim()),
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"MachineGuid registry value was empty; falling back to persisted machine_uid"
|
||||||
|
);
|
||||||
|
persisted_uid()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to read MachineGuid from registry ({e}); falling back to persisted machine_uid"
|
||||||
|
);
|
||||||
|
persisted_uid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn compute_machine_uid() -> String {
|
||||||
|
// No OS machine GUID available — use the persisted random UUID, hashed for a
|
||||||
|
// uniform opaque shape with the Windows path.
|
||||||
|
persisted_uid()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (REG_SZ).
|
||||||
|
///
|
||||||
|
/// Uses `RegGetValueW`, which opens, queries, null-terminates, and (with
|
||||||
|
/// `RRF_RT_REG_SZ`) type-checks the value in one call.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn read_machine_guid() -> anyhow::Result<String> {
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||||
|
use windows::Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ};
|
||||||
|
|
||||||
|
fn to_wide(s: &str) -> Vec<u16> {
|
||||||
|
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let subkey = to_wide(r"SOFTWARE\Microsoft\Cryptography");
|
||||||
|
let value = to_wide("MachineGuid");
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// First query the required buffer size (in bytes).
|
||||||
|
let mut size: u32 = 0;
|
||||||
|
let status = RegGetValueW(
|
||||||
|
HKEY_LOCAL_MACHINE,
|
||||||
|
PCWSTR(subkey.as_ptr()),
|
||||||
|
PCWSTR(value.as_ptr()),
|
||||||
|
RRF_RT_REG_SZ,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&mut size),
|
||||||
|
);
|
||||||
|
if status != ERROR_SUCCESS {
|
||||||
|
return Err(anyhow!("RegGetValueW(size) failed: {:?}", status));
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
return Err(anyhow!("MachineGuid reported zero length"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `size` is bytes; allocate a u16 buffer large enough to hold it.
|
||||||
|
let len_u16 = size.div_ceil(2) as usize;
|
||||||
|
let mut buffer = vec![0u16; len_u16];
|
||||||
|
let mut size_out = size;
|
||||||
|
let status = RegGetValueW(
|
||||||
|
HKEY_LOCAL_MACHINE,
|
||||||
|
PCWSTR(subkey.as_ptr()),
|
||||||
|
PCWSTR(value.as_ptr()),
|
||||||
|
RRF_RT_REG_SZ,
|
||||||
|
None,
|
||||||
|
Some(buffer.as_mut_ptr() as *mut _),
|
||||||
|
Some(&mut size_out),
|
||||||
|
);
|
||||||
|
if status != ERROR_SUCCESS {
|
||||||
|
return Err(anyhow!("RegGetValueW(read) failed: {:?}", status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim the trailing NUL(s) that RegGetValueW guarantees.
|
||||||
|
let chars = size_out as usize / 2;
|
||||||
|
let slice = &buffer[..chars.min(buffer.len())];
|
||||||
|
let end = slice.iter().position(|&c| c == 0).unwrap_or(slice.len());
|
||||||
|
String::from_utf16(&slice[..end]).context("MachineGuid was not valid UTF-16")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read (or, on first use, generate and persist) a random UUID, then derive the
|
||||||
|
/// opaque id from it. This is the fallback identity: stable across calls and
|
||||||
|
/// process restarts because it is persisted to disk.
|
||||||
|
fn persisted_uid() -> String {
|
||||||
|
let path = fallback_uid_path();
|
||||||
|
|
||||||
|
// Try to read an existing value.
|
||||||
|
if let Some(ref p) = path {
|
||||||
|
if let Ok(contents) = std::fs::read_to_string(p) {
|
||||||
|
let trimmed = contents.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return derive_uid(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new random seed and persist it (best-effort).
|
||||||
|
let seed = uuid::Uuid::new_v4().to_string();
|
||||||
|
if let Some(ref p) = path {
|
||||||
|
if let Some(parent) = p.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::write(p, &seed) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not persist fallback machine_uid seed to {:?} ({e}); \
|
||||||
|
id will be stable for this process only",
|
||||||
|
p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"No writable data directory for fallback machine_uid seed; \
|
||||||
|
id will be stable for this process only"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
derive_uid(&seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Location of the persisted fallback seed file.
|
||||||
|
///
|
||||||
|
/// - **Windows:** `%ProgramData%\GuruConnect\machine_uid` (mirrors the agent
|
||||||
|
/// config location), used only when the registry read fails.
|
||||||
|
/// - **Non-Windows:** `$XDG_DATA_HOME/guruconnect/machine_uid`, falling back to
|
||||||
|
/// `$HOME/.local/share/guruconnect/machine_uid`, then a temp-dir path.
|
||||||
|
fn fallback_uid_path() -> Option<std::path::PathBuf> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Ok(program_data) = std::env::var("ProgramData") {
|
||||||
|
return Some(
|
||||||
|
std::path::PathBuf::from(program_data)
|
||||||
|
.join("GuruConnect")
|
||||||
|
.join("machine_uid"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||||
|
if !xdg.is_empty() {
|
||||||
|
return Some(
|
||||||
|
std::path::PathBuf::from(xdg)
|
||||||
|
.join("guruconnect")
|
||||||
|
.join("machine_uid"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
if !home.is_empty() {
|
||||||
|
return Some(
|
||||||
|
std::path::PathBuf::from(home)
|
||||||
|
.join(".local")
|
||||||
|
.join("share")
|
||||||
|
.join("guruconnect")
|
||||||
|
.join("machine_uid"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: a stable name in the system temp dir.
|
||||||
|
Some(std::env::temp_dir().join("guruconnect_machine_uid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn machine_uid_is_non_empty_and_prefixed() {
|
||||||
|
let uid = machine_uid();
|
||||||
|
assert!(!uid.is_empty(), "machine_uid must not be empty");
|
||||||
|
assert!(
|
||||||
|
uid.starts_with(MUID_PREFIX),
|
||||||
|
"machine_uid must start with {MUID_PREFIX}: got {uid}"
|
||||||
|
);
|
||||||
|
// muid_ + 16 bytes hex (32 chars).
|
||||||
|
assert_eq!(
|
||||||
|
uid.len(),
|
||||||
|
MUID_PREFIX.len() + 32,
|
||||||
|
"unexpected machine_uid length: {uid}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
uid[MUID_PREFIX.len()..]
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_hexdigit()),
|
||||||
|
"machine_uid suffix must be lowercase hex: {uid}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn machine_uid_is_deterministic_across_calls() {
|
||||||
|
// The cached public API must be stable.
|
||||||
|
assert_eq!(machine_uid(), machine_uid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_uid_is_deterministic() {
|
||||||
|
// Same input -> same output; different input -> different output.
|
||||||
|
let a = derive_uid("the-same-input");
|
||||||
|
let b = derive_uid("the-same-input");
|
||||||
|
let c = derive_uid("a-different-input");
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert_ne!(a, c);
|
||||||
|
assert!(a.starts_with(MUID_PREFIX));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The non-Windows fallback must be stable across calls because it persists
|
||||||
|
/// its seed. We exercise `persisted_uid()` directly (the public `machine_uid`
|
||||||
|
/// is cached, so it cannot demonstrate persistence on its own).
|
||||||
|
#[test]
|
||||||
|
fn persisted_uid_is_stable_across_calls() {
|
||||||
|
let first = persisted_uid();
|
||||||
|
let second = persisted_uid();
|
||||||
|
assert_eq!(
|
||||||
|
first, second,
|
||||||
|
"persisted fallback uid must be stable across calls"
|
||||||
|
);
|
||||||
|
assert!(first.starts_with(MUID_PREFIX));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On Windows specifically, the registry-derived path must be deterministic:
|
||||||
|
/// reading the same `MachineGuid` twice yields the same uid.
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn windows_machine_guid_path_is_deterministic() {
|
||||||
|
// If the registry read succeeds, two reads must agree and the derived
|
||||||
|
// uid must match. If it fails (unusual), the test still validates the
|
||||||
|
// fallback determinism via compute_machine_uid().
|
||||||
|
let a = compute_machine_uid();
|
||||||
|
let b = compute_machine_uid();
|
||||||
|
assert_eq!(a, b, "compute_machine_uid must be deterministic on Windows");
|
||||||
|
assert!(a.starts_with(MUID_PREFIX));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
//! Keyboard input simulation using Windows SendInput API
|
//! Keyboard input simulation using Windows SendInput API
|
||||||
|
//!
|
||||||
|
//! Injection is **scan-code based** (`KEYEVENTF_SCANCODE`) rather than virtual-key
|
||||||
|
//! based. Scan codes are layout-independent: the same physical key produces the same
|
||||||
|
//! scan code regardless of the remote keyboard layout, so the remote machine's active
|
||||||
|
//! layout (not the technician's) decides what character a key produces. The viewer
|
||||||
|
//! still carries the virtual-key code for logic that needs it, and we fall back to
|
||||||
|
//! deriving a scan code from the VK when the wire frame did not supply one.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
@@ -11,11 +18,13 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
|
|||||||
|
|
||||||
/// Keyboard input controller
|
/// Keyboard input controller
|
||||||
pub struct KeyboardController {
|
pub struct KeyboardController {
|
||||||
// Track modifier states for proper handling
|
/// Tracks which modifier keys this controller currently holds DOWN on the remote.
|
||||||
#[allow(dead_code)]
|
/// Used so a focus-loss / session-end re-sync can release any still-held modifier
|
||||||
|
/// and avoid "stuck" Ctrl/Alt/Shift/Win on the remote desktop.
|
||||||
modifiers: ModifierState,
|
modifiers: ModifierState,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tracks the down/up state of each modifier the agent has injected.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ModifierState {
|
struct ModifierState {
|
||||||
ctrl: bool,
|
ctrl: bool,
|
||||||
@@ -24,6 +33,55 @@ struct ModifierState {
|
|||||||
meta: bool,
|
meta: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ModifierState {
|
||||||
|
/// Record a modifier transition for `vk_code`. Returns `true` if `vk_code` is a
|
||||||
|
/// modifier key (and the state was updated), `false` otherwise.
|
||||||
|
fn record(&mut self, vk_code: u16, down: bool) -> bool {
|
||||||
|
match vk_code {
|
||||||
|
// VK_CONTROL / VK_LCONTROL / VK_RCONTROL
|
||||||
|
0x11 | 0xA2 | 0xA3 => {
|
||||||
|
self.ctrl = down;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// VK_MENU / VK_LMENU / VK_RMENU (Alt)
|
||||||
|
0x12 | 0xA4 | 0xA5 => {
|
||||||
|
self.alt = down;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// VK_SHIFT / VK_LSHIFT / VK_RSHIFT
|
||||||
|
0x10 | 0xA0 | 0xA1 => {
|
||||||
|
self.shift = down;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// VK_LWIN / VK_RWIN
|
||||||
|
0x5B | 0x5C => {
|
||||||
|
self.meta = down;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the VK codes of every modifier currently held down, then clear the state.
|
||||||
|
fn drain_held(&mut self) -> Vec<u16> {
|
||||||
|
let mut held = Vec::new();
|
||||||
|
if self.ctrl {
|
||||||
|
held.push(0x11);
|
||||||
|
}
|
||||||
|
if self.alt {
|
||||||
|
held.push(0x12);
|
||||||
|
}
|
||||||
|
if self.shift {
|
||||||
|
held.push(0x10);
|
||||||
|
}
|
||||||
|
if self.meta {
|
||||||
|
held.push(0x5B);
|
||||||
|
}
|
||||||
|
*self = ModifierState::default();
|
||||||
|
held
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl KeyboardController {
|
impl KeyboardController {
|
||||||
/// Create a new keyboard controller
|
/// Create a new keyboard controller
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
@@ -32,28 +90,75 @@ impl KeyboardController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Press a key down by virtual key code
|
/// Press a key down by virtual key code (scan code derived from the VK).
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn key_down(&mut self, vk_code: u16) -> Result<()> {
|
pub fn key_down(&mut self, vk_code: u16) -> Result<()> {
|
||||||
self.send_key(vk_code, true)
|
self.send_key(vk_code, 0, false, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release a key by virtual key code
|
/// Release a key by virtual key code (scan code derived from the VK).
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn key_up(&mut self, vk_code: u16) -> Result<()> {
|
pub fn key_up(&mut self, vk_code: u16) -> Result<()> {
|
||||||
self.send_key(vk_code, false)
|
self.send_key(vk_code, 0, false, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a key event
|
/// Inject a full-fidelity key event.
|
||||||
|
///
|
||||||
|
/// `scan_code` is the hardware scan code captured by the viewer's low-level hook
|
||||||
|
/// (0 ⇒ derive it from `vk_code`). `is_extended` is the viewer-captured extended-key
|
||||||
|
/// flag (`LLKHF_EXTENDED`); when `false` the agent still derives the flag from the
|
||||||
|
/// VK / scan code so older viewers that don't set it stay correct.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn send_key(&mut self, vk_code: u16, down: bool) -> Result<()> {
|
pub fn key_event_full(
|
||||||
// Get scan code from virtual key
|
&mut self,
|
||||||
let scan_code = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 };
|
vk_code: u16,
|
||||||
|
scan_code: u16,
|
||||||
|
is_extended: bool,
|
||||||
|
down: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.send_key(vk_code, scan_code, is_extended, down)
|
||||||
|
}
|
||||||
|
|
||||||
let mut flags = KEYBD_EVENT_FLAGS::default();
|
/// Release every modifier this controller currently holds down on the remote.
|
||||||
|
///
|
||||||
|
/// Called on viewer focus loss and at session end so a Ctrl/Alt/Shift/Win that was
|
||||||
|
/// pressed but whose key-up never arrived (e.g. the technician alt-tabbed away) does
|
||||||
|
/// not stay latched on the remote desktop.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn release_all_modifiers(&mut self) -> Result<()> {
|
||||||
|
for vk in self.modifiers.drain_held() {
|
||||||
|
// Emit the key-up directly; drain_held already cleared the tracked state.
|
||||||
|
if let Err(e) = self.send_key(vk, 0, false, false) {
|
||||||
|
tracing::warn!("Failed to release held modifier vk={:#x}: {}", vk, e);
|
||||||
|
} else {
|
||||||
|
tracing::debug!("Released stuck modifier vk={:#x} on focus loss", vk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// Add extended key flag for certain keys
|
/// Send a key event using scan-code injection.
|
||||||
if Self::is_extended_key(vk_code) || (scan_code >> 8) == 0xE0 {
|
#[cfg(windows)]
|
||||||
|
fn send_key(
|
||||||
|
&mut self,
|
||||||
|
vk_code: u16,
|
||||||
|
scan_code: u16,
|
||||||
|
is_extended: bool,
|
||||||
|
down: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Track modifier state so we can release stuck modifiers later.
|
||||||
|
self.modifiers.record(vk_code, down);
|
||||||
|
|
||||||
|
// Prefer the viewer-supplied scan code; fall back to deriving one from the VK.
|
||||||
|
// MAPVK_VK_TO_VSC_EX yields a 0xE0-prefixed value for extended keys.
|
||||||
|
let mapped = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 };
|
||||||
|
let effective_scan = if scan_code != 0 { scan_code } else { mapped };
|
||||||
|
|
||||||
|
let mut flags = KEYBD_EVENT_FLAGS::default() | KEYEVENTF_SCANCODE;
|
||||||
|
|
||||||
|
// Add the extended flag if the viewer flagged it, the VK is inherently
|
||||||
|
// extended, or the mapped scan code carries the 0xE0 extended prefix.
|
||||||
|
if is_extended || Self::is_extended_key(vk_code) || (mapped >> 8) == 0xE0 {
|
||||||
flags |= KEYEVENTF_EXTENDEDKEY;
|
flags |= KEYEVENTF_EXTENDEDKEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,12 +166,16 @@ impl KeyboardController {
|
|||||||
flags |= KEYEVENTF_KEYUP;
|
flags |= KEYEVENTF_KEYUP;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For scan-code injection the low byte of the scan code is what Windows uses;
|
||||||
|
// the 0xE0 prefix is conveyed via KEYEVENTF_EXTENDEDKEY, not the wScan value.
|
||||||
|
let w_scan = (effective_scan & 0x00FF) as u16;
|
||||||
|
|
||||||
let input = INPUT {
|
let input = INPUT {
|
||||||
r#type: INPUT_KEYBOARD,
|
r#type: INPUT_KEYBOARD,
|
||||||
Anonymous: INPUT_0 {
|
Anonymous: INPUT_0 {
|
||||||
ki: KEYBDINPUT {
|
ki: KEYBDINPUT {
|
||||||
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(vk_code),
|
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
|
||||||
wScan: scan_code,
|
wScan: w_scan,
|
||||||
dwFlags: flags,
|
dwFlags: flags,
|
||||||
time: 0,
|
time: 0,
|
||||||
dwExtraInfo: 0,
|
dwExtraInfo: 0,
|
||||||
@@ -78,6 +187,7 @@ impl KeyboardController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Type a unicode character
|
/// Type a unicode character
|
||||||
|
#[allow(dead_code)]
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn type_char(&mut self, ch: char) -> Result<()> {
|
pub fn type_char(&mut self, ch: char) -> Result<()> {
|
||||||
let mut inputs = Vec::new();
|
let mut inputs = Vec::new();
|
||||||
@@ -119,6 +229,7 @@ impl KeyboardController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Type a string of text
|
/// Type a string of text
|
||||||
|
#[allow(dead_code)]
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn type_string(&mut self, text: &str) -> Result<()> {
|
pub fn type_string(&mut self, text: &str) -> Result<()> {
|
||||||
for ch in text.chars() {
|
for ch in text.chars() {
|
||||||
@@ -129,21 +240,35 @@ impl KeyboardController {
|
|||||||
|
|
||||||
/// Send Secure Attention Sequence (Ctrl+Alt+Delete)
|
/// Send Secure Attention Sequence (Ctrl+Alt+Delete)
|
||||||
///
|
///
|
||||||
/// This uses a multi-tier approach:
|
/// Ctrl+Alt+Del is the Secure Attention Sequence and **cannot** be injected via
|
||||||
/// 1. Try the GuruConnect SAS Service (runs as SYSTEM, handles via named pipe)
|
/// `SendInput` — Windows reserves it. It must be raised by `SendSAS`, which only
|
||||||
/// 2. Try the sas.dll directly (requires SYSTEM privileges)
|
/// works when the caller runs as SYSTEM (or has SeTcbPrivilege) AND the
|
||||||
/// 3. Fallback to key simulation (won't work on secure desktop)
|
/// `SoftwareSASGeneration` Winlogon policy permits software-generated SAS. The
|
||||||
|
/// managed installer is responsible for installing the SAS helper service (running
|
||||||
|
/// as SYSTEM) and setting that policy. See `set_software_sas_policy` in
|
||||||
|
/// `bin/sas_service.rs` and the `// TODO(installer)` note there.
|
||||||
|
///
|
||||||
|
/// Tiers, in order:
|
||||||
|
/// 1. The GuruConnect SAS helper service (SYSTEM) via named-pipe IPC — the supported path.
|
||||||
|
/// 2. Direct `sas.dll!SendSAS` — only succeeds if THIS process is already SYSTEM with the policy.
|
||||||
|
/// 3. Fallback key simulation — will NOT reach the secure desktop; logged as a clear failure.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn send_sas(&mut self) -> Result<()> {
|
pub fn send_sas(&mut self) -> Result<()> {
|
||||||
// Tier 1: Try the SAS service (named pipe IPC to SYSTEM service)
|
// Tier 1: Try the SAS service (named pipe IPC to SYSTEM service)
|
||||||
if let Ok(()) = crate::sas_client::request_sas() {
|
match crate::sas_client::request_sas() {
|
||||||
|
Ok(()) => {
|
||||||
tracing::info!("SAS sent via GuruConnect SAS Service");
|
tracing::info!("SAS sent via GuruConnect SAS Service");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"SAS helper service unavailable ({}); trying direct sas.dll",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("SAS service not available, trying direct sas.dll...");
|
// Tier 2: Try using the sas.dll directly (requires SYSTEM + SoftwareSASGeneration)
|
||||||
|
|
||||||
// Tier 2: Try using the sas.dll directly (requires SYSTEM privileges)
|
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
||||||
|
|
||||||
@@ -154,49 +279,33 @@ impl KeyboardController {
|
|||||||
if let Ok(lib) = lib {
|
if let Ok(lib) = lib {
|
||||||
let proc_name = b"SendSAS\0";
|
let proc_name = b"SendSAS\0";
|
||||||
if let Some(proc) = GetProcAddress(lib, windows::core::PCSTR(proc_name.as_ptr())) {
|
if let Some(proc) = GetProcAddress(lib, windows::core::PCSTR(proc_name.as_ptr())) {
|
||||||
// SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del
|
// SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del.
|
||||||
|
// It silently no-ops if the caller lacks privilege / the policy is
|
||||||
|
// unset, so we cannot detect success here — but it is the best
|
||||||
|
// effort short of the SYSTEM helper.
|
||||||
let send_sas: extern "system" fn(i32) = std::mem::transmute(proc);
|
let send_sas: extern "system" fn(i32) = std::mem::transmute(proc);
|
||||||
send_sas(0); // FALSE = Ctrl+Alt+Del
|
send_sas(0); // FALSE = Ctrl+Alt+Del
|
||||||
tracing::info!("SAS sent via direct sas.dll call");
|
tracing::info!("SAS attempted via direct sas.dll call (effective only if SYSTEM + SoftwareSASGeneration policy set)");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 3: Fallback - try sending the keys (won't work on secure desktop)
|
// Tier 3: SAS could not be delivered through any privileged path. A plain
|
||||||
tracing::warn!("SAS service and sas.dll not available, Ctrl+Alt+Del may not work");
|
// SendInput of Ctrl+Alt+Del never reaches the secure desktop, so report a
|
||||||
|
// clear, actionable error instead of pretending it worked.
|
||||||
// VK codes
|
let msg = "Ctrl+Alt+Del could not be delivered: the GuruConnect SAS helper \
|
||||||
const VK_CONTROL: u16 = 0x11;
|
service is not running and sas.dll!SendSAS is unavailable. Ensure the \
|
||||||
const VK_MENU: u16 = 0x12; // Alt
|
SAS service is installed (runs as SYSTEM) and the SoftwareSASGeneration \
|
||||||
const VK_DELETE: u16 = 0x2E;
|
policy is enabled by the installer.";
|
||||||
|
tracing::error!("{}", msg);
|
||||||
// Press keys
|
anyhow::bail!("{}", msg)
|
||||||
self.key_down(VK_CONTROL)?;
|
|
||||||
self.key_down(VK_MENU)?;
|
|
||||||
self.key_down(VK_DELETE)?;
|
|
||||||
|
|
||||||
// Release keys
|
|
||||||
self.key_up(VK_DELETE)?;
|
|
||||||
self.key_up(VK_MENU)?;
|
|
||||||
self.key_up(VK_CONTROL)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a virtual key code is an extended key
|
/// Check if a virtual key code is an extended key
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn is_extended_key(vk: u16) -> bool {
|
fn is_extended_key(vk: u16) -> bool {
|
||||||
matches!(
|
vk_is_extended(vk)
|
||||||
vk,
|
|
||||||
0x21..=0x28 | // Page Up, Page Down, End, Home, Arrow keys
|
|
||||||
0x2D | 0x2E | // Insert, Delete
|
|
||||||
0x5B | 0x5C | // Left/Right Windows keys
|
|
||||||
0x5D | // Applications key
|
|
||||||
0x6F | // Numpad Divide
|
|
||||||
0x90 | // Num Lock
|
|
||||||
0x91 // Scroll Lock
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send input events
|
/// Send input events
|
||||||
@@ -221,6 +330,22 @@ impl KeyboardController {
|
|||||||
anyhow::bail!("Keyboard input only supported on Windows")
|
anyhow::bail!("Keyboard input only supported on Windows")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn key_event_full(
|
||||||
|
&mut self,
|
||||||
|
_vk_code: u16,
|
||||||
|
_scan_code: u16,
|
||||||
|
_is_extended: bool,
|
||||||
|
_down: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
anyhow::bail!("Keyboard input only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn release_all_modifiers(&mut self) -> Result<()> {
|
||||||
|
anyhow::bail!("Keyboard input only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub fn type_char(&mut self, _ch: char) -> Result<()> {
|
pub fn type_char(&mut self, _ch: char) -> Result<()> {
|
||||||
anyhow::bail!("Keyboard input only supported on Windows")
|
anyhow::bail!("Keyboard input only supported on Windows")
|
||||||
@@ -290,3 +415,121 @@ pub mod vk {
|
|||||||
pub const LMENU: u16 = 0xA4; // Left Alt
|
pub const LMENU: u16 = 0xA4; // Left Alt
|
||||||
pub const RMENU: u16 = 0xA5; // Right Alt
|
pub const RMENU: u16 = 0xA5; // Right Alt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a Windows virtual-key code is an "extended" key.
|
||||||
|
///
|
||||||
|
/// Extended keys must be injected with `KEYEVENTF_EXTENDEDKEY`. This is the
|
||||||
|
/// platform-independent classifier so the determination can be unit-tested off-Windows;
|
||||||
|
/// the `#[cfg(windows)]` injection path delegates here. The viewer-captured
|
||||||
|
/// `LLKHF_EXTENDED` flag is authoritative when present; this is the fallback used when
|
||||||
|
/// the wire frame did not carry it (older viewers / VK-only synthesis).
|
||||||
|
pub fn vk_is_extended(vk: u16) -> bool {
|
||||||
|
matches!(
|
||||||
|
vk,
|
||||||
|
0x21..=0x28 | // Page Up, Page Down, End, Home, Arrow keys
|
||||||
|
0x2D | 0x2E | // Insert, Delete
|
||||||
|
0x5B | 0x5C | // Left/Right Windows keys
|
||||||
|
0x5D | // Applications key
|
||||||
|
0x6F | // Numpad Divide
|
||||||
|
0x90 | // Num Lock
|
||||||
|
0x91 | // Scroll Lock
|
||||||
|
0xA3 | // Right Control
|
||||||
|
0xA5 // Right Alt (AltGr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extended_keys_are_flagged() {
|
||||||
|
// Arrows / navigation block.
|
||||||
|
for vk in [0x21u16, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28] {
|
||||||
|
assert!(vk_is_extended(vk), "vk={:#x} should be extended", vk);
|
||||||
|
}
|
||||||
|
// Insert / Delete.
|
||||||
|
assert!(vk_is_extended(0x2D));
|
||||||
|
assert!(vk_is_extended(0x2E));
|
||||||
|
// Win keys, Apps, NumLock, numpad Divide.
|
||||||
|
assert!(vk_is_extended(0x5B));
|
||||||
|
assert!(vk_is_extended(0x5C));
|
||||||
|
assert!(vk_is_extended(0x5D));
|
||||||
|
assert!(vk_is_extended(0x6F));
|
||||||
|
assert!(vk_is_extended(0x90));
|
||||||
|
// Right Ctrl / Right Alt.
|
||||||
|
assert!(vk_is_extended(0xA3));
|
||||||
|
assert!(vk_is_extended(0xA5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_extended_keys_are_not_flagged() {
|
||||||
|
// Letters, digits, space, enter, left modifiers, numpad digits.
|
||||||
|
for vk in [
|
||||||
|
0x41u16, // A
|
||||||
|
0x5A, // Z
|
||||||
|
0x30, // 0
|
||||||
|
0x20, // Space
|
||||||
|
0x0D, // Enter
|
||||||
|
0xA0, // Left Shift
|
||||||
|
0xA2, // Left Control
|
||||||
|
0xA4, // Left Alt
|
||||||
|
0x60, // Numpad 0
|
||||||
|
0x6A, // Numpad Multiply (NOT extended; only Divide is)
|
||||||
|
] {
|
||||||
|
assert!(!vk_is_extended(vk), "vk={:#x} should NOT be extended", vk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifier_state_records_ctrl_alt_shift_win() {
|
||||||
|
let mut m = ModifierState::default();
|
||||||
|
// Each of the VK aliases maps to its modifier flag.
|
||||||
|
assert!(m.record(0x11, true)); // VK_CONTROL
|
||||||
|
assert!(m.ctrl);
|
||||||
|
assert!(m.record(0xA4, true)); // VK_LMENU (Alt)
|
||||||
|
assert!(m.alt);
|
||||||
|
assert!(m.record(0xA0, true)); // VK_LSHIFT
|
||||||
|
assert!(m.shift);
|
||||||
|
assert!(m.record(0x5C, true)); // VK_RWIN
|
||||||
|
assert!(m.meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifier_state_ignores_non_modifiers() {
|
||||||
|
let mut m = ModifierState::default();
|
||||||
|
assert!(!m.record(0x41, true)); // 'A' is not a modifier
|
||||||
|
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifier_state_tracks_down_then_up() {
|
||||||
|
let mut m = ModifierState::default();
|
||||||
|
m.record(0x11, true); // Ctrl down
|
||||||
|
assert!(m.ctrl);
|
||||||
|
m.record(0x11, false); // Ctrl up
|
||||||
|
assert!(!m.ctrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_held_returns_and_clears_held_modifiers() {
|
||||||
|
let mut m = ModifierState::default();
|
||||||
|
m.record(0xA2, true); // Left Ctrl -> ctrl
|
||||||
|
m.record(0x12, true); // Alt
|
||||||
|
// Shift and Win were never pressed.
|
||||||
|
let mut held = m.drain_held();
|
||||||
|
held.sort_unstable();
|
||||||
|
// Canonical VKs returned: Ctrl(0x11), Alt(0x12).
|
||||||
|
assert_eq!(held, vec![0x11u16, 0x12]);
|
||||||
|
// State is cleared after draining.
|
||||||
|
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
|
||||||
|
// A second drain yields nothing.
|
||||||
|
assert!(m.drain_held().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_held_empty_when_nothing_pressed() {
|
||||||
|
let mut m = ModifierState::default();
|
||||||
|
assert!(m.drain_held().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
mod keyboard;
|
mod keyboard;
|
||||||
mod mouse;
|
mod mouse;
|
||||||
|
|
||||||
|
pub use keyboard::vk_is_extended;
|
||||||
pub use keyboard::KeyboardController;
|
pub use keyboard::KeyboardController;
|
||||||
pub use mouse::MouseController;
|
pub use mouse::MouseController;
|
||||||
|
|
||||||
@@ -26,11 +27,13 @@ impl InputController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get mouse controller
|
/// Get mouse controller
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn mouse(&mut self) -> &mut MouseController {
|
pub fn mouse(&mut self) -> &mut MouseController {
|
||||||
&mut self.mouse
|
&mut self.mouse
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get keyboard controller
|
/// Get keyboard controller
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn keyboard(&mut self) -> &mut KeyboardController {
|
pub fn keyboard(&mut self) -> &mut KeyboardController {
|
||||||
&mut self.keyboard
|
&mut self.keyboard
|
||||||
}
|
}
|
||||||
@@ -54,7 +57,8 @@ impl InputController {
|
|||||||
self.mouse.scroll(delta_x, delta_y)
|
self.mouse.scroll(delta_x, delta_y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Press or release a key
|
/// Press or release a key by virtual-key code only (scan code derived from the VK).
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn key_event(&mut self, vk_code: u16, down: bool) -> Result<()> {
|
pub fn key_event(&mut self, vk_code: u16, down: bool) -> Result<()> {
|
||||||
if down {
|
if down {
|
||||||
self.keyboard.key_down(vk_code)
|
self.keyboard.key_down(vk_code)
|
||||||
@@ -63,7 +67,32 @@ impl InputController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inject a full-fidelity key event (VK + hardware scan code + extended-key flag).
|
||||||
|
///
|
||||||
|
/// This is the path used for relayed viewer keystrokes so that scan-code injection
|
||||||
|
/// (layout-independent) and the correct `KEYEVENTF_EXTENDEDKEY` flag are applied.
|
||||||
|
pub fn key_event_full(
|
||||||
|
&mut self,
|
||||||
|
vk_code: u16,
|
||||||
|
scan_code: u16,
|
||||||
|
is_extended: bool,
|
||||||
|
down: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.keyboard
|
||||||
|
.key_event_full(vk_code, scan_code, is_extended, down)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release any modifier keys currently held down on the remote.
|
||||||
|
///
|
||||||
|
/// Invoked when the viewer loses focus or the session ends so a Ctrl/Alt/Shift/Win
|
||||||
|
/// whose key-up never arrived does not stay latched on the remote desktop.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn release_all_modifiers(&mut self) -> Result<()> {
|
||||||
|
self.keyboard.release_all_modifiers()
|
||||||
|
}
|
||||||
|
|
||||||
/// Type a unicode character
|
/// Type a unicode character
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn type_unicode(&mut self, ch: char) -> Result<()> {
|
pub fn type_unicode(&mut self, ch: char) -> Result<()> {
|
||||||
self.keyboard.type_char(ch)
|
self.keyboard.type_char(ch)
|
||||||
}
|
}
|
||||||
@@ -80,7 +109,10 @@ pub enum MouseButton {
|
|||||||
Left,
|
Left,
|
||||||
Right,
|
Right,
|
||||||
Middle,
|
Middle,
|
||||||
|
// Extra mouse buttons; not yet produced by the viewer input mapping.
|
||||||
|
#[allow(dead_code)]
|
||||||
X1,
|
X1,
|
||||||
|
#[allow(dead_code)]
|
||||||
X2,
|
X2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! - UAC elevation with graceful fallback
|
//! - UAC elevation with graceful fallback
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::{
|
use windows::{
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
mod capture;
|
mod capture;
|
||||||
mod chat;
|
mod chat;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod consent;
|
||||||
mod encoder;
|
mod encoder;
|
||||||
|
mod identity;
|
||||||
mod input;
|
mod input;
|
||||||
mod install;
|
mod install;
|
||||||
mod sas_client;
|
mod sas_client;
|
||||||
@@ -452,7 +454,7 @@ fn show_error_box(_title: &str, message: &str) {
|
|||||||
fn show_debug_console() {
|
fn show_debug_console() {
|
||||||
unsafe {
|
unsafe {
|
||||||
let hwnd = GetConsoleWindow();
|
let hwnd = GetConsoleWindow();
|
||||||
if hwnd.0 == std::ptr::null_mut() {
|
if hwnd.0.is_null() {
|
||||||
let _ = AllocConsole();
|
let _ = AllocConsole();
|
||||||
} else {
|
} else {
|
||||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||||
|
|||||||
@@ -5,13 +5,11 @@
|
|||||||
|
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
|
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
|
||||||
const TIMEOUT_MS: u64 = 5000;
|
|
||||||
|
|
||||||
/// Request Ctrl+Alt+Del (Secure Attention Sequence) via the SAS service
|
/// Request Ctrl+Alt+Del (Secure Attention Sequence) via the SAS service
|
||||||
pub fn request_sas() -> Result<()> {
|
pub fn request_sas() -> Result<()> {
|
||||||
@@ -65,6 +63,8 @@ pub fn request_sas() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the SAS service is available
|
/// Check if the SAS service is available
|
||||||
|
// Used by the test module and the (not-yet-wired) SAS status API.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn is_service_available() -> bool {
|
pub fn is_service_available() -> bool {
|
||||||
// Try to open the pipe
|
// Try to open the pipe
|
||||||
if let Ok(mut pipe) = OpenOptions::new().read(true).write(true).open(PIPE_NAME) {
|
if let Ok(mut pipe) = OpenOptions::new().read(true).write(true).open(PIPE_NAME) {
|
||||||
@@ -81,6 +81,7 @@ pub fn is_service_available() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get information about SAS service status
|
/// Get information about SAS service status
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_service_status() -> String {
|
pub fn get_service_status() -> String {
|
||||||
if is_service_available() {
|
if is_service_available() {
|
||||||
"SAS service is running and responding".to_string()
|
"SAS service is running and responding".to_string()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
|
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
|
||||||
|
|
||||||
use crate::capture::{self, Capturer, Display};
|
use crate::capture::{self, Capturer};
|
||||||
use crate::chat::{ChatController, ChatMessage as ChatMsg};
|
use crate::chat::{ChatController, ChatMessage as ChatMsg};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::encoder::{self, Encoder};
|
use crate::encoder::{self, Encoder};
|
||||||
@@ -22,7 +22,7 @@ use crate::input::InputController;
|
|||||||
fn show_debug_console() {
|
fn show_debug_console() {
|
||||||
unsafe {
|
unsafe {
|
||||||
let hwnd = GetConsoleWindow();
|
let hwnd = GetConsoleWindow();
|
||||||
if hwnd.0 == std::ptr::null_mut() {
|
if hwnd.0.is_null() {
|
||||||
let _ = AllocConsole();
|
let _ = AllocConsole();
|
||||||
tracing::info!("Debug console window opened");
|
tracing::info!("Debug console window opened");
|
||||||
} else {
|
} else {
|
||||||
@@ -61,6 +61,10 @@ pub struct SessionManager {
|
|||||||
input: Option<InputController>,
|
input: Option<InputController>,
|
||||||
// Streaming state
|
// Streaming state
|
||||||
current_viewer_id: Option<String>,
|
current_viewer_id: Option<String>,
|
||||||
|
// Codec negotiated by the server for the current stream (Task 7). Set from
|
||||||
|
// StartStream.video_codec; the encoder is built from it (guarded by the
|
||||||
|
// agent's own hardware capability, with raw as the safe fallback).
|
||||||
|
negotiated_codec: crate::proto::VideoCodec,
|
||||||
// System info for status reports
|
// System info for status reports
|
||||||
hostname: String,
|
hostname: String,
|
||||||
is_elevated: bool,
|
is_elevated: bool,
|
||||||
@@ -87,6 +91,8 @@ impl SessionManager {
|
|||||||
encoder: None,
|
encoder: None,
|
||||||
input: None,
|
input: None,
|
||||||
current_viewer_id: None,
|
current_viewer_id: None,
|
||||||
|
// Default to RAW until the server negotiates otherwise (StartStream).
|
||||||
|
negotiated_codec: crate::proto::VideoCodec::Raw,
|
||||||
hostname,
|
hostname,
|
||||||
is_elevated,
|
is_elevated,
|
||||||
start_time: Instant::now(),
|
start_time: Instant::now(),
|
||||||
@@ -97,12 +103,16 @@ impl SessionManager {
|
|||||||
pub async fn connect(&mut self) -> Result<()> {
|
pub async fn connect(&mut self) -> Result<()> {
|
||||||
self.state = SessionState::Connecting;
|
self.state = SessionState::Connecting;
|
||||||
|
|
||||||
|
// Deterministic, recomputable identity reported alongside agent_id
|
||||||
|
// (v2 stable-identity Task 1). Cached after the first call.
|
||||||
|
let machine_uid = crate::identity::machine_uid();
|
||||||
let transport = WebSocketTransport::connect(
|
let transport = WebSocketTransport::connect(
|
||||||
&self.config.server_url,
|
&self.config.server_url,
|
||||||
&self.config.agent_id,
|
&self.config.agent_id,
|
||||||
&self.config.api_key,
|
&self.config.api_key,
|
||||||
Some(&self.hostname),
|
Some(&self.hostname),
|
||||||
self.config.support_code.as_deref(),
|
self.config.support_code.as_deref(),
|
||||||
|
Some(&machine_uid),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -130,7 +140,7 @@ impl SessionManager {
|
|||||||
|
|
||||||
// Get primary display with panic protection
|
// Get primary display with panic protection
|
||||||
tracing::debug!("Enumerating displays...");
|
tracing::debug!("Enumerating displays...");
|
||||||
let primary_display = match std::panic::catch_unwind(|| capture::primary_display()) {
|
let primary_display = match std::panic::catch_unwind(capture::primary_display) {
|
||||||
Ok(result) => result?,
|
Ok(result) => result?,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Panic during display enumeration: {:?}", e);
|
tracing::error!("Panic during display enumeration: {:?}", e);
|
||||||
@@ -168,14 +178,20 @@ impl SessionManager {
|
|||||||
self.capturer = Some(capturer);
|
self.capturer = Some(capturer);
|
||||||
tracing::info!("Capturer created successfully");
|
tracing::info!("Capturer created successfully");
|
||||||
|
|
||||||
// Create encoder with panic protection
|
// Create encoder from the NEGOTIATED codec (Task 7), guarded by the
|
||||||
|
// agent's own hardware capability. `create_encoder_for` selects the H.264
|
||||||
|
// encoder only if it can actually be constructed, otherwise it returns a
|
||||||
|
// working raw encoder — so this never breaks the session.
|
||||||
|
let chosen =
|
||||||
|
encoder::select_codec(self.negotiated_codec, encoder::supports_hardware_h264());
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Creating encoder (codec={}, quality={})...",
|
"Creating encoder (negotiated={:?}, chosen={:?}, quality={})...",
|
||||||
self.config.encoding.codec,
|
self.negotiated_codec,
|
||||||
|
chosen,
|
||||||
self.config.encoding.quality
|
self.config.encoding.quality
|
||||||
);
|
);
|
||||||
let encoder = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let encoder = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
encoder::create_encoder(&self.config.encoding.codec, self.config.encoding.quality)
|
encoder::create_encoder_for(chosen, self.config.encoding.quality)
|
||||||
})) {
|
})) {
|
||||||
Ok(result) => result?,
|
Ok(result) => result?,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -232,6 +248,13 @@ impl SessionManager {
|
|||||||
organization: self.config.company.clone().unwrap_or_default(),
|
organization: self.config.company.clone().unwrap_or_default(),
|
||||||
site: self.config.site.clone().unwrap_or_default(),
|
site: self.config.site.clone().unwrap_or_default(),
|
||||||
tags: self.config.tags.clone(),
|
tags: self.config.tags.clone(),
|
||||||
|
// Advertise hardware H.264 capability so the server can negotiate the
|
||||||
|
// codec (Task 7). Detected once and cached by the encoder module.
|
||||||
|
supports_h264: encoder::supports_hardware_h264(),
|
||||||
|
// Deterministic, recomputable hardware identity (v2 stable-identity
|
||||||
|
// Task 1). Reported alongside the unchanged random agent_id; cached
|
||||||
|
// after the first (registry) read.
|
||||||
|
machine_uid: crate::identity::machine_uid(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let msg = Message {
|
let msg = Message {
|
||||||
@@ -336,6 +359,15 @@ impl SessionManager {
|
|||||||
match payload {
|
match payload {
|
||||||
message::Payload::StartStream(start) => {
|
message::Payload::StartStream(start) => {
|
||||||
tracing::info!("StartStream received from viewer: {}", start.viewer_id);
|
tracing::info!("StartStream received from viewer: {}", start.viewer_id);
|
||||||
|
// Apply the server-negotiated codec (Task 7) BEFORE
|
||||||
|
// building the encoder. An older server that omits the
|
||||||
|
// field sends 0 = VIDEO_CODEC_RAW, preserving the raw
|
||||||
|
// default. `select_codec` (in init_streaming) re-guards
|
||||||
|
// against missing hardware.
|
||||||
|
self.negotiated_codec =
|
||||||
|
crate::proto::VideoCodec::try_from(start.video_codec)
|
||||||
|
.unwrap_or(crate::proto::VideoCodec::Raw);
|
||||||
|
tracing::info!("Server negotiated codec: {:?}", self.negotiated_codec);
|
||||||
if let Err(e) = self.init_streaming() {
|
if let Err(e) = self.init_streaming() {
|
||||||
tracing::error!("Failed to init streaming: {}", e);
|
tracing::error!("Failed to init streaming: {}", e);
|
||||||
} else {
|
} else {
|
||||||
@@ -369,6 +401,17 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
message::Payload::ConsentRequest(req) => {
|
||||||
|
// ATTENDED-MODE CONSENT (Task 5). The server is holding
|
||||||
|
// this session in `consent_state = pending` and will not
|
||||||
|
// surface it to the technician until we reply. Show the
|
||||||
|
// end user a native dialog and return their decision; the
|
||||||
|
// dialog blocks, so run it off the async runtime. If the
|
||||||
|
// user closes it / no choice is made, `prompt_consent`
|
||||||
|
// returns false (deny).
|
||||||
|
self.handle_consent_request(req.clone()).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,6 +541,69 @@ impl SessionManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle an attended-mode `ConsentRequest` from the server (Task 5).
|
||||||
|
///
|
||||||
|
/// Shows the end user a native consent dialog (off the async runtime, since
|
||||||
|
/// it blocks) and sends a `ConsentResponse` carrying their decision. A
|
||||||
|
/// closed dialog / unavailable surface is treated as a DENY. The server
|
||||||
|
/// gates the whole session on this reply, so we always send a response (even
|
||||||
|
/// on send failure the server's consent timeout will deny).
|
||||||
|
async fn handle_consent_request(&mut self, req: crate::proto::ConsentRequest) {
|
||||||
|
use crate::consent::{prompt_consent, ConsentAccessMode};
|
||||||
|
use crate::proto::ConsentResponse;
|
||||||
|
|
||||||
|
let session_id = req.session_id.clone();
|
||||||
|
let technician_name = req.technician_name.clone();
|
||||||
|
let access = ConsentAccessMode::from_proto(req.access_mode);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Consent requested for session {} by '{}' ({:?}); prompting end user",
|
||||||
|
session_id,
|
||||||
|
technician_name,
|
||||||
|
access
|
||||||
|
);
|
||||||
|
|
||||||
|
// The MessageBox blocks the calling thread, so it runs on the blocking
|
||||||
|
// pool to avoid stalling the tokio runtime. Note, however, that the main
|
||||||
|
// session loop `.await`s this method (see the ConsentRequest arm), so
|
||||||
|
// the loop is SUSPENDED for the user's entire think-time and does NOT
|
||||||
|
// process or respond to server heartbeats while the dialog is open.
|
||||||
|
// This is safe because CONSENT_TIMEOUT_SECS (60s, server-side) is within
|
||||||
|
// the server's 90s HEARTBEAT_TIMEOUT_SECS: the prompt resolves before the
|
||||||
|
// server would consider the agent dead, so the session is not torn down.
|
||||||
|
let granted = tokio::task::spawn_blocking(move || prompt_consent(&technician_name, access))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
// The blocking task panicked — fail closed (deny).
|
||||||
|
tracing::error!("Consent dialog task failed: {}; denying", e);
|
||||||
|
false
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"End user {} consent for session {}",
|
||||||
|
if granted { "GRANTED" } else { "DENIED" },
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = Message {
|
||||||
|
payload: Some(message::Payload::ConsentResponse(ConsentResponse {
|
||||||
|
session_id,
|
||||||
|
granted,
|
||||||
|
reason: if granted {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
"user_declined".to_string()
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(transport) = self.transport.as_mut() {
|
||||||
|
if let Err(e) = transport.send(response).await {
|
||||||
|
tracing::error!("Failed to send ConsentResponse: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle incoming message from server
|
/// Handle incoming message from server
|
||||||
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||||
match msg.payload {
|
match msg.payload {
|
||||||
@@ -548,19 +654,24 @@ impl SessionManager {
|
|||||||
|
|
||||||
Some(message::Payload::KeyEvent(key)) => {
|
Some(message::Payload::KeyEvent(key)) => {
|
||||||
if let Some(input) = self.input.as_mut() {
|
if let Some(input) = self.input.as_mut() {
|
||||||
input.key_event(key.vk_code as u16, key.down)?;
|
// Full-fidelity scan-code injection: pass the viewer-captured
|
||||||
|
// scan code and extended-key flag through. A scan_code of 0 (older
|
||||||
|
// viewers / synthesized events) makes the agent derive it from the VK.
|
||||||
|
input.key_event_full(
|
||||||
|
key.vk_code as u16,
|
||||||
|
key.scan_code as u16,
|
||||||
|
key.is_extended,
|
||||||
|
key.down,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(message::Payload::SpecialKey(special)) => {
|
Some(message::Payload::SpecialKey(special)) => {
|
||||||
if let Some(input) = self.input.as_mut() {
|
if let Some(input) = self.input.as_mut() {
|
||||||
use crate::proto::SpecialKey;
|
use crate::proto::SpecialKey;
|
||||||
match SpecialKey::try_from(special.key).ok() {
|
if let Ok(SpecialKey::CtrlAltDel) = SpecialKey::try_from(special.key) {
|
||||||
Some(SpecialKey::CtrlAltDel) => {
|
|
||||||
input.send_ctrl_alt_del()?;
|
input.send_ctrl_alt_del()?;
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! Handles adding/removing the agent from Windows startup.
|
//! Handles adding/removing the agent from Windows startup.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
@@ -58,7 +58,10 @@ pub fn add_to_startup() -> Result<()> {
|
|||||||
anyhow::bail!("Failed to open registry key: {:?}", result);
|
anyhow::bail!("Failed to open registry key: {:?}", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
|
let hkey_raw = std::mem::transmute::<
|
||||||
|
windows::Win32::Foundation::HANDLE,
|
||||||
|
windows::Win32::System::Registry::HKEY,
|
||||||
|
>(hkey);
|
||||||
|
|
||||||
// Set the value
|
// Set the value
|
||||||
let data_bytes =
|
let data_bytes =
|
||||||
@@ -116,7 +119,10 @@ pub fn remove_from_startup() -> Result<()> {
|
|||||||
return Ok(()); // Not an error if key doesn't exist
|
return Ok(()); // Not an error if key doesn't exist
|
||||||
}
|
}
|
||||||
|
|
||||||
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
|
let hkey_raw = std::mem::transmute::<
|
||||||
|
windows::Win32::Foundation::HANDLE,
|
||||||
|
windows::Win32::System::Registry::HKEY,
|
||||||
|
>(hkey);
|
||||||
|
|
||||||
let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr()));
|
let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr()));
|
||||||
|
|
||||||
@@ -180,6 +186,8 @@ pub fn uninstall() -> Result<()> {
|
|||||||
|
|
||||||
/// Install the SAS service if the binary is available
|
/// Install the SAS service if the binary is available
|
||||||
/// This allows the agent to send Ctrl+Alt+Del even without SYSTEM privileges
|
/// This allows the agent to send Ctrl+Alt+Del even without SYSTEM privileges
|
||||||
|
// Not yet wired into the CLI; retained as the SAS service management API.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn install_sas_service() -> Result<()> {
|
pub fn install_sas_service() -> Result<()> {
|
||||||
info!("Attempting to install SAS service...");
|
info!("Attempting to install SAS service...");
|
||||||
@@ -230,6 +238,8 @@ pub fn install_sas_service() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Uninstall the SAS service
|
/// Uninstall the SAS service
|
||||||
|
// Not yet wired into the CLI; retained as the SAS service management API.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn uninstall_sas_service() -> Result<()> {
|
pub fn uninstall_sas_service() -> Result<()> {
|
||||||
info!("Attempting to uninstall SAS service...");
|
info!("Attempting to uninstall SAS service...");
|
||||||
@@ -244,8 +254,7 @@ pub fn uninstall_sas_service() -> Result<()> {
|
|||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|
||||||
for path_opt in paths.iter() {
|
for path in paths.iter().flatten() {
|
||||||
if let Some(ref path) = path_opt {
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let output = std::process::Command::new(path).arg("uninstall").output();
|
let output = std::process::Command::new(path).arg("uninstall").output();
|
||||||
|
|
||||||
@@ -257,13 +266,14 @@ pub fn uninstall_sas_service() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
warn!("SAS service binary not found for uninstall");
|
warn!("SAS service binary not found for uninstall");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the SAS service is installed and running
|
/// Check if the SAS service is installed and running
|
||||||
|
// Not yet wired into the CLI; retained as the SAS service management API.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn check_sas_service() -> bool {
|
pub fn check_sas_service() -> bool {
|
||||||
use crate::sas_client;
|
use crate::sas_client;
|
||||||
|
|||||||
@@ -35,14 +35,25 @@ impl WebSocketTransport {
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
hostname: Option<&str>,
|
hostname: Option<&str>,
|
||||||
support_code: Option<&str>,
|
support_code: Option<&str>,
|
||||||
|
machine_uid: Option<&str>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// Build query parameters
|
// Build query parameters. agent_id + api_key are kept exactly as-is;
|
||||||
|
// machine_uid is appended ALONGSIDE them (v2 stable-identity Task 1) so
|
||||||
|
// the server sees the deterministic identity at connect time. It does not
|
||||||
|
// change registration keying (a separate server-side task).
|
||||||
let mut params = format!("agent_id={}&api_key={}", agent_id, api_key);
|
let mut params = format!("agent_id={}&api_key={}", agent_id, api_key);
|
||||||
|
|
||||||
if let Some(hostname) = hostname {
|
if let Some(hostname) = hostname {
|
||||||
params.push_str(&format!("&hostname={}", urlencoding::encode(hostname)));
|
params.push_str(&format!("&hostname={}", urlencoding::encode(hostname)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(machine_uid) = machine_uid {
|
||||||
|
params.push_str(&format!(
|
||||||
|
"&machine_uid={}",
|
||||||
|
urlencoding::encode(machine_uid)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(code) = support_code {
|
if let Some(code) = support_code {
|
||||||
params.push_str(&format!("&support_code={}", code));
|
params.push_str(&format!("&support_code={}", code));
|
||||||
}
|
}
|
||||||
@@ -82,7 +93,7 @@ impl WebSocketTransport {
|
|||||||
|
|
||||||
// Send as binary WebSocket message
|
// Send as binary WebSocket message
|
||||||
stream
|
stream
|
||||||
.send(WsMessage::Binary(buf.into()))
|
.send(WsMessage::Binary(buf))
|
||||||
.await
|
.await
|
||||||
.context("Failed to send message")?;
|
.context("Failed to send message")?;
|
||||||
|
|
||||||
@@ -132,6 +143,7 @@ impl WebSocketTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Receive a message (blocking)
|
/// Receive a message (blocking)
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn recv(&mut self) -> Result<Option<Message>> {
|
pub async fn recv(&mut self) -> Result<Option<Message>> {
|
||||||
// Return buffered message if available
|
// Return buffered message if available
|
||||||
if let Some(msg) = self.incoming.pop_front() {
|
if let Some(msg) = self.incoming.pop_front() {
|
||||||
@@ -164,7 +176,7 @@ impl WebSocketTransport {
|
|||||||
.context("Failed to decode protobuf message")?;
|
.context("Failed to decode protobuf message")?;
|
||||||
Ok(Some(msg))
|
Ok(Some(msg))
|
||||||
}
|
}
|
||||||
WsMessage::Ping(data) => {
|
WsMessage::Ping(_data) => {
|
||||||
// Pong is sent automatically by tungstenite
|
// Pong is sent automatically by tungstenite
|
||||||
tracing::trace!("Received ping");
|
tracing::trace!("Received ping");
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -193,6 +205,7 @@ impl WebSocketTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Close the connection
|
/// Close the connection
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn close(&mut self) -> Result<()> {
|
pub async fn close(&mut self) -> Result<()> {
|
||||||
let mut stream = self.stream.lock().await;
|
let mut stream = self.stream.lock().await;
|
||||||
stream.close(None).await?;
|
stream.close(None).await?;
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
//! - End session
|
//! - End session
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{info, warn};
|
use tracing::info;
|
||||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -28,7 +28,8 @@ pub enum TrayAction {
|
|||||||
/// Tray icon controller
|
/// Tray icon controller
|
||||||
pub struct TrayController {
|
pub struct TrayController {
|
||||||
_tray_icon: TrayIcon,
|
_tray_icon: TrayIcon,
|
||||||
menu: Menu,
|
// Kept alive for the lifetime of the tray icon; not read directly.
|
||||||
|
_menu: Menu,
|
||||||
end_session_item: MenuItem,
|
end_session_item: MenuItem,
|
||||||
debug_item: MenuItem,
|
debug_item: MenuItem,
|
||||||
status_item: MenuItem,
|
status_item: MenuItem,
|
||||||
@@ -86,7 +87,7 @@ impl TrayController {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
_tray_icon: tray_icon,
|
_tray_icon: tray_icon,
|
||||||
menu,
|
_menu: menu,
|
||||||
end_session_item,
|
end_session_item,
|
||||||
debug_item,
|
debug_item,
|
||||||
status_item,
|
status_item,
|
||||||
@@ -124,15 +125,10 @@ impl TrayController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for tray icon events (like double-click)
|
// Check for tray icon events (like double-click)
|
||||||
if let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
if let Ok(TrayIconEvent::DoubleClick { .. }) = TrayIconEvent::receiver().try_recv() {
|
||||||
match event {
|
|
||||||
TrayIconEvent::DoubleClick { .. } => {
|
|
||||||
info!("Tray icon double-clicked");
|
info!("Tray icon double-clicked");
|
||||||
return Some(TrayAction::ShowDetails);
|
return Some(TrayAction::ShowDetails);
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ use tracing::{error, info, warn};
|
|||||||
|
|
||||||
use crate::build_info;
|
use crate::build_info;
|
||||||
|
|
||||||
|
/// Whether to disable TLS certificate verification for update traffic.
|
||||||
|
///
|
||||||
|
/// Returns `true` ONLY in a debug build (`cfg!(debug_assertions)`) when the
|
||||||
|
/// `GURUCONNECT_DEV_INSECURE_TLS` environment variable is set. The `cfg!` gate
|
||||||
|
/// is compiled out of release builds, so a shipped agent ALWAYS verifies certs
|
||||||
|
/// regardless of environment — a MITM cannot serve a forged update binary over
|
||||||
|
/// an unverified channel. The env var lets a developer test against a
|
||||||
|
/// self-signed server without weakening production.
|
||||||
|
fn dev_insecure_tls() -> bool {
|
||||||
|
if cfg!(debug_assertions) && std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_ok() {
|
||||||
|
warn!(
|
||||||
|
"TLS certificate verification DISABLED (dev-insecure mode) — DO NOT use in production"
|
||||||
|
);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Version information from the server
|
/// Version information from the server
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
pub struct VersionInfo {
|
pub struct VersionInfo {
|
||||||
@@ -17,10 +36,14 @@ pub struct VersionInfo {
|
|||||||
pub download_url: String,
|
pub download_url: String,
|
||||||
pub checksum_sha256: String,
|
pub checksum_sha256: String,
|
||||||
pub is_mandatory: bool,
|
pub is_mandatory: bool,
|
||||||
|
// Part of the server JSON contract; deserialized but not yet surfaced in the UI.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub release_notes: Option<String>,
|
pub release_notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update state tracking
|
/// Update state tracking
|
||||||
|
// Future use: drive an update-progress indicator.
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum UpdateState {
|
pub enum UpdateState {
|
||||||
Idle,
|
Idle,
|
||||||
@@ -38,7 +61,7 @@ pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInf
|
|||||||
info!("Checking for updates at {}", url);
|
info!("Checking for updates at {}", url);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.danger_accept_invalid_certs(true) // For self-signed certs in dev
|
.danger_accept_invalid_certs(dev_insecure_tls())
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -104,7 +127,7 @@ pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
|
|||||||
info!("Downloading update from {}", version_info.download_url);
|
info!("Downloading update from {}", version_info.download_url);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.danger_accept_invalid_certs(true)
|
.danger_accept_invalid_certs(dev_insecure_tls())
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -130,6 +153,13 @@ pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify downloaded file checksum
|
/// Verify downloaded file checksum
|
||||||
|
///
|
||||||
|
/// NOTE: This is a transport-integrity check (catches truncated/corrupted
|
||||||
|
/// downloads), NOT a tamper defense. The expected checksum arrives over the
|
||||||
|
/// same channel as the binary, so an attacker who can serve a forged binary
|
||||||
|
/// can also serve a matching checksum. Tamper resistance comes from verifying
|
||||||
|
/// the TLS certificate of the update server (see `dev_insecure_tls`) and, as a
|
||||||
|
/// future hardening step, an embedded-public-key signature over the artifact.
|
||||||
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
|
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
|
||||||
info!("Verifying checksum...");
|
info!("Verifying checksum...");
|
||||||
|
|
||||||
@@ -156,6 +186,9 @@ pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<boo
|
|||||||
/// Perform the actual update installation
|
/// Perform the actual update installation
|
||||||
/// This renames the current executable and copies the new one in place
|
/// This renames the current executable and copies the new one in place
|
||||||
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
|
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
|
||||||
|
// TODO(security): defense-in-depth — verify an embedded-public-key signature
|
||||||
|
// over the update binary/manifest before install_update; see
|
||||||
|
// reports/2026-05-30-gc-audit.md
|
||||||
info!("Installing update...");
|
info!("Installing update...");
|
||||||
|
|
||||||
// Get current executable path
|
// Get current executable path
|
||||||
@@ -317,4 +350,31 @@ mod tests {
|
|||||||
assert!(!is_newer_version("0.1.0", "0.2.0"));
|
assert!(!is_newer_version("0.1.0", "0.2.0"));
|
||||||
assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
|
assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In a release build (`debug_assertions` off), `dev_insecure_tls()` MUST
|
||||||
|
/// return false regardless of the env var — the shipped agent can never
|
||||||
|
/// accept invalid certs. In a debug build, it returns true only when
|
||||||
|
/// `GURUCONNECT_DEV_INSECURE_TLS` is set; we cannot assert the env-var path
|
||||||
|
/// here without mutating process-global state (which would race other
|
||||||
|
/// tests), so we only assert the invariant that holds in the current
|
||||||
|
/// build profile.
|
||||||
|
#[test]
|
||||||
|
fn test_dev_insecure_tls_release_is_always_false() {
|
||||||
|
if !cfg!(debug_assertions) {
|
||||||
|
// Release/test-release profile: must be false no matter the env.
|
||||||
|
assert!(
|
||||||
|
!dev_insecure_tls(),
|
||||||
|
"release build must never disable TLS verification"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Debug profile: with the env var unset, must still be false.
|
||||||
|
// (We avoid setting it to prevent cross-test interference.)
|
||||||
|
if std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_err() {
|
||||||
|
assert!(
|
||||||
|
!dev_insecure_tls(),
|
||||||
|
"debug build without the env var must verify TLS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
522
agent/src/viewer/decoder.rs
Normal file
522
agent/src/viewer/decoder.rs
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
//! H.264 video decoder for the native viewer (Task 7).
|
||||||
|
//!
|
||||||
|
//! FIRST-CUT / COMPILE-VERIFIED ONLY. Decodes an H.264 elementary stream
|
||||||
|
//! (`EncodedFrame{h264}`) via a Media Foundation H.264 decoder MFT into NV12,
|
||||||
|
//! then converts NV12 -> BGRA so it can flow through the EXISTING raw render
|
||||||
|
//! path (`render::FrameData { compressed: false, BGRA }`). Not yet validated on
|
||||||
|
//! real hardware with a live stream — that is plan Task 8. On decode-init
|
||||||
|
//! failure the decoder reports an error and the viewer logs it; the raw-frame
|
||||||
|
//! render path is untouched for raw sessions.
|
||||||
|
//!
|
||||||
|
//! The decoder is created lazily on the first H.264 frame (so a raw session
|
||||||
|
//! never spins up MF). It is `!Send` (COM), so it lives on the viewer's receive
|
||||||
|
//! task and is wrapped accordingly by the caller.
|
||||||
|
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use windows::Win32::Media::MediaFoundation::{
|
||||||
|
IMFMediaType, IMFSample, IMFTransform, MFCreateMediaType, MFCreateMemoryBuffer, MFCreateSample,
|
||||||
|
MFMediaType_Video, MFShutdown, MFStartup, MFTEnumEx, MFVideoFormat_H264, MFVideoFormat_NV12,
|
||||||
|
MFSTARTUP_LITE, MFT_CATEGORY_VIDEO_DECODER, MFT_ENUM_FLAG_SORTANDFILTER, MFT_ENUM_FLAG_SYNCMFT,
|
||||||
|
MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, MFT_MESSAGE_NOTIFY_END_OF_STREAM,
|
||||||
|
MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_OUTPUT_DATA_BUFFER,
|
||||||
|
MFT_OUTPUT_STREAM_INFO, MFT_REGISTER_TYPE_INFO, MF_E_NOTACCEPTING,
|
||||||
|
MF_E_TRANSFORM_NEED_MORE_INPUT, MF_E_TRANSFORM_STREAM_CHANGE, MF_E_TRANSFORM_TYPE_NOT_SET,
|
||||||
|
MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A decoded NV12 frame and its dimensions, ready for NV12 -> BGRA conversion.
|
||||||
|
pub struct DecodedFrame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// BGRA pixels (4 bytes/px), ready for `render::FrameData`.
|
||||||
|
pub bgra: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media Foundation H.264 decoder wrapper.
|
||||||
|
pub struct H264Decoder {
|
||||||
|
transform: IMFTransform,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
streaming: bool,
|
||||||
|
input_stream_id: u32,
|
||||||
|
output_stream_id: u32,
|
||||||
|
mf_started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: H264Decoder is intentionally NOT `Send`. It wraps COM interfaces with
|
||||||
|
// thread affinity and is created + used entirely on the dedicated `gc-h264-decode`
|
||||||
|
// OS thread (see viewer::spawn_h264_decode_worker), so it never crosses a thread
|
||||||
|
// boundary and does not need a Send assertion.
|
||||||
|
|
||||||
|
impl H264Decoder {
|
||||||
|
/// Construct an H.264 decoder MFT and set its input type to H.264. The
|
||||||
|
/// output type (NV12) is negotiated after the first frames decode the
|
||||||
|
/// sequence header (we (re)read the real frame size on a stream change).
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
unsafe {
|
||||||
|
MFStartup(mf_version(), MFSTARTUP_LITE).context("MFStartup (decoder)")?;
|
||||||
|
let transform = match activate_decoder() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = MFShutdown();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut dec = Self {
|
||||||
|
transform,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
streaming: false,
|
||||||
|
input_stream_id: 0,
|
||||||
|
output_stream_id: 0,
|
||||||
|
mf_started: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
dec.configure_input()?;
|
||||||
|
Ok(dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the decoder input type to H.264 (no fixed frame size — the decoder
|
||||||
|
/// learns it from the bitstream).
|
||||||
|
unsafe fn configure_input(&mut self) -> Result<()> {
|
||||||
|
let in_type: IMFMediaType = MFCreateMediaType().context("MFCreateMediaType(dec in)")?;
|
||||||
|
in_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?;
|
||||||
|
in_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_H264)?;
|
||||||
|
self.transform
|
||||||
|
.SetInputType(self.input_stream_id, &in_type, 0)
|
||||||
|
.context("SetInputType(H264 decode)")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Negotiate the decoder's NV12 output type by ENUMERATING the available
|
||||||
|
/// output types it offers (these carry the decoder-negotiated frame size),
|
||||||
|
/// then setting the NV12 one. The Microsoft H.264 decoder MFT rejects a
|
||||||
|
/// hand-built, underspecified output type, so we must select from what it
|
||||||
|
/// exposes after it has parsed enough of the bitstream. Driven by a
|
||||||
|
/// STREAM_CHANGE / TYPE_NOT_SET round-trip — never set eagerly.
|
||||||
|
unsafe fn negotiate_output_type(&mut self) -> Result<()> {
|
||||||
|
let mut index: u32 = 0;
|
||||||
|
// GetOutputAvailableType returns Err (MF_E_NO_MORE_TYPES) past the last
|
||||||
|
// entry, which ends the enumeration.
|
||||||
|
while let Ok(mt) = self
|
||||||
|
.transform
|
||||||
|
.GetOutputAvailableType(self.output_stream_id, index)
|
||||||
|
{
|
||||||
|
let subtype = mt
|
||||||
|
.GetGUID(&MF_MT_SUBTYPE)
|
||||||
|
.context("read available output subtype")?;
|
||||||
|
if subtype == MFVideoFormat_NV12 {
|
||||||
|
self.transform
|
||||||
|
.SetOutputType(self.output_stream_id, &mt, 0)
|
||||||
|
.context("SetOutputType(NV12 decode)")?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
Err(anyhow!("decoder offered no NV12 output type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the negotiated output frame size from the decoder's current output type.
|
||||||
|
unsafe fn read_output_size(&mut self) -> Result<(u32, u32)> {
|
||||||
|
let out_type = self
|
||||||
|
.transform
|
||||||
|
.GetOutputCurrentType(self.output_stream_id)
|
||||||
|
.context("GetOutputCurrentType")?;
|
||||||
|
let packed = out_type
|
||||||
|
.GetUINT64(&MF_MT_FRAME_SIZE)
|
||||||
|
.context("read MF_MT_FRAME_SIZE")?;
|
||||||
|
let width = (packed >> 32) as u32;
|
||||||
|
let height = (packed & 0xFFFF_FFFF) as u32;
|
||||||
|
Ok((width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn ensure_streaming(&mut self) -> Result<()> {
|
||||||
|
if !self.streaming {
|
||||||
|
self.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, 0)
|
||||||
|
.context("decoder BEGIN_STREAMING")?;
|
||||||
|
self.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_START_OF_STREAM, 0)
|
||||||
|
.context("decoder START_OF_STREAM")?;
|
||||||
|
self.streaming = true;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one H.264 access unit and return all BGRA frames the decoder emits
|
||||||
|
/// in response. A single input access unit can legitimately yield zero, one,
|
||||||
|
/// or more decoded frames, so the result is a `Vec`.
|
||||||
|
///
|
||||||
|
/// This implements the Media Foundation MFT streaming contract: `ProcessInput`
|
||||||
|
/// may return `MF_E_NOTACCEPTING`, which is NOT an error — it means the decoder
|
||||||
|
/// has pending output that must be fully drained via `ProcessOutput` before it
|
||||||
|
/// will accept the next input. The previous implementation treated NOTACCEPTING
|
||||||
|
/// as fatal and only drained one frame per call, so once the MFT filled up it
|
||||||
|
/// rejected every subsequent frame (0xC00D36B5) and nothing rendered. We now
|
||||||
|
/// drain on back-pressure, retry the same (unconsumed) sample, then drain ALL
|
||||||
|
/// ready outputs before returning.
|
||||||
|
pub fn decode(&mut self, h264: &[u8], pts_100ns: i64) -> Result<Vec<DecodedFrame>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if h264.is_empty() {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
self.ensure_streaming()?;
|
||||||
|
|
||||||
|
let sample = make_input_sample(h264, pts_100ns)?;
|
||||||
|
|
||||||
|
// Submit the sample, tolerating back-pressure. On NOTACCEPTING the
|
||||||
|
// sample is NOT consumed, so we drain pending output and re-submit the
|
||||||
|
// same `&sample`.
|
||||||
|
loop {
|
||||||
|
match self
|
||||||
|
.transform
|
||||||
|
.ProcessInput(self.input_stream_id, &sample, 0)
|
||||||
|
{
|
||||||
|
// Input accepted (or accepted while still wanting more).
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => break,
|
||||||
|
// Back-pressure: drain a pending output, then retry the SAME
|
||||||
|
// sample (it was not consumed).
|
||||||
|
Err(e) if e.code() == MF_E_NOTACCEPTING => {
|
||||||
|
match self.drain_one()? {
|
||||||
|
Some(frame) => {
|
||||||
|
out.push(frame);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Pathological: decoder won't accept input yet has
|
||||||
|
// nothing to drain. Don't spin — warn once and drop
|
||||||
|
// this access unit.
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
"H.264 decoder reported NOTACCEPTING with no drainable output; dropping access unit"
|
||||||
|
);
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(anyhow!("decoder ProcessInput failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain every output the decoder has ready for this input.
|
||||||
|
while let Some(frame) = self.drain_one()? {
|
||||||
|
out.push(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain one decoded output sample, handling the initial NV12 output-type
|
||||||
|
/// negotiation (`MF_E_TRANSFORM_STREAM_CHANGE`).
|
||||||
|
unsafe fn drain_one(&mut self) -> Result<Option<DecodedFrame>> {
|
||||||
|
// Tracks whether we have already (re)negotiated the output type during
|
||||||
|
// THIS drain call. Guards against spinning forever if the decoder keeps
|
||||||
|
// surfacing TYPE_NOT_SET / STREAM_CHANGE without making progress.
|
||||||
|
let mut negotiated = false;
|
||||||
|
loop {
|
||||||
|
let stream_info: MFT_OUTPUT_STREAM_INFO = self
|
||||||
|
.transform
|
||||||
|
.GetOutputStreamInfo(self.output_stream_id)
|
||||||
|
.context("decoder GetOutputStreamInfo")?;
|
||||||
|
|
||||||
|
const MFT_OUTPUT_STREAM_PROVIDES_SAMPLES: u32 = 0x100;
|
||||||
|
let mft_provides = stream_info.dwFlags & MFT_OUTPUT_STREAM_PROVIDES_SAMPLES != 0;
|
||||||
|
|
||||||
|
let mut out_buffer = MFT_OUTPUT_DATA_BUFFER {
|
||||||
|
dwStreamID: self.output_stream_id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !mft_provides {
|
||||||
|
let alloc = stream_info.cbSize.max(self.guess_nv12_size());
|
||||||
|
let sample: IMFSample = MFCreateSample().context("MFCreateSample(dec out)")?;
|
||||||
|
let buffer =
|
||||||
|
MFCreateMemoryBuffer(alloc).context("MFCreateMemoryBuffer(dec out)")?;
|
||||||
|
sample.AddBuffer(&buffer)?;
|
||||||
|
out_buffer.pSample = std::mem::ManuallyDrop::new(Some(sample));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut status: u32 = 0;
|
||||||
|
let mut bufs = [out_buffer];
|
||||||
|
let hr = self.transform.ProcessOutput(0, &mut bufs, &mut status);
|
||||||
|
let produced = std::mem::ManuallyDrop::take(&mut bufs[0].pSample);
|
||||||
|
|
||||||
|
match hr {
|
||||||
|
Ok(()) => {
|
||||||
|
// (Re)read the negotiated size in case it just became known.
|
||||||
|
if let Ok((w, h)) = self.read_output_size() {
|
||||||
|
self.width = w;
|
||||||
|
self.height = h;
|
||||||
|
}
|
||||||
|
let Some(sample) = produced else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if self.width == 0 || self.height == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let nv12 = sample_to_vec(&sample)?;
|
||||||
|
let bgra = nv12_to_bgra(&nv12, self.width, self.height)?;
|
||||||
|
return Ok(Some(DecodedFrame {
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
bgra,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(e) if e.code() == MF_E_TRANSFORM_NEED_MORE_INPUT => return Ok(None),
|
||||||
|
// Both of these mean "you must (re)negotiate the output type now."
|
||||||
|
// STREAM_CHANGE fires once the decoder has parsed the sequence
|
||||||
|
// header and learned the real frame size; depending on input
|
||||||
|
// timing the MS decoder may surface TYPE_NOT_SET instead. Handle
|
||||||
|
// them identically: enumerate the decoder's available output
|
||||||
|
// types, set the NV12 one, record the negotiated size, and retry.
|
||||||
|
Err(e)
|
||||||
|
if e.code() == MF_E_TRANSFORM_STREAM_CHANGE
|
||||||
|
|| e.code() == MF_E_TRANSFORM_TYPE_NOT_SET =>
|
||||||
|
{
|
||||||
|
// We already negotiated once this drain yet the decoder still
|
||||||
|
// demands a type: bail rather than spin forever.
|
||||||
|
if negotiated {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"decoder still reports output type not set after renegotiation: {e:#}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.negotiate_output_type()
|
||||||
|
.context("decoder output renegotiation after stream change")?;
|
||||||
|
negotiated = true;
|
||||||
|
if let Ok((w, h)) = self.read_output_size() {
|
||||||
|
self.width = w;
|
||||||
|
self.height = h;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(anyhow!("decoder ProcessOutput failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conservative NV12 buffer estimate when the decoder doesn't report cbSize.
|
||||||
|
fn guess_nv12_size(&self) -> u32 {
|
||||||
|
if self.width != 0 && self.height != 0 {
|
||||||
|
self.width * self.height * 3 / 2
|
||||||
|
} else {
|
||||||
|
// 1080p NV12 upper bound until the real size is known.
|
||||||
|
1920 * 1080 * 3 / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for H264Decoder {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
if self.streaming {
|
||||||
|
let _ = self
|
||||||
|
.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0);
|
||||||
|
let _ = self
|
||||||
|
.transform
|
||||||
|
.ProcessMessage(MFT_MESSAGE_NOTIFY_END_STREAMING, 0);
|
||||||
|
}
|
||||||
|
if self.mf_started {
|
||||||
|
let _ = MFShutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumerate and activate an H.264 decoder MFT (hardware preferred, software
|
||||||
|
/// acceptable — decode does not require a HW encoder).
|
||||||
|
unsafe fn activate_decoder() -> Result<IMFTransform> {
|
||||||
|
let input_type = MFT_REGISTER_TYPE_INFO {
|
||||||
|
guidMajorType: MFMediaType_Video,
|
||||||
|
guidSubtype: MFVideoFormat_H264,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut activate_ptr: *mut Option<windows::Win32::Media::MediaFoundation::IMFActivate> =
|
||||||
|
std::ptr::null_mut();
|
||||||
|
let mut count: u32 = 0;
|
||||||
|
|
||||||
|
// Allow both HW and SW decoders; SYNCMFT keeps the simple ProcessInput/Output
|
||||||
|
// contract this first cut uses.
|
||||||
|
MFTEnumEx(
|
||||||
|
MFT_CATEGORY_VIDEO_DECODER,
|
||||||
|
MFT_ENUM_FLAG_SYNCMFT | MFT_ENUM_FLAG_SORTANDFILTER,
|
||||||
|
Some(&input_type as *const _),
|
||||||
|
None,
|
||||||
|
&mut activate_ptr,
|
||||||
|
&mut count,
|
||||||
|
)
|
||||||
|
.context("MFTEnumEx (H264 decoder)")?;
|
||||||
|
|
||||||
|
if count == 0 || activate_ptr.is_null() {
|
||||||
|
if !activate_ptr.is_null() {
|
||||||
|
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
|
||||||
|
}
|
||||||
|
return Err(anyhow!("no H.264 decoder MFT available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = std::slice::from_raw_parts_mut(activate_ptr, count as usize);
|
||||||
|
let mut chosen: Option<IMFTransform> = None;
|
||||||
|
for entry in slice.iter_mut() {
|
||||||
|
if chosen.is_none() {
|
||||||
|
if let Some(activate) = entry.as_ref() {
|
||||||
|
if let Ok(t) = activate.ActivateObject::<IMFTransform>() {
|
||||||
|
chosen = Some(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.take();
|
||||||
|
}
|
||||||
|
windows::Win32::System::Com::CoTaskMemFree(Some(activate_ptr as *const _));
|
||||||
|
|
||||||
|
chosen.ok_or_else(|| anyhow!("failed to activate H.264 decoder MFT"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap an H.264 access unit into an IMFSample.
|
||||||
|
unsafe fn make_input_sample(data: &[u8], pts_100ns: i64) -> Result<IMFSample> {
|
||||||
|
let sample: IMFSample = MFCreateSample().context("MFCreateSample(dec in)")?;
|
||||||
|
let buffer = MFCreateMemoryBuffer(data.len() as u32).context("MFCreateMemoryBuffer(dec in)")?;
|
||||||
|
|
||||||
|
let mut ptr: *mut u8 = std::ptr::null_mut();
|
||||||
|
let mut max_len: u32 = 0;
|
||||||
|
buffer
|
||||||
|
.Lock(&mut ptr, Some(&mut max_len), None)
|
||||||
|
.context("decoder input Lock")?;
|
||||||
|
if (max_len as usize) < data.len() || ptr.is_null() {
|
||||||
|
let _ = buffer.Unlock();
|
||||||
|
return Err(anyhow!("MF buffer too small for H.264 access unit"));
|
||||||
|
}
|
||||||
|
std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, data.len());
|
||||||
|
buffer.SetCurrentLength(data.len() as u32)?;
|
||||||
|
buffer.Unlock()?;
|
||||||
|
|
||||||
|
sample.AddBuffer(&buffer)?;
|
||||||
|
sample.SetSampleTime(pts_100ns)?;
|
||||||
|
Ok(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a sample's contiguous bytes into a Vec.
|
||||||
|
unsafe fn sample_to_vec(sample: &IMFSample) -> Result<Vec<u8>> {
|
||||||
|
let buffer = sample
|
||||||
|
.ConvertToContiguousBuffer()
|
||||||
|
.context("decoder ConvertToContiguousBuffer")?;
|
||||||
|
let mut ptr: *mut u8 = std::ptr::null_mut();
|
||||||
|
let mut len: u32 = 0;
|
||||||
|
buffer
|
||||||
|
.Lock(&mut ptr, None, Some(&mut len))
|
||||||
|
.context("decoder output Lock")?;
|
||||||
|
let out = if ptr.is_null() || len == 0 {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
std::slice::from_raw_parts(ptr, len as usize).to_vec()
|
||||||
|
};
|
||||||
|
let _ = buffer.Unlock();
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MF version word for `MFStartup` (see encoder::h264).
|
||||||
|
fn mf_version() -> u32 {
|
||||||
|
0x0002_0070
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an NV12 buffer to BGRA (BT.601 limited range). Inverse of the
|
||||||
|
/// encoder's BGRA->NV12. Shared with the unit tests below.
|
||||||
|
pub fn nv12_to_bgra(nv12: &[u8], width: u32, height: u32) -> Result<Vec<u8>> {
|
||||||
|
let w = width as usize;
|
||||||
|
let h = height as usize;
|
||||||
|
let y_size = w * h;
|
||||||
|
let need = y_size * 3 / 2;
|
||||||
|
if nv12.len() < need {
|
||||||
|
return Err(anyhow!("NV12 buffer too small: {} < {}", nv12.len(), need));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (y_plane, uv_plane) = nv12.split_at(y_size);
|
||||||
|
let mut bgra = vec![0u8; w * h * 4];
|
||||||
|
let chroma_cols = w / 2;
|
||||||
|
|
||||||
|
for row in 0..h {
|
||||||
|
for col in 0..w {
|
||||||
|
let y = y_plane[row * w + col] as i32;
|
||||||
|
let cx = col / 2;
|
||||||
|
let cy = row / 2;
|
||||||
|
let uv_idx = (cy * chroma_cols + cx) * 2;
|
||||||
|
let u = uv_plane[uv_idx] as i32;
|
||||||
|
let v = uv_plane[uv_idx + 1] as i32;
|
||||||
|
|
||||||
|
// BT.601 limited-range YUV -> RGB.
|
||||||
|
let c = y - 16;
|
||||||
|
let d = u - 128;
|
||||||
|
let e = v - 128;
|
||||||
|
let r = ((298 * c + 409 * e + 128) >> 8).clamp(0, 255);
|
||||||
|
let g = ((298 * c - 100 * d - 208 * e + 128) >> 8).clamp(0, 255);
|
||||||
|
let b = ((298 * c + 516 * d + 128) >> 8).clamp(0, 255);
|
||||||
|
|
||||||
|
let px = (row * w + col) * 4;
|
||||||
|
bgra[px] = b as u8;
|
||||||
|
bgra[px + 1] = g as u8;
|
||||||
|
bgra[px + 2] = r as u8;
|
||||||
|
bgra[px + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bgra)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::encoder::color::{bgra_to_nv12, nv12_size};
|
||||||
|
|
||||||
|
/// Round-trip a solid color through BGRA->NV12->BGRA. Chroma subsampling and
|
||||||
|
/// limited-range rounding introduce small error, so allow a tolerance.
|
||||||
|
#[test]
|
||||||
|
fn nv12_bgra_roundtrip_is_approximately_lossless_for_solid_color() {
|
||||||
|
let w = 4u32;
|
||||||
|
let h = 4u32;
|
||||||
|
// Mid gray.
|
||||||
|
let mut bgra = vec![0u8; (w * h * 4) as usize];
|
||||||
|
for px in bgra.chunks_mut(4) {
|
||||||
|
px[0] = 120; // B
|
||||||
|
px[1] = 120; // G
|
||||||
|
px[2] = 120; // R
|
||||||
|
px[3] = 255;
|
||||||
|
}
|
||||||
|
let mut nv12 = vec![0u8; nv12_size(w, h)];
|
||||||
|
bgra_to_nv12(&bgra, w, h, &mut nv12).unwrap();
|
||||||
|
let back = nv12_to_bgra(&nv12, w, h).unwrap();
|
||||||
|
|
||||||
|
for (orig, got) in bgra.chunks(4).zip(back.chunks(4)) {
|
||||||
|
for ch in 0..3 {
|
||||||
|
let diff = (orig[ch] as i32 - got[ch] as i32).abs();
|
||||||
|
assert!(diff <= 6, "channel {ch} drift {diff} too large");
|
||||||
|
}
|
||||||
|
assert_eq!(got[3], 255, "alpha must be opaque");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nv12_to_bgra_rejects_short_buffer() {
|
||||||
|
let nv12 = vec![0u8; 4];
|
||||||
|
assert!(nv12_to_bgra(&nv12, 16, 16).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn black_nv12_decodes_to_black_bgra() {
|
||||||
|
// Limited-range black: Y=16, UV=128.
|
||||||
|
let w = 2u32;
|
||||||
|
let h = 2u32;
|
||||||
|
let mut nv12 = vec![128u8; nv12_size(w, h)];
|
||||||
|
for y in nv12.iter_mut().take((w * h) as usize) {
|
||||||
|
*y = 16;
|
||||||
|
}
|
||||||
|
let bgra = nv12_to_bgra(&nv12, w, h).unwrap();
|
||||||
|
for px in bgra.chunks(4) {
|
||||||
|
assert!(px[0] <= 2 && px[1] <= 2 && px[2] <= 2, "near-black");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
//! Low-level keyboard hook for capturing all keys including Win key
|
//! Low-level keyboard hook for capturing system key combinations.
|
||||||
|
//!
|
||||||
|
//! `WH_KEYBOARD_LL` is a GLOBAL hook: the OS invokes it for ALL desktop input regardless
|
||||||
|
//! of which window is focused. We therefore gate diversion on the viewer's focus state.
|
||||||
|
//! ONLY when the viewer window actually has focus AND "send system keys to remote" is
|
||||||
|
//! enabled does the hook DIVERT the system combinations the local shell would otherwise
|
||||||
|
//! consume — the Windows key, Win+R, Win+E, Alt+Tab, Ctrl+Esc, Alt+Esc — and forward them
|
||||||
|
//! to the remote as full-fidelity `KeyEvent`s (virtual key + hardware scan code +
|
||||||
|
//! extended-key flag + modifier snapshot), returning 1 from the hook proc to suppress the
|
||||||
|
//! local handling. All other keys flow through the normal viewer input path.
|
||||||
|
//!
|
||||||
|
//! When the toggle is OFF, the viewer is not focused, or the key is not a system combo,
|
||||||
|
//! the hook diverts NOTHING — it falls through to `CallNextHookEx` and every key reaches
|
||||||
|
//! the local OS unchanged. This keeps the technician's own Start menu / Alt+Tab working
|
||||||
|
//! while the viewer sits unfocused in the background.
|
||||||
|
|
||||||
use super::InputEvent;
|
use super::InputEvent;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use crate::proto;
|
use crate::proto;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
@@ -12,36 +27,83 @@ use tracing::trace;
|
|||||||
use windows::{
|
use windows::{
|
||||||
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
|
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
|
||||||
Win32::UI::WindowsAndMessaging::{
|
Win32::UI::WindowsAndMessaging::{
|
||||||
CallNextHookEx, DispatchMessageW, GetMessageW, PeekMessageW, SetWindowsHookExW,
|
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT,
|
||||||
TranslateMessage, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE,
|
LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||||
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Global toggle: when `true`, system key combinations are diverted to the remote;
|
||||||
|
/// when `false`, the hook is transparent and the local OS handles them. Default ON.
|
||||||
|
///
|
||||||
|
/// Lives at module scope because the `WH_KEYBOARD_LL` callback is a bare `extern "system"`
|
||||||
|
/// function with no user context pointer, so its state must be reachable statically.
|
||||||
|
static SEND_SYSTEM_KEYS: AtomicBool = AtomicBool::new(true);
|
||||||
|
|
||||||
|
/// Set whether system key combinations are forwarded to the remote (vs. handled locally).
|
||||||
|
///
|
||||||
|
/// Part of the programmatic toggle API (alongside `toggle_send_system_keys`, which the
|
||||||
|
/// Pause/Break host key drives). Retained for a future viewer menu / tray item and used
|
||||||
|
/// by the unit tests; not yet called from non-test code, hence the allow.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn set_send_system_keys(enabled: bool) {
|
||||||
|
SEND_SYSTEM_KEYS.store(enabled, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flip the "send system keys to remote" toggle and return the new value.
|
||||||
|
pub fn toggle_send_system_keys() -> bool {
|
||||||
|
// fetch_xor(true) flips the bit and returns the PREVIOUS value; invert for the new one.
|
||||||
|
!SEND_SYSTEM_KEYS.fetch_xor(true, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state of the "send system keys to remote" toggle.
|
||||||
|
///
|
||||||
|
/// Part of the programmatic toggle API; used by the unit tests and available for a
|
||||||
|
/// viewer menu / status indicator. Not yet read from non-test code, hence the allow.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn send_system_keys_enabled() -> bool {
|
||||||
|
SEND_SYSTEM_KEYS.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the viewer window currently has input focus. Default `false`.
|
||||||
|
///
|
||||||
|
/// `WH_KEYBOARD_LL` is a GLOBAL hook fired for all desktop input, so it must NOT divert
|
||||||
|
/// system combos while the viewer is unfocused — otherwise the technician's own local
|
||||||
|
/// Win key / Alt+Tab / Ctrl+Esc would be suppressed and pushed to the remote. The render
|
||||||
|
/// loop updates this on `WindowEvent::Focused`. Lives at module scope for the same reason
|
||||||
|
/// as `SEND_SYSTEM_KEYS`: the bare `extern "system"` callback has no user-context pointer.
|
||||||
|
static VIEWER_FOCUSED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Record whether the viewer window has input focus (drives the hook's focus gate).
|
||||||
|
pub fn set_viewer_focused(focused: bool) {
|
||||||
|
VIEWER_FOCUSED.store(focused, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current focus state as seen by the keyboard hook.
|
||||||
|
///
|
||||||
|
/// Used by the unit tests and available for diagnostics; not yet read from non-test code
|
||||||
|
/// beyond the hook callback itself, hence the allow.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn viewer_focused() -> bool {
|
||||||
|
VIEWER_FOCUSED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
|
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut());
|
static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut());
|
||||||
|
|
||||||
/// Virtual key codes for special keys
|
/// Virtual key codes for keys the hook reasons about.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod vk {
|
mod vk {
|
||||||
pub const VK_LWIN: u32 = 0x5B;
|
pub const VK_LWIN: u32 = 0x5B;
|
||||||
pub const VK_RWIN: u32 = 0x5C;
|
pub const VK_RWIN: u32 = 0x5C;
|
||||||
pub const VK_APPS: u32 = 0x5D;
|
pub const VK_APPS: u32 = 0x5D;
|
||||||
pub const VK_LSHIFT: u32 = 0xA0;
|
|
||||||
pub const VK_RSHIFT: u32 = 0xA1;
|
|
||||||
pub const VK_LCONTROL: u32 = 0xA2;
|
|
||||||
pub const VK_RCONTROL: u32 = 0xA3;
|
|
||||||
pub const VK_LMENU: u32 = 0xA4; // Left Alt
|
|
||||||
pub const VK_RMENU: u32 = 0xA5; // Right Alt
|
|
||||||
pub const VK_TAB: u32 = 0x09;
|
pub const VK_TAB: u32 = 0x09;
|
||||||
pub const VK_ESCAPE: u32 = 0x1B;
|
pub const VK_ESCAPE: u32 = 0x1B;
|
||||||
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -52,10 +114,10 @@ pub struct KeyboardHook {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
impl KeyboardHook {
|
impl KeyboardHook {
|
||||||
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
||||||
// Store the sender globally for the hook callback
|
// Store the sender globally for the hook callback. If it was already set (e.g.
|
||||||
INPUT_TX
|
// a previous viewer instance in the same process), reuse the existing one rather
|
||||||
.set(input_tx)
|
// than failing — the hook handle itself is what we re-install.
|
||||||
.map_err(|_| anyhow::anyhow!("Input TX already set"))?;
|
let _ = INPUT_TX.set(input_tx);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?;
|
let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?;
|
||||||
@@ -78,21 +140,56 @@ impl Drop for KeyboardHook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decide whether a key event is a SYSTEM combination we must divert to the remote.
|
||||||
|
///
|
||||||
|
/// `vk_code` is the key; `alt`/`ctrl` are the modifier state at the moment of the event
|
||||||
|
/// (from `GetAsyncKeyState`). The Windows-key combos (Win, Win+R, Win+E) are recognized
|
||||||
|
/// by matching the Win keys themselves, so the held-Win state is not needed here. Pure
|
||||||
|
/// functions like this keep the (untestable) hook callback thin and unit-testable.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_system_combo(vk_code: u32, alt: bool, ctrl: bool) -> bool {
|
||||||
|
match vk_code {
|
||||||
|
// The Windows keys and the Applications (context-menu) key: always divert so the
|
||||||
|
// local Start menu / Win+R / Win+E / Win+E etc. do not fire. With Win forwarded
|
||||||
|
// down to the remote, subsequent letters (R, E, ...) compose there naturally.
|
||||||
|
vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS => true,
|
||||||
|
// Alt+Tab and Alt+Esc — the local window-switcher would otherwise eat these.
|
||||||
|
vk::VK_TAB if alt => true,
|
||||||
|
vk::VK_ESCAPE if alt => true,
|
||||||
|
// Ctrl+Esc opens the local Start menu; divert it.
|
||||||
|
vk::VK_ESCAPE if ctrl => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||||
if code >= 0 {
|
if code >= 0 {
|
||||||
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||||
let vk_code = kb_struct.vkCode;
|
let vk_code = kb_struct.vkCode;
|
||||||
let scan_code = kb_struct.scanCode;
|
let scan_code = kb_struct.scanCode;
|
||||||
|
// LLKHF_EXTENDED (bit 0) marks extended keys (right Ctrl/Alt, arrows, etc.).
|
||||||
|
let is_extended = (kb_struct.flags.0 & LLKHF_EXTENDED.0) != 0;
|
||||||
|
|
||||||
let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN;
|
let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN;
|
||||||
let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
|
let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
|
||||||
|
|
||||||
if is_down || is_up {
|
if is_down || is_up {
|
||||||
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
|
let forwarding = SEND_SYSTEM_KEYS.load(Ordering::Relaxed);
|
||||||
let should_intercept = matches!(vk_code, vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS);
|
let focused = VIEWER_FOCUSED.load(Ordering::Relaxed);
|
||||||
|
let modifiers = current_modifiers();
|
||||||
|
|
||||||
// Send the key event to the remote
|
// Divert ONLY a SYSTEM combo, ONLY while forwarding is enabled, and ONLY while
|
||||||
|
// the viewer window has focus. This is a global hook, so without the focus gate
|
||||||
|
// we would swallow the technician's own Win/Alt+Tab/Ctrl+Esc while the viewer
|
||||||
|
// sits unfocused in the background. When any condition is false we fall through
|
||||||
|
// to CallNextHookEx and suppress nothing — the local OS handles the key. Ordinary
|
||||||
|
// keys are left to the normal winit viewer input path (they are NOT forwarded
|
||||||
|
// here to avoid double-injection).
|
||||||
|
let divert =
|
||||||
|
forwarding && focused && is_system_combo(vk_code, modifiers.alt, modifiers.ctrl);
|
||||||
|
|
||||||
|
if divert {
|
||||||
if let Some(tx) = INPUT_TX.get() {
|
if let Some(tx) = INPUT_TX.get() {
|
||||||
let event = proto::KeyEvent {
|
let event = proto::KeyEvent {
|
||||||
down: is_down,
|
down: is_down,
|
||||||
@@ -100,20 +197,21 @@ unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam:
|
|||||||
vk_code,
|
vk_code,
|
||||||
scan_code,
|
scan_code,
|
||||||
unicode: String::new(),
|
unicode: String::new(),
|
||||||
modifiers: Some(get_current_modifiers()),
|
is_extended,
|
||||||
|
modifiers: Some(modifiers),
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = tx.try_send(InputEvent::Key(event));
|
let _ = tx.try_send(InputEvent::Key(event));
|
||||||
trace!(
|
trace!(
|
||||||
"Key hook: vk={:#x} scan={} down={}",
|
"System-key hook diverted: vk={:#x} scan={} ext={} down={}",
|
||||||
vk_code,
|
vk_code,
|
||||||
scan_code,
|
scan_code,
|
||||||
|
is_extended,
|
||||||
is_down
|
is_down
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Win key, consume the event so it doesn't open Start menu locally
|
// Suppress local handling of the diverted system combo.
|
||||||
if should_intercept {
|
|
||||||
return LRESULT(1);
|
return LRESULT(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +221,7 @@ unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam:
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn get_current_modifiers() -> proto::Modifiers {
|
fn current_modifiers() -> proto::Modifiers {
|
||||||
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -138,18 +236,6 @@ fn get_current_modifiers() -> proto::Modifiers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pump Windows message queue (required for hooks to work)
|
|
||||||
#[cfg(windows)]
|
|
||||||
pub fn pump_messages() {
|
|
||||||
unsafe {
|
|
||||||
let mut msg = MSG::default();
|
|
||||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
|
||||||
let _ = TranslateMessage(&msg);
|
|
||||||
DispatchMessageW(&msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-Windows stubs
|
// Non-Windows stubs
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -163,6 +249,73 @@ impl KeyboardHook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(test)]
|
||||||
#[allow(dead_code)]
|
mod tests {
|
||||||
pub fn pump_messages() {}
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_defaults_on_and_flips() {
|
||||||
|
// Default is ON.
|
||||||
|
set_send_system_keys(true);
|
||||||
|
assert!(send_system_keys_enabled());
|
||||||
|
|
||||||
|
// Toggling flips and returns the NEW value.
|
||||||
|
assert!(!toggle_send_system_keys());
|
||||||
|
assert!(!send_system_keys_enabled());
|
||||||
|
assert!(toggle_send_system_keys());
|
||||||
|
assert!(send_system_keys_enabled());
|
||||||
|
|
||||||
|
// Explicit set wins.
|
||||||
|
set_send_system_keys(false);
|
||||||
|
assert!(!send_system_keys_enabled());
|
||||||
|
set_send_system_keys(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewer_focus_flag_defaults_off_and_tracks() {
|
||||||
|
// The hook starts gated CLOSED (unfocused) so a background viewer never swallows
|
||||||
|
// the technician's local system keys until it actually gains focus.
|
||||||
|
set_viewer_focused(false);
|
||||||
|
assert!(!viewer_focused());
|
||||||
|
|
||||||
|
set_viewer_focused(true);
|
||||||
|
assert!(viewer_focused());
|
||||||
|
|
||||||
|
set_viewer_focused(false);
|
||||||
|
assert!(!viewer_focused());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn win_keys_always_divert() {
|
||||||
|
// Win / Apps keys divert regardless of modifier state.
|
||||||
|
assert!(is_system_combo(vk::VK_LWIN, false, false));
|
||||||
|
assert!(is_system_combo(vk::VK_RWIN, false, false));
|
||||||
|
assert!(is_system_combo(vk::VK_APPS, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn alt_tab_and_alt_esc_divert_only_with_alt() {
|
||||||
|
assert!(is_system_combo(vk::VK_TAB, true, false)); // Alt+Tab
|
||||||
|
assert!(!is_system_combo(vk::VK_TAB, false, false)); // plain Tab -> local path
|
||||||
|
assert!(is_system_combo(vk::VK_ESCAPE, true, false)); // Alt+Esc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn ctrl_esc_diverts_only_with_ctrl() {
|
||||||
|
assert!(is_system_combo(vk::VK_ESCAPE, false, true)); // Ctrl+Esc
|
||||||
|
assert!(!is_system_combo(vk::VK_ESCAPE, false, false)); // plain Esc -> local path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn ordinary_keys_never_divert() {
|
||||||
|
// 'R' is NOT itself a "system combo" — Win was already diverted (and forwarded
|
||||||
|
// down), so R flows through the normal viewer path and composes Win+R on the remote.
|
||||||
|
assert!(!is_system_combo(0x52, false, false)); // 'R'
|
||||||
|
assert!(!is_system_combo(0x41, false, false)); // 'A'
|
||||||
|
assert!(!is_system_combo(vk::VK_TAB, false, true)); // Ctrl+Tab is app-level, not a shell combo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
//! This module provides the viewer functionality for connecting to remote
|
//! This module provides the viewer functionality for connecting to remote
|
||||||
//! GuruConnect sessions with low-level keyboard hooks for Win key capture.
|
//! GuruConnect sessions with low-level keyboard hooks for Win key capture.
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod decoder;
|
||||||
mod input;
|
mod input;
|
||||||
mod render;
|
mod render;
|
||||||
mod transport;
|
mod transport;
|
||||||
@@ -26,9 +28,84 @@ pub enum ViewerEvent {
|
|||||||
pub enum InputEvent {
|
pub enum InputEvent {
|
||||||
Mouse(proto::MouseEvent),
|
Mouse(proto::MouseEvent),
|
||||||
Key(proto::KeyEvent),
|
Key(proto::KeyEvent),
|
||||||
|
// Not yet emitted by the viewer input path (special-key fidelity is pending).
|
||||||
|
#[allow(dead_code)]
|
||||||
SpecialKey(proto::SpecialKeyEvent),
|
SpecialKey(proto::SpecialKeyEvent),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn the dedicated H.264 decode worker thread (Task 7, Windows only).
|
||||||
|
///
|
||||||
|
/// Returns a sender for `(h264_access_unit, pts_100ns)`. The worker lazily
|
||||||
|
/// creates the Media Foundation decoder on the first frame; if creation fails it
|
||||||
|
/// logs once and then silently drops subsequent frames (the raw render path is
|
||||||
|
/// never affected). Each decoded frame is converted to BGRA and delivered to the
|
||||||
|
/// viewer as an uncompressed `FrameData`, reusing the existing render path.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn spawn_h264_decode_worker(
|
||||||
|
viewer_tx: mpsc::Sender<ViewerEvent>,
|
||||||
|
) -> std::sync::mpsc::Sender<(Vec<u8>, i64)> {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel::<(Vec<u8>, i64)>();
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gc-h264-decode".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
let mut decoder: Option<decoder::H264Decoder> = None;
|
||||||
|
let mut init_failed = false;
|
||||||
|
|
||||||
|
while let Ok((data, pts)) = rx.recv() {
|
||||||
|
if init_failed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if decoder.is_none() {
|
||||||
|
match decoder::H264Decoder::new() {
|
||||||
|
Ok(d) => {
|
||||||
|
info!("H.264 decoder initialized (Media Foundation)");
|
||||||
|
decoder = Some(d);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"H.264 decoder init failed: {e:#}; H.264 frames will be dropped"
|
||||||
|
);
|
||||||
|
init_failed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dec = decoder.as_mut().expect("decoder present after init");
|
||||||
|
match dec.decode(&data, pts) {
|
||||||
|
// One input access unit may yield zero, one, or more frames.
|
||||||
|
Ok(frames) => {
|
||||||
|
let mut viewer_closed = false;
|
||||||
|
for decoded in frames {
|
||||||
|
let frame = render::FrameData {
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
|
data: decoded.bgra,
|
||||||
|
compressed: false, // already BGRA
|
||||||
|
is_keyframe: false,
|
||||||
|
};
|
||||||
|
if viewer_tx.blocking_send(ViewerEvent::Frame(frame)).is_err() {
|
||||||
|
// Viewer closed; stop the worker.
|
||||||
|
viewer_closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewer_closed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("H.264 decode error: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to spawn H.264 decode worker thread");
|
||||||
|
|
||||||
|
tx
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the viewer to connect to a remote session
|
/// Run the viewer to connect to a remote session
|
||||||
pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()> {
|
pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()> {
|
||||||
info!("GuruConnect Viewer starting");
|
info!("GuruConnect Viewer starting");
|
||||||
@@ -75,13 +152,23 @@ pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// H.264 decode worker (Task 7, Windows only). The Media Foundation decoder
|
||||||
|
// wraps COM interfaces with thread affinity, so it runs on a DEDICATED OS
|
||||||
|
// thread (not a tokio task, which can migrate across workers at await
|
||||||
|
// points). The receive task forwards H.264 access units to it over a std
|
||||||
|
// channel; the worker decodes to BGRA and pushes a FrameData back through
|
||||||
|
// the viewer channel via `blocking_send`. On decoder-init failure the worker
|
||||||
|
// logs and drops H.264 frames (the raw path is unaffected).
|
||||||
|
#[cfg(windows)]
|
||||||
|
let h264_tx = spawn_h264_decode_worker(viewer_tx.clone());
|
||||||
|
|
||||||
// Spawn task to receive messages from server
|
// Spawn task to receive messages from server
|
||||||
let viewer_tx_recv = viewer_tx.clone();
|
let viewer_tx_recv = viewer_tx.clone();
|
||||||
let receive_task = tokio::spawn(async move {
|
let receive_task = tokio::spawn(async move {
|
||||||
while let Some(msg) = ws_receiver.recv().await {
|
while let Some(msg) = ws_receiver.recv().await {
|
||||||
match msg.payload {
|
match msg.payload {
|
||||||
Some(proto::message::Payload::VideoFrame(frame)) => {
|
Some(proto::message::Payload::VideoFrame(frame)) => match frame.encoding {
|
||||||
if let Some(proto::video_frame::Encoding::Raw(raw)) = frame.encoding {
|
Some(proto::video_frame::Encoding::Raw(raw)) => {
|
||||||
let frame_data = render::FrameData {
|
let frame_data = render::FrameData {
|
||||||
width: raw.width as u32,
|
width: raw.width as u32,
|
||||||
height: raw.height as u32,
|
height: raw.height as u32,
|
||||||
@@ -91,7 +178,23 @@ pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()
|
|||||||
};
|
};
|
||||||
let _ = viewer_tx_recv.send(ViewerEvent::Frame(frame_data)).await;
|
let _ = viewer_tx_recv.send(ViewerEvent::Frame(frame_data)).await;
|
||||||
}
|
}
|
||||||
|
Some(proto::video_frame::Encoding::H264(enc)) => {
|
||||||
|
// Forward to the decode worker (Windows). On other
|
||||||
|
// platforms H.264 is never negotiated, so this is dead.
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if h264_tx.send((enc.data, enc.pts)).is_err() {
|
||||||
|
warn!("H.264 decode worker unavailable; dropping frame");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// VP9/H265 not implemented on the viewer (raw + H.264 only).
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
Some(proto::message::Payload::CursorPosition(pos)) => {
|
Some(proto::message::Payload::CursorPosition(pos)) => {
|
||||||
let _ = viewer_tx_recv
|
let _ = viewer_tx_recv
|
||||||
.send(ViewerEvent::CursorPosition(pos.x, pos.y, pos.visible))
|
.send(ViewerEvent::CursorPosition(pos.x, pos.y, pos.visible))
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Window rendering and frame display
|
//! Window rendering and frame display
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use super::input;
|
use super::input;
|
||||||
use super::{InputEvent, ViewerEvent};
|
use super::{InputEvent, ViewerEvent};
|
||||||
use crate::proto;
|
use crate::proto;
|
||||||
@@ -25,9 +24,55 @@ pub struct FrameData {
|
|||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
pub compressed: bool,
|
pub compressed: bool,
|
||||||
|
// Carried through from the wire frame; the renderer does not branch on it yet.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub is_keyframe: bool,
|
pub is_keyframe: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Viewer-local tracker of which modifier keys are currently held down on the remote.
|
||||||
|
///
|
||||||
|
/// Mirrors what the viewer has forwarded so that on focus loss it can emit explicit
|
||||||
|
/// key-ups for anything still pressed, preventing a stuck Ctrl/Alt/Shift/Win.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ViewerModifierState {
|
||||||
|
ctrl: bool,
|
||||||
|
alt: bool,
|
||||||
|
shift: bool,
|
||||||
|
meta: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewerModifierState {
|
||||||
|
/// Record a modifier transition for `vk_code`.
|
||||||
|
fn update(&mut self, vk_code: u32, down: bool) {
|
||||||
|
match vk_code {
|
||||||
|
0x11 | 0xA2 | 0xA3 => self.ctrl = down, // Ctrl / LCtrl / RCtrl
|
||||||
|
0x12 | 0xA4 | 0xA5 => self.alt = down, // Alt / LAlt / RAlt
|
||||||
|
0x10 | 0xA0 | 0xA1 => self.shift = down, // Shift / LShift / RShift
|
||||||
|
0x5B | 0x5C => self.meta = down, // LWin / RWin
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the canonical VK of every held modifier, then clear all state.
|
||||||
|
fn drain_held(&mut self) -> Vec<u16> {
|
||||||
|
let mut held = Vec::new();
|
||||||
|
if self.ctrl {
|
||||||
|
held.push(0x11u16);
|
||||||
|
}
|
||||||
|
if self.alt {
|
||||||
|
held.push(0x12);
|
||||||
|
}
|
||||||
|
if self.shift {
|
||||||
|
held.push(0x10);
|
||||||
|
}
|
||||||
|
if self.meta {
|
||||||
|
held.push(0x5B);
|
||||||
|
}
|
||||||
|
*self = ViewerModifierState::default();
|
||||||
|
held
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ViewerApp {
|
struct ViewerApp {
|
||||||
window: Option<Arc<Window>>,
|
window: Option<Arc<Window>>,
|
||||||
surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>,
|
surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>,
|
||||||
@@ -38,6 +83,7 @@ struct ViewerApp {
|
|||||||
input_tx: mpsc::Sender<InputEvent>,
|
input_tx: mpsc::Sender<InputEvent>,
|
||||||
mouse_x: i32,
|
mouse_x: i32,
|
||||||
mouse_y: i32,
|
mouse_y: i32,
|
||||||
|
modifiers: ViewerModifierState,
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
keyboard_hook: Option<input::KeyboardHook>,
|
keyboard_hook: Option<input::KeyboardHook>,
|
||||||
}
|
}
|
||||||
@@ -54,6 +100,7 @@ impl ViewerApp {
|
|||||||
input_tx,
|
input_tx,
|
||||||
mouse_x: 0,
|
mouse_x: 0,
|
||||||
mouse_y: 0,
|
mouse_y: 0,
|
||||||
|
modifiers: ViewerModifierState::default(),
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
keyboard_hook: None,
|
keyboard_hook: None,
|
||||||
}
|
}
|
||||||
@@ -214,24 +261,56 @@ impl ViewerApp {
|
|||||||
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
|
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_key_event(&self, key: PhysicalKey, state: ElementState) {
|
fn send_key_event(&mut self, key: PhysicalKey, state: ElementState) {
|
||||||
let vk_code = match key {
|
let vk_code = match key {
|
||||||
PhysicalKey::Code(code) => keycode_to_vk(code),
|
PhysicalKey::Code(code) => keycode_to_vk(code),
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
if vk_code == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let down = state == ElementState::Pressed;
|
||||||
|
|
||||||
|
// Track modifier state locally so focus loss can release anything still held.
|
||||||
|
self.modifiers.update(vk_code, down);
|
||||||
|
|
||||||
|
// The winit path has no hardware scan code; the agent derives one from the VK.
|
||||||
|
// The extended-key flag is derived from the VK so extended keys (arrows, etc.)
|
||||||
|
// still inject correctly without a captured LLKHF_EXTENDED bit.
|
||||||
let event = proto::KeyEvent {
|
let event = proto::KeyEvent {
|
||||||
down: state == ElementState::Pressed,
|
down,
|
||||||
key_type: proto::KeyEventType::KeyVk as i32,
|
key_type: proto::KeyEventType::KeyVk as i32,
|
||||||
vk_code,
|
vk_code,
|
||||||
scan_code: 0,
|
scan_code: 0,
|
||||||
unicode: String::new(),
|
unicode: String::new(),
|
||||||
|
is_extended: crate::input::vk_is_extended(vk_code as u16),
|
||||||
modifiers: Some(proto::Modifiers::default()),
|
modifiers: Some(proto::Modifiers::default()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = self.input_tx.try_send(InputEvent::Key(event));
|
let _ = self.input_tx.try_send(InputEvent::Key(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Release every modifier this viewer currently believes is held on the remote.
|
||||||
|
///
|
||||||
|
/// Invoked on focus loss and at window close so that a Ctrl/Alt/Shift/Win whose
|
||||||
|
/// key-up the viewer never saw (because focus left mid-press) is explicitly released
|
||||||
|
/// on the remote, preventing a "stuck modifier".
|
||||||
|
fn release_held_modifiers(&mut self) {
|
||||||
|
for vk in self.modifiers.drain_held() {
|
||||||
|
let event = proto::KeyEvent {
|
||||||
|
down: false,
|
||||||
|
key_type: proto::KeyEventType::KeyVk as i32,
|
||||||
|
vk_code: vk as u32,
|
||||||
|
scan_code: 0,
|
||||||
|
unicode: String::new(),
|
||||||
|
is_extended: crate::input::vk_is_extended(vk),
|
||||||
|
modifiers: Some(proto::Modifiers::default()),
|
||||||
|
};
|
||||||
|
let _ = self.input_tx.try_send(InputEvent::Key(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn screen_to_frame_coords(&self, x: f64, y: f64) -> (i32, i32) {
|
fn screen_to_frame_coords(&self, x: f64, y: f64) -> (i32, i32) {
|
||||||
let Some(window) = &self.window else {
|
let Some(window) = &self.window else {
|
||||||
return (x as i32, y as i32);
|
return (x as i32, y as i32);
|
||||||
@@ -316,6 +395,8 @@ impl ApplicationHandler for ViewerApp {
|
|||||||
match event {
|
match event {
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
info!("Window close requested");
|
info!("Window close requested");
|
||||||
|
// Release any modifiers still held so the remote isn't left latched.
|
||||||
|
self.release_held_modifiers();
|
||||||
event_loop.exit();
|
event_loop.exit();
|
||||||
}
|
}
|
||||||
WindowEvent::RedrawRequested => {
|
WindowEvent::RedrawRequested => {
|
||||||
@@ -343,13 +424,39 @@ impl ApplicationHandler for ViewerApp {
|
|||||||
};
|
};
|
||||||
self.send_mouse_wheel(dx, dy);
|
self.send_mouse_wheel(dx, dy);
|
||||||
}
|
}
|
||||||
WindowEvent::KeyboardInput { event, .. } => {
|
// Focus changes drive the low-level hook's focus gate. The hook is GLOBAL
|
||||||
// Note: This handles keys that aren't captured by the low-level hook
|
// (fires for all desktop input), so it must only divert system keys while the
|
||||||
// The hook handles Win key and other special keys
|
// viewer is focused; we flip `set_viewer_focused` here. On blur we also release
|
||||||
if !event.repeat {
|
// any held modifiers so they don't stay latched on the remote — winit's hook
|
||||||
self.send_key_event(event.physical_key, event.state);
|
// pump only runs while we have focus, so this is the safety net for a modifier
|
||||||
|
// pressed-but-not-released across the blur.
|
||||||
|
WindowEvent::Focused(focused) => {
|
||||||
|
input::set_viewer_focused(focused);
|
||||||
|
if focused {
|
||||||
|
debug!("Viewer gained focus; system-key forwarding active");
|
||||||
|
} else {
|
||||||
|
debug!("Viewer lost focus; releasing held modifiers on remote");
|
||||||
|
self.release_held_modifiers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Note: This handles keys that aren't captured by the low-level hook.
|
||||||
|
// The hook handles the Windows key and other diverted system combinations.
|
||||||
|
WindowEvent::KeyboardInput { event, .. } if !event.repeat => {
|
||||||
|
// Host key: Pause/Break toggles "send system keys to remote". It is
|
||||||
|
// intercepted locally (not forwarded) so the technician can flip the
|
||||||
|
// behavior without affecting the remote. Only act on key-down.
|
||||||
|
if matches!(event.physical_key, PhysicalKey::Code(KeyCode::Pause))
|
||||||
|
&& event.state == ElementState::Pressed
|
||||||
|
{
|
||||||
|
let enabled = input::toggle_send_system_keys();
|
||||||
|
info!(
|
||||||
|
"Send-system-keys toggled {} (Pause/Break host key)",
|
||||||
|
if enabled { "ON" } else { "OFF" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.send_key_event(event.physical_key, event.state);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,9 +465,11 @@ impl ApplicationHandler for ViewerApp {
|
|||||||
// Keep checking for events
|
// Keep checking for events
|
||||||
event_loop.set_control_flow(ControlFlow::Poll);
|
event_loop.set_control_flow(ControlFlow::Poll);
|
||||||
|
|
||||||
// Process Windows messages for keyboard hook
|
// NOTE: do NOT manually pump the Win32 message queue here. winit's own
|
||||||
#[cfg(windows)]
|
// run_app loop already pumps this thread's messages (which also services
|
||||||
input::pump_messages();
|
// the low-level keyboard hook). A manual PeekMessage/DispatchMessage pump
|
||||||
|
// inside about_to_wait steals winit's messages and re-enters its window
|
||||||
|
// proc, freezing the event loop after one iteration (blank viewer).
|
||||||
|
|
||||||
// Request redraw periodically to check for new frames
|
// Request redraw periodically to check for new frames
|
||||||
if let Some(window) = &self.window {
|
if let Some(window) = &self.window {
|
||||||
|
|||||||
@@ -1,14 +1,58 @@
|
|||||||
## [0.2.0] - 2026-05-29
|
## [0.3.0] - 2026-06-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
|
||||||
|
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
|
||||||
|
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
|
||||||
|
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
|
||||||
|
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
|
||||||
|
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
|
||||||
|
- GuruConnect v2 Users admin view (96b4fd77)
|
||||||
|
- GuruConnect v2 Support Codes view (664f33d5)
|
||||||
|
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
|
||||||
|
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
|
||||||
|
- GuruConnect v2 operator console (pass 1) (43a9432b)
|
||||||
|
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
|
||||||
|
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
|
||||||
|
- V2 secure-session-core Task 5 - attended consent (9082e114)
|
||||||
|
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
|
||||||
|
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
|
||||||
|
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
|
||||||
|
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
|
||||||
|
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
|
||||||
|
|
||||||
### Fix
|
### Fixed
|
||||||
|
|
||||||
- Use Self:: for static method calls (cc35d111)
|
- Make native H.264 viewer render live frames (97780304)
|
||||||
|
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
|
||||||
|
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
|
||||||
|
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
|
||||||
|
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
|
||||||
|
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
|
||||||
|
- Clippy fixes for Task 4 (CI green) (21189423)
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Require authentication for all WebSocket and API endpoints (4614df04)
|
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
|
||||||
|
|
||||||
|
### Spec
|
||||||
|
|
||||||
|
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
|
||||||
|
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
|
||||||
|
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
|
||||||
|
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
|
||||||
|
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
|
||||||
|
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
|
||||||
|
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
|
||||||
|
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
|
||||||
|
- Add SPEC-009 feature-rich documented API (7ab87384)
|
||||||
|
- Add SPEC-008 valuable error messages (65eff5cf)
|
||||||
|
- Add SPEC-007 managed-agent installer builder (008d2bf3)
|
||||||
|
- Add SPEC-006 universal machine search (0eb38520)
|
||||||
|
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
|
||||||
|
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
|
||||||
|
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
|
||||||
|
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
|
||||||
|
- Add v2-secure-session-core shape spec (81e4b99a)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,58 @@
|
|||||||
## [0.2.0] - 2026-05-29
|
## [0.3.0] - 2026-06-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
|
||||||
|
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
|
||||||
|
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
|
||||||
|
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
|
||||||
|
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
|
||||||
|
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
|
||||||
|
- GuruConnect v2 Users admin view (96b4fd77)
|
||||||
|
- GuruConnect v2 Support Codes view (664f33d5)
|
||||||
|
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
|
||||||
|
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
|
||||||
|
- GuruConnect v2 operator console (pass 1) (43a9432b)
|
||||||
|
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
|
||||||
|
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
|
||||||
|
- V2 secure-session-core Task 5 - attended consent (9082e114)
|
||||||
|
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
|
||||||
|
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
|
||||||
|
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
|
||||||
|
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
|
||||||
|
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
|
||||||
|
|
||||||
### Fix
|
### Fixed
|
||||||
|
|
||||||
- Use Self:: for static method calls (cc35d111)
|
- Make native H.264 viewer render live frames (97780304)
|
||||||
|
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
|
||||||
|
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
|
||||||
|
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
|
||||||
|
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
|
||||||
|
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
|
||||||
|
- Clippy fixes for Task 4 (CI green) (21189423)
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Require authentication for all WebSocket and API endpoints (4614df04)
|
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
|
||||||
|
|
||||||
|
### Spec
|
||||||
|
|
||||||
|
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
|
||||||
|
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
|
||||||
|
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
|
||||||
|
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
|
||||||
|
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
|
||||||
|
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
|
||||||
|
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
|
||||||
|
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
|
||||||
|
- Add SPEC-009 feature-rich documented API (7ab87384)
|
||||||
|
- Add SPEC-008 valuable error messages (65eff5cf)
|
||||||
|
- Add SPEC-007 managed-agent installer builder (008d2bf3)
|
||||||
|
- Add SPEC-006 universal machine search (0eb38520)
|
||||||
|
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
|
||||||
|
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
|
||||||
|
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
|
||||||
|
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
|
||||||
|
- Add v2-secure-session-core shape spec (81e4b99a)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,58 @@
|
|||||||
## [0.2.0] - 2026-05-29
|
## [0.3.0] - 2026-06-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
|
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
|
||||||
|
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
|
||||||
|
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
|
||||||
|
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
|
||||||
|
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
|
||||||
|
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
|
||||||
|
- GuruConnect v2 Users admin view (96b4fd77)
|
||||||
|
- GuruConnect v2 Support Codes view (664f33d5)
|
||||||
|
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
|
||||||
|
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
|
||||||
|
- GuruConnect v2 operator console (pass 1) (43a9432b)
|
||||||
|
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
|
||||||
|
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
|
||||||
|
- V2 secure-session-core Task 5 - attended consent (9082e114)
|
||||||
|
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
|
||||||
|
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
|
||||||
|
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
|
||||||
|
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
|
||||||
|
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
|
||||||
|
|
||||||
### Fix
|
### Fixed
|
||||||
|
|
||||||
- Use Self:: for static method calls (cc35d111)
|
- Make native H.264 viewer render live frames (97780304)
|
||||||
|
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
|
||||||
|
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
|
||||||
|
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
|
||||||
|
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
|
||||||
|
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
|
||||||
|
- Clippy fixes for Task 4 (CI green) (21189423)
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Require authentication for all WebSocket and API endpoints (4614df04)
|
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
|
||||||
|
|
||||||
|
### Spec
|
||||||
|
|
||||||
|
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
|
||||||
|
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
|
||||||
|
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
|
||||||
|
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
|
||||||
|
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
|
||||||
|
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
|
||||||
|
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
|
||||||
|
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
|
||||||
|
- Add SPEC-009 feature-rich documented API (7ab87384)
|
||||||
|
- Add SPEC-008 valuable error messages (65eff5cf)
|
||||||
|
- Add SPEC-007 managed-agent installer builder (008d2bf3)
|
||||||
|
- Add SPEC-006 universal machine search (0eb38520)
|
||||||
|
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
|
||||||
|
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
|
||||||
|
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
|
||||||
|
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
|
||||||
|
- Add v2-secure-session-core shape spec (81e4b99a)
|
||||||
|
|
||||||
|
|||||||
58
changelogs/agent/v0.3.0.md
Normal file
58
changelogs/agent/v0.3.0.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
## [0.3.0] - 2026-06-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
|
||||||
|
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
|
||||||
|
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
|
||||||
|
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
|
||||||
|
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
|
||||||
|
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
|
||||||
|
- GuruConnect v2 Users admin view (96b4fd77)
|
||||||
|
- GuruConnect v2 Support Codes view (664f33d5)
|
||||||
|
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
|
||||||
|
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
|
||||||
|
- GuruConnect v2 operator console (pass 1) (43a9432b)
|
||||||
|
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
|
||||||
|
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
|
||||||
|
- V2 secure-session-core Task 5 - attended consent (9082e114)
|
||||||
|
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
|
||||||
|
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
|
||||||
|
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
|
||||||
|
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
|
||||||
|
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Make native H.264 viewer render live frames (97780304)
|
||||||
|
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
|
||||||
|
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
|
||||||
|
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
|
||||||
|
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
|
||||||
|
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
|
||||||
|
- Clippy fixes for Task 4 (CI green) (21189423)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
|
||||||
|
|
||||||
|
### Spec
|
||||||
|
|
||||||
|
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
|
||||||
|
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
|
||||||
|
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
|
||||||
|
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
|
||||||
|
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
|
||||||
|
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
|
||||||
|
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
|
||||||
|
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
|
||||||
|
- Add SPEC-009 feature-rich documented API (7ab87384)
|
||||||
|
- Add SPEC-008 valuable error messages (65eff5cf)
|
||||||
|
- Add SPEC-007 managed-agent installer builder (008d2bf3)
|
||||||
|
- Add SPEC-006 universal machine search (0eb38520)
|
||||||
|
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
|
||||||
|
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
|
||||||
|
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
|
||||||
|
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
|
||||||
|
- Add v2-secure-session-core shape spec (81e4b99a)
|
||||||
|
|
||||||
58
changelogs/dashboard/v0.3.0.md
Normal file
58
changelogs/dashboard/v0.3.0.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
## [0.3.0] - 2026-06-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
|
||||||
|
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
|
||||||
|
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
|
||||||
|
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
|
||||||
|
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
|
||||||
|
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
|
||||||
|
- GuruConnect v2 Users admin view (96b4fd77)
|
||||||
|
- GuruConnect v2 Support Codes view (664f33d5)
|
||||||
|
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
|
||||||
|
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
|
||||||
|
- GuruConnect v2 operator console (pass 1) (43a9432b)
|
||||||
|
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
|
||||||
|
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
|
||||||
|
- V2 secure-session-core Task 5 - attended consent (9082e114)
|
||||||
|
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
|
||||||
|
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
|
||||||
|
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
|
||||||
|
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
|
||||||
|
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Make native H.264 viewer render live frames (97780304)
|
||||||
|
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
|
||||||
|
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
|
||||||
|
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
|
||||||
|
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
|
||||||
|
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
|
||||||
|
- Clippy fixes for Task 4 (CI green) (21189423)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
|
||||||
|
|
||||||
|
### Spec
|
||||||
|
|
||||||
|
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
|
||||||
|
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
|
||||||
|
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
|
||||||
|
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
|
||||||
|
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
|
||||||
|
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
|
||||||
|
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
|
||||||
|
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
|
||||||
|
- Add SPEC-009 feature-rich documented API (7ab87384)
|
||||||
|
- Add SPEC-008 valuable error messages (65eff5cf)
|
||||||
|
- Add SPEC-007 managed-agent installer builder (008d2bf3)
|
||||||
|
- Add SPEC-006 universal machine search (0eb38520)
|
||||||
|
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
|
||||||
|
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
|
||||||
|
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
|
||||||
|
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
|
||||||
|
- Add v2-secure-session-core shape spec (81e4b99a)
|
||||||
|
|
||||||
58
changelogs/server/v0.3.0.md
Normal file
58
changelogs/server/v0.3.0.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
## [0.3.0] - 2026-06-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
|
||||||
|
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
|
||||||
|
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
|
||||||
|
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
|
||||||
|
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
|
||||||
|
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
|
||||||
|
- GuruConnect v2 Users admin view (96b4fd77)
|
||||||
|
- GuruConnect v2 Support Codes view (664f33d5)
|
||||||
|
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
|
||||||
|
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
|
||||||
|
- GuruConnect v2 operator console (pass 1) (43a9432b)
|
||||||
|
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
|
||||||
|
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
|
||||||
|
- V2 secure-session-core Task 5 - attended consent (9082e114)
|
||||||
|
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
|
||||||
|
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
|
||||||
|
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
|
||||||
|
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
|
||||||
|
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Make native H.264 viewer render live frames (97780304)
|
||||||
|
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
|
||||||
|
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
|
||||||
|
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
|
||||||
|
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
|
||||||
|
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
|
||||||
|
- Clippy fixes for Task 4 (CI green) (21189423)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
|
||||||
|
|
||||||
|
### Spec
|
||||||
|
|
||||||
|
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
|
||||||
|
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
|
||||||
|
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
|
||||||
|
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
|
||||||
|
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
|
||||||
|
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
|
||||||
|
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
|
||||||
|
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
|
||||||
|
- Add SPEC-009 feature-rich documented API (7ab87384)
|
||||||
|
- Add SPEC-008 valuable error messages (65eff5cf)
|
||||||
|
- Add SPEC-007 managed-agent installer builder (008d2bf3)
|
||||||
|
- Add SPEC-006 universal machine search (0eb38520)
|
||||||
|
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
|
||||||
|
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
|
||||||
|
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
|
||||||
|
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
|
||||||
|
- Add v2-secure-session-core shape spec (81e4b99a)
|
||||||
|
|
||||||
12
dashboard/.env.example
Normal file
12
dashboard/.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# GuruConnect dashboard — environment.
|
||||||
|
# Copy to `.env.local` for local overrides (gitignored via `*.local`).
|
||||||
|
|
||||||
|
# Base URL for the GuruConnect API. Leave UNSET to use same-origin (the
|
||||||
|
# production default — the dashboard is served by the GC server itself).
|
||||||
|
#
|
||||||
|
# In `npm run dev`, leave this unset too: Vite proxies `/api` and `/ws` to the
|
||||||
|
# local GC server (see vite.config.ts), so same-origin requests just work.
|
||||||
|
#
|
||||||
|
# Set it only to point the dashboard at a *different* host (e.g. a remote
|
||||||
|
# server while developing the UI locally):
|
||||||
|
# VITE_API_URL=https://connect.azcomputerguru.com
|
||||||
5
dashboard/.gitignore
vendored
Normal file
5
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
.vite
|
||||||
|
node_modules/.tmp
|
||||||
138
dashboard/README.md
Normal file
138
dashboard/README.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# GuruConnect Operator Dashboard (v2)
|
||||||
|
|
||||||
|
React + Vite + TypeScript SPA — the operator console for GuruConnect v2. A dark
|
||||||
|
"operations terminal" UI for managing the remote-support fleet.
|
||||||
|
|
||||||
|
> **Pass 1 scope.** This pass ships the scaffold, design system, app shell,
|
||||||
|
> auth, the typed API client, and the **Machines** view. Sessions, Codes, and
|
||||||
|
> Users are nav stubs only (disabled in the sidebar) and arrive in later passes.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **React 18** + **React Router 6** (client-side routing)
|
||||||
|
- **Vite 5** (dev server + build)
|
||||||
|
- **TypeScript** (strict)
|
||||||
|
- **@tanstack/react-query** (server-state, polling, cache invalidation)
|
||||||
|
- **@fontsource** — Hanken Grotesk (UI) + JetBrains Mono (technical data)
|
||||||
|
|
||||||
|
No component/icon libraries — primitives and icons are hand-built to keep the
|
||||||
|
console aesthetic and the bundle lean.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite dev server (proxies /api + /ws to the local GC server)
|
||||||
|
npm run build # tsc -b && vite build -> dist/
|
||||||
|
npm run preview # serve the production build locally
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run lint # eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
api/ Typed API client + response interfaces (source of truth: server/src/api/*.rs)
|
||||||
|
client.ts fetch wrapper: base URL, bearer token, dual error-envelope normalization
|
||||||
|
types.ts TS mirrors of the Rust response structs
|
||||||
|
auth.ts login / me / logout
|
||||||
|
machines.ts list / get / history / delete + admin key endpoints
|
||||||
|
stubs.ts sessions / codes / users — scaffolds for later passes
|
||||||
|
auth/ AuthProvider (token in memory + sessionStorage), context, ProtectedRoute
|
||||||
|
components/
|
||||||
|
ui/ Reusable primitives: Button, Badge/StatusDot, Table, Panel,
|
||||||
|
Modal, ConfirmDialog, Input/Field, Spinner, States, Toast
|
||||||
|
layout/ AppShell, Sidebar, Topbar, PageHeader, inline SVG icons
|
||||||
|
features/
|
||||||
|
auth/ LoginPage
|
||||||
|
machines/ MachinesPage + detail / delete / admin-keys modals + hooks
|
||||||
|
lib/ time formatting, clipboard, relay-status probe
|
||||||
|
styles/ tokens.css (design tokens)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design system — "operations terminal"
|
||||||
|
|
||||||
|
Dark control-room console. Tokens live in `src/styles/tokens.css`; primitive
|
||||||
|
styles in `src/components/ui/*.css`.
|
||||||
|
|
||||||
|
- **Surfaces:** `--bg #0b0f14`, `--panel #141b22`, `--panel-2 #0e1419`
|
||||||
|
- **Accent (signal cyan):** `--accent #22d3bf` — primary actions + live state
|
||||||
|
- **Status language (dot + label, used everywhere):** ok/online `--ok`,
|
||||||
|
pending `--warn` (soft pulse), denied/offline/error `--bad`, neutral
|
||||||
|
`--neutral`. Mapping centralised in `components/ui/status.ts`.
|
||||||
|
- **Type:** Hanken Grotesk for UI; **JetBrains Mono for all technical data**
|
||||||
|
(agent IDs, support codes, IPs, versions, timestamps, key fingerprints).
|
||||||
|
- **Motion (restrained):** staggered row fade-in, the consent pulse, the live
|
||||||
|
relay pip, hover transitions. All disabled under `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
`POST /api/auth/login` → `{ token, user }`. The token is held in an in-memory
|
||||||
|
ref and mirrored to **sessionStorage** (never localStorage), so it clears when
|
||||||
|
the tab closes. `GET /api/auth/me` restores the session on reload;
|
||||||
|
`POST /api/auth/logout` revokes it server-side. The client attaches
|
||||||
|
`Authorization: Bearer <token>` to every request and bounces to `/login` on any
|
||||||
|
401. Admin-only UI (per-agent key management) is gated on `role === "admin"`.
|
||||||
|
|
||||||
|
The API uses **two** error envelopes — `{ error }` and
|
||||||
|
`{ detail, error_code, status_code }`. `api/client.ts` extracts a message from
|
||||||
|
whichever is present (and falls back to plain-text bodies that some routes
|
||||||
|
return), so callers see one normalized `ApiError`.
|
||||||
|
|
||||||
|
## Dev proxy
|
||||||
|
|
||||||
|
`vite.config.ts` proxies `/api` and `/ws` to the local GC server
|
||||||
|
(`http://localhost:3002`). Run the Rust server locally, then `npm run dev` —
|
||||||
|
same-origin requests reach the backend with no CORS setup.
|
||||||
|
|
||||||
|
To develop the UI against a *remote* backend instead, set `VITE_API_URL`
|
||||||
|
(see `.env.example`).
|
||||||
|
|
||||||
|
## Production serving — WIRED
|
||||||
|
|
||||||
|
The SPA is served by the GC Axum server from the server root. No manual copy
|
||||||
|
step: `vite.config.ts` sets `build.outDir` to `../server/static/app/`, so the
|
||||||
|
build lands exactly where the server serves it.
|
||||||
|
|
||||||
|
### Build & deploy flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from dashboard/
|
||||||
|
npm run build # tsc -b && vite build -> ../server/static/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
That single command refreshes the served SPA. `emptyOutDir` clears only
|
||||||
|
`server/static/app/` (the dedicated SPA subdir), so the v1 portal files in the
|
||||||
|
static root are never touched.
|
||||||
|
|
||||||
|
### How the server serves it (`server/src/main.rs`)
|
||||||
|
|
||||||
|
- `base` is **`/`** (absolute asset paths). The SPA uses `BrowserRouter`, so a
|
||||||
|
hard reload of a deep link (`/machines`) must still load `/assets/*`; relative
|
||||||
|
(`./`) paths would resolve against the deep-link path and 404. Absolute is
|
||||||
|
required.
|
||||||
|
- The Router's `fallback_service` is `ServeDir::new("static/app")` with
|
||||||
|
`.fallback(ServeFile::new("static/app/index.html"))`. Real files under
|
||||||
|
`/assets/*` are served from disk; any other unmatched path returns
|
||||||
|
`index.html` (HTTP **200**) so React Router resolves the route.
|
||||||
|
- **Precedence / safety:** the fallback runs only after every explicit
|
||||||
|
`/api/*`, `/ws/*`, `/health`, `/metrics` route and the `/downloads` nest. Two
|
||||||
|
catch-all routes — `/api/*rest` and `/ws/*rest` — return a JSON **404** for
|
||||||
|
unrouted API/WS paths, so the SPA fallback never answers an API/WS path with
|
||||||
|
HTML (which would break this client's error-envelope parsing).
|
||||||
|
- **Caching:** `/assets/*` (content-hashed) → `immutable`, one year;
|
||||||
|
`index.html` and everything else → `no-cache, must-revalidate`.
|
||||||
|
|
||||||
|
### Build output in git
|
||||||
|
|
||||||
|
`server/static/app/` is a build artifact. Whether to commit it or `.gitignore`
|
||||||
|
it depends on the deploy model (server-side `npm run build` vs shipping the
|
||||||
|
repo's static dir). Decide at commit time. The old `dashboard/dist/` path is no
|
||||||
|
longer used.
|
||||||
|
|
||||||
|
### Sub-path mounting (not used)
|
||||||
|
|
||||||
|
The dashboard is mounted at the server root. If it is ever moved under a
|
||||||
|
sub-path, switch Vite `base` to that path and pass the same `basename` to
|
||||||
|
`<BrowserRouter>`.
|
||||||
32
dashboard/eslint.config.js
Normal file
32
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist", "node_modules"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
13
dashboard/index.html
Normal file
13
dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>GuruConnect — Operator Console</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3331
dashboard/package-lock.json
generated
Normal file
3331
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "@guruconnect/dashboard",
|
"name": "@guruconnect/dashboard",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "GuruConnect Remote Desktop Viewer Components",
|
"description": "GuruConnect v2 operator dashboard",
|
||||||
"author": "AZ Computer Guru",
|
"author": "AZ Computer Guru",
|
||||||
"license": "Proprietary",
|
"license": "Proprietary",
|
||||||
"main": "src/components/index.ts",
|
"private": true,
|
||||||
"types": "src/components/index.ts",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"dev": "vite",
|
||||||
"lint": "eslint src"
|
"build": "tsc -b && vite build",
|
||||||
},
|
"preview": "vite preview",
|
||||||
"peerDependencies": {
|
"lint": "eslint .",
|
||||||
"react": "^18.0.0",
|
"typecheck": "tsc --noEmit"
|
||||||
"react-dom": "^18.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.2.0",
|
|
||||||
"@types/react-dom": "^18.2.0",
|
|
||||||
"typescript": "^5.0.0"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fzstd": "^0.1.1"
|
"@fontsource/hanken-grotesk": "^5.0.8",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.0.18",
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.11.1",
|
||||||
|
"@types/react": "^18.3.10",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"eslint": "^9.11.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.12",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"typescript-eslint": "^8.7.0",
|
||||||
|
"vite": "^5.4.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
dashboard/src/App.tsx
Normal file
51
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Navigate, Route, BrowserRouter, Routes } from "react-router-dom";
|
||||||
|
import { AdminRoute } from "./auth/AdminRoute";
|
||||||
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
|
import { ProtectedRoute } from "./auth/ProtectedRoute";
|
||||||
|
import { AppShell } from "./components/layout/AppShell";
|
||||||
|
import { ToastProvider } from "./components/ui/toast";
|
||||||
|
import { LoginPage } from "./features/auth/LoginPage";
|
||||||
|
import { SupportCodesPage } from "./features/codes/SupportCodesPage";
|
||||||
|
import { MachinesPage } from "./features/machines/MachinesPage";
|
||||||
|
import { SessionsPage } from "./features/sessions/SessionsPage";
|
||||||
|
import { UsersPage } from "./features/users/UsersPage";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="/machines" element={<MachinesPage />} />
|
||||||
|
<Route path="/sessions" element={<SessionsPage />} />
|
||||||
|
<Route path="/codes" element={<SupportCodesPage />} />
|
||||||
|
{/* Users is admin-only: AdminRoute renders an access-denied
|
||||||
|
panel for non-admins instead of the view. */}
|
||||||
|
<Route element={<AdminRoute />}>
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/" element={<Navigate to="/machines" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/machines" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
dashboard/src/api/auth.ts
Normal file
20
dashboard/src/api/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { http } from "./client";
|
||||||
|
import type { LoginRequest, LoginResponse, User } from "./types";
|
||||||
|
|
||||||
|
/** POST /api/auth/login — exchange credentials for a JWT + user record. */
|
||||||
|
export function login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||||
|
// skipAuthRedirect: a 401 here is "bad credentials", not "session expired".
|
||||||
|
return http.post<LoginResponse>("/api/auth/login", credentials, {
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/auth/me — restore the current user from a stored token. */
|
||||||
|
export function getMe(): Promise<User> {
|
||||||
|
return http.get<User>("/api/auth/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/auth/logout — revoke the current token server-side. */
|
||||||
|
export function logout(): Promise<{ message: string }> {
|
||||||
|
return http.post<{ message: string }>("/api/auth/logout");
|
||||||
|
}
|
||||||
138
dashboard/src/api/client.ts
Normal file
138
dashboard/src/api/client.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Typed fetch wrapper for the GuruConnect API.
|
||||||
|
//
|
||||||
|
// Responsibilities:
|
||||||
|
// - Resolve the base URL (VITE_API_URL, default same-origin).
|
||||||
|
// - Attach `Authorization: Bearer <token>` from a pluggable token provider.
|
||||||
|
// - Normalize the *two* inconsistent server error envelopes into one
|
||||||
|
// ApiError shape so callers/UI never have to branch on which one came back.
|
||||||
|
|
||||||
|
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||||
|
|
||||||
|
/** A normalized API error. `code` is present only for the structured envelope. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code?: string;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, code?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token lives in memory in the auth layer. We read it through a provider so
|
||||||
|
// the client has no hard dependency on React state and stays testable.
|
||||||
|
let tokenProvider: () => string | null = () => null;
|
||||||
|
|
||||||
|
export function setTokenProvider(provider: () => string | null): void {
|
||||||
|
tokenProvider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when any request returns 401 — lets the auth layer tear down session
|
||||||
|
// state and bounce to /login. Set by AuthProvider.
|
||||||
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||||
|
onUnauthorized = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
// Suppress the global 401 handler (used by the login call itself).
|
||||||
|
skipAuthRedirect?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's two error envelopes, unioned. We extract a message from either. */
|
||||||
|
interface ErrorEnvelope {
|
||||||
|
error?: string;
|
||||||
|
detail?: string;
|
||||||
|
error_code?: string;
|
||||||
|
status_code?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path: string): string {
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||||
|
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractError(res: Response): Promise<ApiError> {
|
||||||
|
let message = `Request failed (${res.status})`;
|
||||||
|
let code: string | undefined;
|
||||||
|
|
||||||
|
const raw = await res.text();
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const env = JSON.parse(raw) as ErrorEnvelope;
|
||||||
|
// Handle BOTH envelopes: `{error}` and `{detail, error_code, status_code}`.
|
||||||
|
const msg = env.detail ?? env.error;
|
||||||
|
if (typeof msg === "string" && msg.length > 0) message = msg;
|
||||||
|
if (typeof env.error_code === "string") code = env.error_code;
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body (e.g. the machines routes return plain &'static str on
|
||||||
|
// error). Use the trimmed text as the message if it looks sane.
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed && trimmed.length < 300) message = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApiError(message, res.status, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const token = tokenProvider();
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
let body: BodyInit | undefined;
|
||||||
|
if (opts.body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
body = JSON.stringify(opts.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(buildUrl(path), {
|
||||||
|
method: opts.method ?? "GET",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||||
|
throw new ApiError("Network error — could not reach the server.", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401 && !opts.skipAuthRedirect) {
|
||||||
|
onUnauthorized?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw await extractError(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content / empty body.
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return undefined as T;
|
||||||
|
// Most success responses are JSON, but some routes return a plain-text body
|
||||||
|
// on 200 (e.g. cancel returns "Code cancelled"). Tolerate non-JSON so a
|
||||||
|
// successful call isn't surfaced as a SyntaxError failure.
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = {
|
||||||
|
get: <T>(path: string, signal?: AbortSignal) =>
|
||||||
|
request<T>(path, { method: "GET", signal }),
|
||||||
|
post: <T>(path: string, body?: unknown, opts?: Partial<RequestOptions>) =>
|
||||||
|
request<T>(path, { method: "POST", body, ...opts }),
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: "PUT", body }),
|
||||||
|
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||||
|
};
|
||||||
39
dashboard/src/api/codes.ts
Normal file
39
dashboard/src/api/codes.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { http } from "./client";
|
||||||
|
import type { CreateCodeRequest, SupportCode } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/codes — the active support codes (server returns only `pending` and
|
||||||
|
* `connected`, newest first is NOT guaranteed by the in-memory map, so the view
|
||||||
|
* sorts). Requires an authenticated dashboard JWT; any authenticated user may
|
||||||
|
* list. (See server/src/main.rs::list_codes.)
|
||||||
|
*/
|
||||||
|
export function listCodes(signal?: AbortSignal): Promise<SupportCode[]> {
|
||||||
|
return http.get<SupportCode[]>("/api/codes", signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/codes — generate a new one-time support code. The server creates an
|
||||||
|
* in-memory `pending` code (and persists a durable row for the single-use
|
||||||
|
* guard) and returns the full `SupportCode`, including the `XXX-XXX-XXX` value
|
||||||
|
* the tech reads to the end user. `technician_name` attributes the code to the
|
||||||
|
* operator. Requires an authenticated dashboard JWT.
|
||||||
|
* (See server/src/main.rs::create_code.)
|
||||||
|
*/
|
||||||
|
export function createCode(body: CreateCodeRequest): Promise<SupportCode> {
|
||||||
|
return http.post<SupportCode>("/api/codes", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/codes/:code/cancel — revoke an un-redeemed (or connected) code. The
|
||||||
|
* server flips a `pending`/`connected` code to `cancelled` and returns 200
|
||||||
|
* "Code cancelled"; a code that cannot be cancelled (already completed /
|
||||||
|
* cancelled / unknown) returns 400 "Cannot cancel code", which the typed client
|
||||||
|
* surfaces as an ApiError with that message. Requires an authenticated JWT.
|
||||||
|
* (See server/src/main.rs::cancel_code.)
|
||||||
|
*
|
||||||
|
* The path segment is the code itself; it can contain hyphens, so it is
|
||||||
|
* URL-encoded defensively even though the unambiguous alphabet is path-safe.
|
||||||
|
*/
|
||||||
|
export function cancelCode(code: string): Promise<void> {
|
||||||
|
return http.post<void>(`/api/codes/${encodeURIComponent(code)}/cancel`);
|
||||||
|
}
|
||||||
7
dashboard/src/api/index.ts
Normal file
7
dashboard/src/api/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export { ApiError, http, setTokenProvider, setUnauthorizedHandler } from "./client";
|
||||||
|
export * as authApi from "./auth";
|
||||||
|
export * as codesApi from "./codes";
|
||||||
|
export * as machinesApi from "./machines";
|
||||||
|
export * as stubsApi from "./stubs";
|
||||||
|
export * as usersApi from "./users";
|
||||||
99
dashboard/src/api/machines.ts
Normal file
99
dashboard/src/api/machines.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { http } from "./client";
|
||||||
|
import type {
|
||||||
|
BulkRemoveResponse,
|
||||||
|
CreatedKey,
|
||||||
|
DeleteMachineParams,
|
||||||
|
DeleteMachineResponse,
|
||||||
|
KeyMetadata,
|
||||||
|
Machine,
|
||||||
|
MachineHistory,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** GET /api/machines — the real machines endpoint (NOT /api/sessions). */
|
||||||
|
export function listMachines(signal?: AbortSignal): Promise<Machine[]> {
|
||||||
|
return http.get<Machine[]>("/api/machines", signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/machines/:agent_id — single machine. */
|
||||||
|
export function getMachine(agentId: string): Promise<Machine> {
|
||||||
|
return http.get<Machine>(`/api/machines/${encodeURIComponent(agentId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/machines/:agent_id/history — past sessions + events. */
|
||||||
|
export function getMachineHistory(
|
||||||
|
agentId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MachineHistory> {
|
||||||
|
return http.get<MachineHistory>(
|
||||||
|
`/api/machines/${encodeURIComponent(agentId)}/history`,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/machines/:agent_id — remove a machine (admin only).
|
||||||
|
*
|
||||||
|
* Two server-side modes, selected by the query flags:
|
||||||
|
* - `purge: true` → soft-delete + purge the in-memory session (Task 5
|
||||||
|
* operator removal of ghost rows). Mutually exclusive with uninstall/export.
|
||||||
|
* - otherwise → the legacy hard delete, optionally commanding the agent
|
||||||
|
* to uninstall and/or returning full history in the response.
|
||||||
|
*/
|
||||||
|
export function deleteMachine(
|
||||||
|
agentId: string,
|
||||||
|
params: DeleteMachineParams = {},
|
||||||
|
): Promise<DeleteMachineResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.purge) qs.set("purge", "true");
|
||||||
|
if (params.uninstall) qs.set("uninstall", "true");
|
||||||
|
if (params.export) qs.set("export", "true");
|
||||||
|
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
||||||
|
return http.del<DeleteMachineResponse>(
|
||||||
|
`/api/machines/${encodeURIComponent(agentId)}${suffix}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/machines/bulk-remove — remove many machines at once (admin only).
|
||||||
|
* Each id is soft-deleted + its session purged when `purge` is true. Invalid or
|
||||||
|
* unknown ids are reported per-id in the response rather than failing the batch;
|
||||||
|
* the server caps the batch at 500.
|
||||||
|
*/
|
||||||
|
export function bulkRemoveMachines(
|
||||||
|
ids: string[],
|
||||||
|
purge = true,
|
||||||
|
): Promise<BulkRemoveResponse> {
|
||||||
|
return http.post<BulkRemoveResponse>("/api/machines/bulk-remove", {
|
||||||
|
ids,
|
||||||
|
purge,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin: per-agent keys --------------------------------------------------
|
||||||
|
|
||||||
|
/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */
|
||||||
|
export function listMachineKeys(agentId: string): Promise<KeyMetadata[]> {
|
||||||
|
return http.get<KeyMetadata[]>(
|
||||||
|
`/api/machines/${encodeURIComponent(agentId)}/keys`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/machines/:agent_id/keys — mint a new per-agent key (admin only).
|
||||||
|
* The plaintext `key` is returned ONCE in the response — never again.
|
||||||
|
*/
|
||||||
|
export function createMachineKey(agentId: string): Promise<CreatedKey> {
|
||||||
|
return http.post<CreatedKey>(
|
||||||
|
`/api/machines/${encodeURIComponent(agentId)}/keys`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/machines/:agent_id/keys/:key_id — revoke a key (admin only). */
|
||||||
|
export function revokeMachineKey(
|
||||||
|
agentId: string,
|
||||||
|
keyId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return http.del<void>(
|
||||||
|
`/api/machines/${encodeURIComponent(agentId)}/keys/${encodeURIComponent(keyId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
55
dashboard/src/api/sessions.ts
Normal file
55
dashboard/src/api/sessions.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { http } from "./client";
|
||||||
|
import type {
|
||||||
|
RemoveSessionResponse,
|
||||||
|
Session,
|
||||||
|
ViewerTokenResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/sessions — all live sessions known to the relay's in-memory session
|
||||||
|
* manager (active + offline-persistent). Requires an authenticated dashboard
|
||||||
|
* JWT; any authenticated user may list.
|
||||||
|
*/
|
||||||
|
export function listSessions(signal?: AbortSignal): Promise<Session[]> {
|
||||||
|
return http.get<Session[]>("/api/sessions", signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/sessions/:id/viewer-token — mint a short-lived, session-scoped
|
||||||
|
* viewer token. The server decides the access mode from the caller's
|
||||||
|
* permissions: admin or `control` permission gets a `control` token, otherwise
|
||||||
|
* a `view_only` token. A caller with neither `control` nor `view` gets 403.
|
||||||
|
* The access mode is stamped into the signed token; this response only echoes
|
||||||
|
* it. (See server/src/api/sessions.rs::mint_viewer_token.)
|
||||||
|
*/
|
||||||
|
export function mintViewerToken(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<ViewerTokenResponse> {
|
||||||
|
return http.post<ViewerTokenResponse>(
|
||||||
|
`/api/sessions/${encodeURIComponent(sessionId)}/viewer-token`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/sessions/:id — disconnect/end a live session (admin only). The
|
||||||
|
* relay sends a Disconnect to the agent. Returns 200 on success, 404 if the
|
||||||
|
* session is not live in memory. This is the live-only path (no `purge`); it
|
||||||
|
* does not soft-delete any persisted row.
|
||||||
|
*/
|
||||||
|
export function endSession(sessionId: string): Promise<void> {
|
||||||
|
return http.del<void>(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/sessions/:id?purge=true — operator removal of a session (admin
|
||||||
|
* only). Soft-deletes the persisted `connect_sessions` row and drops any live
|
||||||
|
* in-memory session, clearing a ghost/stale session from the console. 404 only
|
||||||
|
* when neither a live nor a persisted session exists.
|
||||||
|
*/
|
||||||
|
export function purgeSession(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<RemoveSessionResponse> {
|
||||||
|
return http.del<RemoveSessionResponse>(
|
||||||
|
`/api/sessions/${encodeURIComponent(sessionId)}?purge=true`,
|
||||||
|
);
|
||||||
|
}
|
||||||
18
dashboard/src/api/stubs.ts
Normal file
18
dashboard/src/api/stubs.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Scaffolds for later passes. These endpoints exist on the server but their
|
||||||
|
// views (Sessions, Codes, Users) are out of scope for pass 1. Typed signatures
|
||||||
|
// are stubbed here so the API surface is discoverable and future passes can
|
||||||
|
// flesh out the response interfaces against the Rust source.
|
||||||
|
//
|
||||||
|
// Intentionally minimal: do NOT build UI against these yet.
|
||||||
|
|
||||||
|
import { http } from "./client";
|
||||||
|
|
||||||
|
/** GET /api/sessions — active/historical sessions. Pass 2. */
|
||||||
|
export function listSessions(signal?: AbortSignal): Promise<unknown[]> {
|
||||||
|
return http.get<unknown[]>("/api/sessions", signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/users — dashboard users (admin). Pass 2. */
|
||||||
|
export function listUsers(signal?: AbortSignal): Promise<unknown[]> {
|
||||||
|
return http.get<unknown[]>("/api/users", signal);
|
||||||
|
}
|
||||||
360
dashboard/src/api/types.ts
Normal file
360
dashboard/src/api/types.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
// Typed mirrors of the GuruConnect server API responses.
|
||||||
|
// Shapes match server/src/api/*.rs exactly. Keep in sync with the Rust source
|
||||||
|
// of truth — these are hand-maintained, not generated.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Role = "admin" | "operator" | "viewer";
|
||||||
|
|
||||||
|
export type Permission =
|
||||||
|
| "view"
|
||||||
|
| "control"
|
||||||
|
| "transfer"
|
||||||
|
| "manage_users"
|
||||||
|
| "manage_clients";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical role set the server accepts (server/src/api/users.rs
|
||||||
|
* `valid_roles`). The Users admin editor must offer exactly these — sending any
|
||||||
|
* other value is a 400.
|
||||||
|
*/
|
||||||
|
export const ROLES: readonly Role[] = ["admin", "operator", "viewer"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical permission set the server accepts (server/src/api/users.rs
|
||||||
|
* `valid_permissions`). These are the exact strings the rest of the app checks
|
||||||
|
* (`view`/`control` gate viewer-token minting; `manage_users` gates the admin
|
||||||
|
* plane). The permission editor must use these — an invented string is a 400.
|
||||||
|
*/
|
||||||
|
export const PERMISSIONS: readonly Permission[] = [
|
||||||
|
"view",
|
||||||
|
"control",
|
||||||
|
"transfer",
|
||||||
|
"manage_users",
|
||||||
|
"manage_clients",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's role-default permissions (server/src/api/users.rs, the `match
|
||||||
|
* request.role` block). When a user is created without an explicit permission
|
||||||
|
* list the server seeds these. The create form mirrors them so the checkboxes
|
||||||
|
* preview exactly what the server will store.
|
||||||
|
*/
|
||||||
|
export const ROLE_DEFAULT_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
|
admin: ["view", "control", "transfer", "manage_users", "manage_clients"],
|
||||||
|
operator: ["view", "control", "transfer"],
|
||||||
|
viewer: ["view"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
// role/permission come from the server as plain strings; widen defensively.
|
||||||
|
role: Role | string;
|
||||||
|
permissions: (Permission | string)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full admin-plane view of a user. Mirrors `api::users::UserInfo`
|
||||||
|
* (server/src/api/users.rs) exactly — every field the list/create/get/update
|
||||||
|
* endpoints return. The password hash is NEVER serialized by the server, so it
|
||||||
|
* has no place in this type. `enabled` is the server's active/disabled flag
|
||||||
|
* (a disabled user cannot log in). `email` and `last_login` are nullable.
|
||||||
|
*/
|
||||||
|
export interface UserAdmin {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
role: Role | string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string; // RFC3339
|
||||||
|
last_login: string | null; // RFC3339
|
||||||
|
permissions: (Permission | string)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /api/users`. Mirrors `api::users::CreateUserRequest`.
|
||||||
|
* `password` is required (server enforces >= 8 chars). `permissions` is
|
||||||
|
* optional: when omitted the server seeds role-default permissions, so the
|
||||||
|
* create UI sends it only when the admin overrides the defaults.
|
||||||
|
*/
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
email?: string | null;
|
||||||
|
role: Role | string;
|
||||||
|
permissions?: (Permission | string)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `PUT /api/users/:id`. Mirrors `api::users::UpdateUserRequest`.
|
||||||
|
* `role` and `enabled` are required (the server always re-applies them).
|
||||||
|
* `password`, when present, sets a new password (server enforces >= 8 chars);
|
||||||
|
* omit it to leave the password unchanged. Permissions are NOT updated here —
|
||||||
|
* they go through the dedicated permissions endpoint.
|
||||||
|
*/
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
email?: string | null;
|
||||||
|
role: Role | string;
|
||||||
|
enabled: boolean;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machines
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type MachineStatus = "online" | "offline";
|
||||||
|
|
||||||
|
export interface Machine {
|
||||||
|
id: string;
|
||||||
|
agent_id: string;
|
||||||
|
hostname: string;
|
||||||
|
os_version: string | null;
|
||||||
|
is_elevated: boolean;
|
||||||
|
is_persistent: boolean;
|
||||||
|
first_seen: string; // RFC3339
|
||||||
|
last_seen: string; // RFC3339
|
||||||
|
status: MachineStatus | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRecord {
|
||||||
|
id: string;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
duration_secs: number | null;
|
||||||
|
is_support_session: boolean;
|
||||||
|
support_code: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventRecord {
|
||||||
|
id: number;
|
||||||
|
session_id: string;
|
||||||
|
event_type: string;
|
||||||
|
timestamp: string;
|
||||||
|
viewer_id: string | null;
|
||||||
|
viewer_name: string | null;
|
||||||
|
details: unknown | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MachineHistory {
|
||||||
|
machine: Machine;
|
||||||
|
sessions: SessionRecord[];
|
||||||
|
events: EventRecord[];
|
||||||
|
exported_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteMachineParams {
|
||||||
|
/** Send an uninstall command to the agent if it is online. */
|
||||||
|
uninstall?: boolean;
|
||||||
|
/** Include full history in the delete response before removal. */
|
||||||
|
export?: boolean;
|
||||||
|
/**
|
||||||
|
* Operator-removal (Task 5): soft-delete the machine and purge its in-memory
|
||||||
|
* session so a ghost row disappears from the console. Selects the server's
|
||||||
|
* `?purge=true` path (admin-only). Mutually exclusive with the legacy
|
||||||
|
* `uninstall`/`export` hard-delete options.
|
||||||
|
*/
|
||||||
|
purge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteMachineResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
uninstall_sent: boolean;
|
||||||
|
history: MachineHistory | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-id outcome in a bulk machine removal. Mirrors
|
||||||
|
* `api::removal::BulkRemoveItem`. `status` is one of `removed` | `not_found` |
|
||||||
|
* `invalid` | `error` (widened to string for forward compatibility).
|
||||||
|
*/
|
||||||
|
export interface BulkRemoveItem {
|
||||||
|
agent_id: string;
|
||||||
|
status: "removed" | "not_found" | "invalid" | "error" | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /api/machines/bulk-remove`. Mirrors
|
||||||
|
* `api::removal::BulkRemoveRequest`. The server caps the batch at 500 ids and
|
||||||
|
* defaults `purge` to true; we always send it explicitly for the operator
|
||||||
|
* removal workflow.
|
||||||
|
*/
|
||||||
|
export interface BulkRemoveRequest {
|
||||||
|
ids: string[];
|
||||||
|
purge: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary body for a bulk removal. Mirrors `api::removal::BulkRemoveResponse`.
|
||||||
|
* `requested` is the batch size, `removed` the count that actually soft-deleted,
|
||||||
|
* and `results` the per-id outcomes.
|
||||||
|
*/
|
||||||
|
export interface BulkRemoveResponse {
|
||||||
|
requested: number;
|
||||||
|
removed: number;
|
||||||
|
results: BulkRemoveItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sessions (live relay state)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attended-consent state. Mirrors `connect_sessions.consent_state` and
|
||||||
|
* `session::ConsentState::as_db_str`. Managed/persistent sessions are
|
||||||
|
* `not_required`; attended (support-code) sessions move
|
||||||
|
* `pending` -> `granted` | `denied`. A viewer may only join `not_required` or
|
||||||
|
* `granted` (the relay refuses the others).
|
||||||
|
*/
|
||||||
|
export type ConsentState =
|
||||||
|
| "not_required"
|
||||||
|
| "pending"
|
||||||
|
| "granted"
|
||||||
|
| "denied";
|
||||||
|
|
||||||
|
/** A technician/viewer currently watching a session. Mirrors `ViewerInfoApi`. */
|
||||||
|
export interface SessionViewer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
connected_at: string; // RFC3339
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live session as returned by GET /api/sessions. Field names mirror
|
||||||
|
* `api::SessionInfo` (server/src/api/mod.rs) exactly. This is in-memory relay
|
||||||
|
* state, not the historical `connect_sessions` row (that is `SessionRecord`).
|
||||||
|
*/
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
agent_id: string;
|
||||||
|
agent_name: string;
|
||||||
|
started_at: string; // RFC3339
|
||||||
|
viewer_count: number;
|
||||||
|
viewers: SessionViewer[];
|
||||||
|
is_streaming: boolean;
|
||||||
|
is_online: boolean;
|
||||||
|
is_persistent: boolean;
|
||||||
|
last_heartbeat: string; // RFC3339
|
||||||
|
os_version: string | null;
|
||||||
|
is_elevated: boolean;
|
||||||
|
uptime_secs: number;
|
||||||
|
display_count: number;
|
||||||
|
agent_version: string | null;
|
||||||
|
consent_state: ConsentState | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from `DELETE /api/sessions/:id?purge=true`. Mirrors
|
||||||
|
* `api::removal::RemoveSessionResponse`. `soft_deleted` is whether a persisted
|
||||||
|
* `connect_sessions` row was marked deleted (false when the session was only
|
||||||
|
* live in memory, e.g. an attended session that never persisted).
|
||||||
|
*/
|
||||||
|
export interface RemoveSessionResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
soft_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access mode the relay grants a minted viewer token. */
|
||||||
|
export type ViewerAccess = "control" | "view_only";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from POST /api/sessions/:id/viewer-token. Mirrors
|
||||||
|
* `api::sessions::ViewerTokenResponse`. The signed token carries the
|
||||||
|
* authoritative access claim; `access` here is the echoed mode.
|
||||||
|
*/
|
||||||
|
export interface ViewerTokenResponse {
|
||||||
|
token: string;
|
||||||
|
session_id: string;
|
||||||
|
expires_in_secs: number;
|
||||||
|
access: ViewerAccess | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Support codes (attended-support, one-time)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle state of a support code. Mirrors `support_codes::CodeStatus`
|
||||||
|
* (`#[serde(rename_all = "lowercase")]`), serialized as the `status` field:
|
||||||
|
* pending — generated, waiting for an end user to redeem it (single-use).
|
||||||
|
* connected — redeemed; an attended session is now bound to it.
|
||||||
|
* completed — that session ended normally.
|
||||||
|
* cancelled — revoked by a tech before it was redeemed.
|
||||||
|
* `GET /api/codes` returns only `pending` and `connected` (the active set);
|
||||||
|
* `completed`/`cancelled` are modeled for completeness and defensive rendering.
|
||||||
|
*/
|
||||||
|
export type CodeStatus =
|
||||||
|
| "pending"
|
||||||
|
| "connected"
|
||||||
|
| "completed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A support code as returned by `POST /api/codes` and `GET /api/codes`. Field
|
||||||
|
* names mirror `support_codes::SupportCode` (serde default snake_case) exactly.
|
||||||
|
*
|
||||||
|
* NOTE: the in-memory `SupportCode` the API serializes has NO `expires_at`
|
||||||
|
* field (only the durable DB row does); codes are short-lived and the dashboard
|
||||||
|
* surfaces liveness via the poll + status, not an absolute expiry. `code` is the
|
||||||
|
* grouped `XXX-XXX-XXX` value the tech reads to the end user.
|
||||||
|
*/
|
||||||
|
export interface SupportCode {
|
||||||
|
code: string;
|
||||||
|
session_id: string; // UUID
|
||||||
|
created_by: string;
|
||||||
|
created_at: string; // RFC3339
|
||||||
|
status: CodeStatus | string;
|
||||||
|
client_name: string | null;
|
||||||
|
client_machine: string | null;
|
||||||
|
connected_at: string | null; // RFC3339, set when redeemed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /api/codes`. Mirrors `support_codes::CreateCodeRequest`. Both
|
||||||
|
* fields are optional; the server stamps `created_by` from `technician_name`
|
||||||
|
* (falling back to "Unknown"). `technician_id` is accepted but currently unused
|
||||||
|
* server-side. We send `technician_name` so the code is attributed to the
|
||||||
|
* signed-in operator.
|
||||||
|
*/
|
||||||
|
export interface CreateCodeRequest {
|
||||||
|
technician_id?: string;
|
||||||
|
technician_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-agent keys (admin plane)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface KeyMetadata {
|
||||||
|
id: string;
|
||||||
|
machine_id: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returned exactly once when a key is minted. `key` is plaintext `cak_...`. */
|
||||||
|
export interface CreatedKey {
|
||||||
|
id: string;
|
||||||
|
machine_id: string;
|
||||||
|
key: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
58
dashboard/src/api/users.ts
Normal file
58
dashboard/src/api/users.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { http } from "./client";
|
||||||
|
import type {
|
||||||
|
CreateUserRequest,
|
||||||
|
Permission,
|
||||||
|
UpdateUserRequest,
|
||||||
|
UserAdmin,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// Admin-plane user management. Every endpoint here is admin-gated server-side
|
||||||
|
// (the `AdminUser` extractor in server/src/auth/mod.rs returns 403 for a
|
||||||
|
// non-admin). The dashboard mirrors that gate so a non-admin never reaches
|
||||||
|
// these calls, but the server is the authority.
|
||||||
|
|
||||||
|
/** GET /api/users — list every user (admin only). */
|
||||||
|
export function listUsers(signal?: AbortSignal): Promise<UserAdmin[]> {
|
||||||
|
return http.get<UserAdmin[]>("/api/users", signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/users — create a user (admin only). Returns the created user.
|
||||||
|
* The plaintext password is sent in the body but NEVER echoed back in the
|
||||||
|
* response (the server's UserInfo has no password field).
|
||||||
|
*/
|
||||||
|
export function createUser(body: CreateUserRequest): Promise<UserAdmin> {
|
||||||
|
return http.post<UserAdmin>("/api/users", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/users/:id — update role / enabled / email, and optionally set a new
|
||||||
|
* password (admin only). Permissions are NOT changed here — use setPermissions.
|
||||||
|
*/
|
||||||
|
export function updateUser(
|
||||||
|
id: string,
|
||||||
|
body: UpdateUserRequest,
|
||||||
|
): Promise<UserAdmin> {
|
||||||
|
return http.put<UserAdmin>(`/api/users/${encodeURIComponent(id)}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/users/:id/permissions — replace a user's permission set (admin
|
||||||
|
* only). Returns 200 with no body.
|
||||||
|
*/
|
||||||
|
export function setUserPermissions(
|
||||||
|
id: string,
|
||||||
|
permissions: (Permission | string)[],
|
||||||
|
): Promise<void> {
|
||||||
|
return http.put<void>(`/api/users/${encodeURIComponent(id)}/permissions`, {
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/users/:id — permanently delete a user (admin only). The server
|
||||||
|
* refuses to delete the caller's own account (400). Returns 204.
|
||||||
|
*/
|
||||||
|
export function deleteUser(id: string): Promise<void> {
|
||||||
|
return http.del<void>(`/api/users/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
56
dashboard/src/auth/AdminRoute.tsx
Normal file
56
dashboard/src/auth/AdminRoute.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Link, Outlet } from "react-router-dom";
|
||||||
|
import { Panel } from "../components/ui/Panel";
|
||||||
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route gate for admin-only sections (the Users plane). Sits inside
|
||||||
|
* ProtectedRoute, so the user is already authenticated here — this only checks
|
||||||
|
* the admin role.
|
||||||
|
*
|
||||||
|
* A non-admin who navigates to an admin route sees a calm, explicit
|
||||||
|
* access-denied panel (NOT a redirect loop and NOT a 403 toast storm). The
|
||||||
|
* server remains the real authority: the underlying /api/users calls are
|
||||||
|
* admin-gated server-side, so this is defense-in-depth plus correct UX.
|
||||||
|
*/
|
||||||
|
export function AdminRoute() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="denied">
|
||||||
|
<Panel>
|
||||||
|
<div className="denied__body">
|
||||||
|
<span className="denied__badge" aria-hidden="true">
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<h1 className="denied__title">Admins only</h1>
|
||||||
|
<p className="denied__msg">
|
||||||
|
User management is restricted to administrators. Your account
|
||||||
|
does not have admin access. If you need it, ask an administrator
|
||||||
|
to update your role.
|
||||||
|
</p>
|
||||||
|
<Link to="/machines" className="btn btn--primary denied__link">
|
||||||
|
Back to Machines
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
21
dashboard/src/auth/AuthContext.tsx
Normal file
21
dashboard/src/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { Permission, Role, User } from "../api/types";
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
/** True while restoring a session from a stored token on first load. */
|
||||||
|
initializing: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
isAdmin: boolean;
|
||||||
|
hasRole: (role: Role) => boolean;
|
||||||
|
hasPermission: (perm: Permission) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
100
dashboard/src/auth/AuthProvider.tsx
Normal file
100
dashboard/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import * as authApi from "../api/auth";
|
||||||
|
import { setTokenProvider, setUnauthorizedHandler } from "../api/client";
|
||||||
|
import type { Permission, Role, User } from "../api/types";
|
||||||
|
import { AuthContext, type AuthState } from "./AuthContext";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gc.token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token storage policy: the source of truth is an in-memory ref (survives
|
||||||
|
* re-renders, never serialized into React state to avoid accidental logging).
|
||||||
|
* It is mirrored into sessionStorage — NOT localStorage — so it clears when the
|
||||||
|
* tab closes and never leaks across browser sessions.
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const tokenRef = useRef<string | null>(sessionStorage.getItem(STORAGE_KEY));
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [initializing, setInitializing] = useState(true);
|
||||||
|
|
||||||
|
const setToken = useCallback((token: string | null) => {
|
||||||
|
tokenRef.current = token;
|
||||||
|
if (token) sessionStorage.setItem(STORAGE_KEY, token);
|
||||||
|
else sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Wire the API client to read our token and to notify us on 401.
|
||||||
|
useEffect(() => {
|
||||||
|
setTokenProvider(() => tokenRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSession = useCallback(() => {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}, [setToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(clearSession);
|
||||||
|
return () => setUnauthorizedHandler(null);
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
// Restore session on first load if a token is present.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function restore() {
|
||||||
|
if (!tokenRef.current) {
|
||||||
|
setInitializing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const me = await authApi.getMe();
|
||||||
|
if (!cancelled) setUser(me);
|
||||||
|
} catch {
|
||||||
|
// Invalid/expired token — clear it. The 401 handler also fires, but
|
||||||
|
// guard here for non-401 failures too.
|
||||||
|
if (!cancelled) clearSession();
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setInitializing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void restore();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (username: string, password: string) => {
|
||||||
|
const res = await authApi.login({ username, password });
|
||||||
|
setToken(res.token);
|
||||||
|
setUser(res.user);
|
||||||
|
},
|
||||||
|
[setToken],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Best-effort server-side revocation; clear locally regardless.
|
||||||
|
await authApi.logout();
|
||||||
|
} catch {
|
||||||
|
// ignore — token may already be invalid
|
||||||
|
} finally {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
const value = useMemo<AuthState>(() => {
|
||||||
|
const role = user?.role;
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
initializing,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAdmin: role === "admin",
|
||||||
|
hasRole: (r: Role) => role === r,
|
||||||
|
hasPermission: (p: Permission) => user?.permissions.includes(p) ?? false,
|
||||||
|
};
|
||||||
|
}, [user, initializing, login, logout]);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
27
dashboard/src/auth/ProtectedRoute.tsx
Normal file
27
dashboard/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { Spinner } from "../components/ui/Spinner";
|
||||||
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate for authenticated routes. While restoring a session from a stored token
|
||||||
|
* we show a spinner (avoids a login-flash on reload). No user -> /login,
|
||||||
|
* preserving the attempted location for post-login return.
|
||||||
|
*/
|
||||||
|
export function ProtectedRoute() {
|
||||||
|
const { user, initializing } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (initializing) {
|
||||||
|
return (
|
||||||
|
<div className="auth-gate">
|
||||||
|
<Spinner label="Restoring session" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* RemoteViewer Component
|
|
||||||
*
|
|
||||||
* Canvas-based remote desktop viewer that connects to a GuruConnect
|
|
||||||
* agent via the relay server. Handles frame rendering and input capture.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
|
||||||
import { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';
|
|
||||||
import type { VideoFrame, ConnectionStatus, MouseEventType } from '../types/protocol';
|
|
||||||
|
|
||||||
interface RemoteViewerProps {
|
|
||||||
serverUrl: string;
|
|
||||||
sessionId: string;
|
|
||||||
className?: string;
|
|
||||||
onStatusChange?: (status: ConnectionStatus) => void;
|
|
||||||
autoConnect?: boolean;
|
|
||||||
showStatusBar?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RemoteViewer: React.FC<RemoteViewerProps> = ({
|
|
||||||
serverUrl,
|
|
||||||
sessionId,
|
|
||||||
className = '',
|
|
||||||
onStatusChange,
|
|
||||||
autoConnect = true,
|
|
||||||
showStatusBar = true,
|
|
||||||
}) => {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
|
||||||
|
|
||||||
// Display dimensions from received frames
|
|
||||||
const [displaySize, setDisplaySize] = useState({ width: 1920, height: 1080 });
|
|
||||||
|
|
||||||
// Frame buffer for rendering
|
|
||||||
const frameBufferRef = useRef<ImageData | null>(null);
|
|
||||||
|
|
||||||
// Handle incoming video frames
|
|
||||||
const handleFrame = useCallback((frame: VideoFrame) => {
|
|
||||||
if (!frame.raw || !canvasRef.current) return;
|
|
||||||
|
|
||||||
const { width, height, data, compressed, isKeyframe } = frame.raw;
|
|
||||||
|
|
||||||
// Update display size if changed
|
|
||||||
if (width !== displaySize.width || height !== displaySize.height) {
|
|
||||||
setDisplaySize({ width, height });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create context
|
|
||||||
if (!ctxRef.current) {
|
|
||||||
ctxRef.current = canvasRef.current.getContext('2d', {
|
|
||||||
alpha: false,
|
|
||||||
desynchronized: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = ctxRef.current;
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
// For MVP, we assume raw BGRA frames
|
|
||||||
// In production, handle compressed frames with fzstd
|
|
||||||
let frameData = data;
|
|
||||||
|
|
||||||
// Create or reuse ImageData
|
|
||||||
if (!frameBufferRef.current ||
|
|
||||||
frameBufferRef.current.width !== width ||
|
|
||||||
frameBufferRef.current.height !== height) {
|
|
||||||
frameBufferRef.current = ctx.createImageData(width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageData = frameBufferRef.current;
|
|
||||||
|
|
||||||
// Convert BGRA to RGBA for canvas
|
|
||||||
const pixels = imageData.data;
|
|
||||||
const len = Math.min(frameData.length, pixels.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < len; i += 4) {
|
|
||||||
pixels[i] = frameData[i + 2]; // R <- B
|
|
||||||
pixels[i + 1] = frameData[i + 1]; // G <- G
|
|
||||||
pixels[i + 2] = frameData[i]; // B <- R
|
|
||||||
pixels[i + 3] = 255; // A (opaque)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw to canvas
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
}, [displaySize]);
|
|
||||||
|
|
||||||
// Set up session
|
|
||||||
const { status, connect, disconnect, sendMouseEvent, sendKeyEvent } = useRemoteSession({
|
|
||||||
serverUrl,
|
|
||||||
sessionId,
|
|
||||||
onFrame: handleFrame,
|
|
||||||
onStatusChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-connect on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoConnect) {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
disconnect();
|
|
||||||
};
|
|
||||||
}, [autoConnect, connect, disconnect]);
|
|
||||||
|
|
||||||
// Update canvas size when display size changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (canvasRef.current) {
|
|
||||||
canvasRef.current.width = displaySize.width;
|
|
||||||
canvasRef.current.height = displaySize.height;
|
|
||||||
// Reset context reference
|
|
||||||
ctxRef.current = null;
|
|
||||||
frameBufferRef.current = null;
|
|
||||||
}
|
|
||||||
}, [displaySize]);
|
|
||||||
|
|
||||||
// Get canvas rect for coordinate translation
|
|
||||||
const getCanvasRect = useCallback(() => {
|
|
||||||
return canvasRef.current?.getBoundingClientRect() ?? new DOMRect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Mouse event handlers
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
||||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 0);
|
|
||||||
sendMouseEvent(event);
|
|
||||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 1);
|
|
||||||
sendMouseEvent(event);
|
|
||||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
||||||
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 2);
|
|
||||||
sendMouseEvent(event);
|
|
||||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
|
||||||
|
|
||||||
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const baseEvent = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 3);
|
|
||||||
sendMouseEvent({
|
|
||||||
...baseEvent,
|
|
||||||
wheelDeltaX: Math.round(e.deltaX),
|
|
||||||
wheelDeltaY: Math.round(e.deltaY),
|
|
||||||
});
|
|
||||||
}, [getCanvasRect, displaySize, sendMouseEvent]);
|
|
||||||
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
||||||
e.preventDefault(); // Prevent browser context menu
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keyboard event handlers
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const event = createKeyEvent(e, true);
|
|
||||||
sendKeyEvent(event);
|
|
||||||
}, [sendKeyEvent]);
|
|
||||||
|
|
||||||
const handleKeyUp = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const event = createKeyEvent(e, false);
|
|
||||||
sendKeyEvent(event);
|
|
||||||
}, [sendKeyEvent]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className={`remote-viewer ${className}`}>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
tabIndex={0}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: 'auto',
|
|
||||||
aspectRatio: `${displaySize.width} / ${displaySize.height}`,
|
|
||||||
cursor: 'none', // Hide cursor, remote cursor is shown in frame
|
|
||||||
outline: 'none',
|
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showStatusBar && (
|
|
||||||
<div className="remote-viewer-status" style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '4px 8px',
|
|
||||||
backgroundColor: '#333',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}>
|
|
||||||
<span>
|
|
||||||
{status.connected ? (
|
|
||||||
<span style={{ color: '#4ade80' }}>Connected</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#f87171' }}>Disconnected</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span>{displaySize.width}x{displaySize.height}</span>
|
|
||||||
{status.fps !== undefined && <span>{status.fps} FPS</span>}
|
|
||||||
{status.latencyMs !== undefined && <span>{status.latencyMs}ms</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RemoteViewer;
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* Session Controls Component
|
|
||||||
*
|
|
||||||
* Toolbar for controlling the remote session (quality, displays, special keys)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import type { QualitySettings, Display } from '../types/protocol';
|
|
||||||
|
|
||||||
interface SessionControlsProps {
|
|
||||||
displays?: Display[];
|
|
||||||
currentDisplay?: number;
|
|
||||||
onDisplayChange?: (displayId: number) => void;
|
|
||||||
quality?: QualitySettings;
|
|
||||||
onQualityChange?: (settings: QualitySettings) => void;
|
|
||||||
onSpecialKey?: (key: 'ctrl-alt-del' | 'lock-screen' | 'print-screen') => void;
|
|
||||||
onDisconnect?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionControls: React.FC<SessionControlsProps> = ({
|
|
||||||
displays = [],
|
|
||||||
currentDisplay = 0,
|
|
||||||
onDisplayChange,
|
|
||||||
quality,
|
|
||||||
onQualityChange,
|
|
||||||
onSpecialKey,
|
|
||||||
onDisconnect,
|
|
||||||
}) => {
|
|
||||||
const [showQuality, setShowQuality] = useState(false);
|
|
||||||
|
|
||||||
const handleQualityPreset = (preset: 'auto' | 'low' | 'balanced' | 'high') => {
|
|
||||||
onQualityChange?.({
|
|
||||||
preset,
|
|
||||||
codec: 'auto',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="session-controls" style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '8px',
|
|
||||||
backgroundColor: '#222',
|
|
||||||
borderBottom: '1px solid #444',
|
|
||||||
}}>
|
|
||||||
{/* Display selector */}
|
|
||||||
{displays.length > 1 && (
|
|
||||||
<select
|
|
||||||
value={currentDisplay}
|
|
||||||
onChange={(e) => onDisplayChange?.(Number(e.target.value))}
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px',
|
|
||||||
backgroundColor: '#333',
|
|
||||||
color: '#fff',
|
|
||||||
border: '1px solid #555',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displays.map((d) => (
|
|
||||||
<option key={d.id} value={d.id}>
|
|
||||||
{d.name || `Display ${d.id + 1}`}
|
|
||||||
{d.isPrimary ? ' (Primary)' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quality dropdown */}
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowQuality(!showQuality)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 12px',
|
|
||||||
backgroundColor: '#333',
|
|
||||||
color: '#fff',
|
|
||||||
border: '1px solid #555',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Quality: {quality?.preset || 'auto'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showQuality && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '100%',
|
|
||||||
left: 0,
|
|
||||||
marginTop: '4px',
|
|
||||||
backgroundColor: '#333',
|
|
||||||
border: '1px solid #555',
|
|
||||||
borderRadius: '4px',
|
|
||||||
zIndex: 100,
|
|
||||||
}}>
|
|
||||||
{(['auto', 'low', 'balanced', 'high'] as const).map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset}
|
|
||||||
onClick={() => {
|
|
||||||
handleQualityPreset(preset);
|
|
||||||
setShowQuality(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 16px',
|
|
||||||
backgroundColor: quality?.preset === preset ? '#444' : 'transparent',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
textAlign: 'left',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{preset.charAt(0).toUpperCase() + preset.slice(1)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Special keys */}
|
|
||||||
<button
|
|
||||||
onClick={() => onSpecialKey?.('ctrl-alt-del')}
|
|
||||||
title="Send Ctrl+Alt+Delete"
|
|
||||||
style={{
|
|
||||||
padding: '4px 12px',
|
|
||||||
backgroundColor: '#333',
|
|
||||||
color: '#fff',
|
|
||||||
border: '1px solid #555',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ctrl+Alt+Del
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onSpecialKey?.('lock-screen')}
|
|
||||||
title="Lock Screen (Win+L)"
|
|
||||||
style={{
|
|
||||||
padding: '4px 12px',
|
|
||||||
backgroundColor: '#333',
|
|
||||||
color: '#fff',
|
|
||||||
border: '1px solid #555',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Lock
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onSpecialKey?.('print-screen')}
|
|
||||||
title="Print Screen"
|
|
||||||
style={{
|
|
||||||
padding: '4px 12px',
|
|
||||||
backgroundColor: '#333',
|
|
||||||
color: '#fff',
|
|
||||||
border: '1px solid #555',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
PrtSc
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
|
|
||||||
{/* Disconnect */}
|
|
||||||
<button
|
|
||||||
onClick={onDisconnect}
|
|
||||||
style={{
|
|
||||||
padding: '4px 12px',
|
|
||||||
backgroundColor: '#dc2626',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SessionControls;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* GuruConnect Dashboard Components
|
|
||||||
*
|
|
||||||
* Export all components for use in GuruRMM dashboard
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { RemoteViewer } from './RemoteViewer';
|
|
||||||
export { SessionControls } from './SessionControls';
|
|
||||||
|
|
||||||
// Re-export types
|
|
||||||
export type {
|
|
||||||
ConnectionStatus,
|
|
||||||
Display,
|
|
||||||
DisplayInfo,
|
|
||||||
QualitySettings,
|
|
||||||
VideoFrame,
|
|
||||||
MouseEvent as ProtoMouseEvent,
|
|
||||||
KeyEvent as ProtoKeyEvent,
|
|
||||||
} from '../types/protocol';
|
|
||||||
|
|
||||||
// Re-export hooks
|
|
||||||
export { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';
|
|
||||||
17
dashboard/src/components/layout/AppShell.tsx
Normal file
17
dashboard/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import "./layout.css";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { Topbar } from "./Topbar";
|
||||||
|
|
||||||
|
/** Persistent chrome: left sidebar + top bar around the routed page. */
|
||||||
|
export function AppShell() {
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<Sidebar />
|
||||||
|
<Topbar />
|
||||||
|
<main className="main">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
dashboard/src/components/layout/PageHeader.tsx
Normal file
21
dashboard/src/components/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
/** Primary action slot, right-aligned. */
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard page title block with an action slot. */
|
||||||
|
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="page__header">
|
||||||
|
<div className="page__titles">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{subtitle && <div className="page__subtitle">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="page__actions">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
dashboard/src/components/layout/Sidebar.tsx
Normal file
85
dashboard/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import type { ComponentType, SVGProps } from "react";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import {
|
||||||
|
CodesIcon,
|
||||||
|
MachinesIcon,
|
||||||
|
SessionsIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "./icons";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
/** Pass-1 stubs are disabled until their views land in later passes. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Only render for admins (the underlying route is admin-gated). */
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV: NavItem[] = [
|
||||||
|
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
|
||||||
|
{ to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: true },
|
||||||
|
{ to: "/codes", label: "Codes", Icon: CodesIcon, enabled: true },
|
||||||
|
{
|
||||||
|
to: "/users",
|
||||||
|
label: "Users",
|
||||||
|
Icon: UsersIcon,
|
||||||
|
enabled: true,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
// Hide admin-only items from non-admins entirely (the route also gates them,
|
||||||
|
// and the API is admin-gated server-side — this keeps the UX honest).
|
||||||
|
const items = NAV.filter((item) => !item.adminOnly || isAdmin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar__brand">
|
||||||
|
<span className="sidebar__logo" aria-hidden="true">
|
||||||
|
GC
|
||||||
|
</span>
|
||||||
|
<span className="sidebar__name">
|
||||||
|
GuruConnect
|
||||||
|
<small>Operator Console</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<nav className="sidebar__nav" aria-label="Primary">
|
||||||
|
<span className="sidebar__section">Operations</span>
|
||||||
|
{items.map(({ to, label, Icon, enabled }) =>
|
||||||
|
enabled ? (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`navlink${isActive ? " navlink--active" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="navlink__icon">
|
||||||
|
<Icon />
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
key={to}
|
||||||
|
className="navlink navlink--disabled"
|
||||||
|
aria-disabled="true"
|
||||||
|
title={`${label} — coming in a later pass`}
|
||||||
|
>
|
||||||
|
<span className="navlink__icon">
|
||||||
|
<Icon />
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
<span className="navlink__soon">Soon</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
dashboard/src/components/layout/Topbar.tsx
Normal file
51
dashboard/src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { useRelayStatus } from "../../lib/useRelayStatus";
|
||||||
|
import { Badge } from "../ui/Badge";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import { LogoutIcon } from "./icons";
|
||||||
|
|
||||||
|
function roleTone(role: string | undefined): "accent" | "ok" | "neutral" {
|
||||||
|
if (role === "admin") return "accent";
|
||||||
|
if (role === "operator") return "ok";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Topbar() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { live, checking } = useRelayStatus();
|
||||||
|
|
||||||
|
const relayClass = live ? "relay relay--live" : "relay relay--down";
|
||||||
|
const relayLabel = checking ? "probing" : live ? "live" : "offline";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div
|
||||||
|
className={relayClass}
|
||||||
|
title="GuruConnect relay connection"
|
||||||
|
aria-label={`Relay ${relayLabel}`}
|
||||||
|
>
|
||||||
|
<span className="relay__pip" aria-hidden="true" />
|
||||||
|
<span>Relay</span>
|
||||||
|
<span className="relay__label mono">{relayLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topbar__spacer" />
|
||||||
|
|
||||||
|
<div className="topbar__user">
|
||||||
|
<div className="topbar__id">
|
||||||
|
<span className="topbar__username">{user?.username}</span>
|
||||||
|
</div>
|
||||||
|
<Badge tone={roleTone(user?.role)}>{user?.role ?? "—"}</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void logout()}
|
||||||
|
aria-label="Log out"
|
||||||
|
>
|
||||||
|
<LogoutIcon width={15} height={15} />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
dashboard/src/components/layout/icons.tsx
Normal file
172
dashboard/src/components/layout/icons.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// Inline stroke icons (no icon-library dependency). 18px on a 24 viewBox.
|
||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
function base(props: IconProps) {
|
||||||
|
return {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
strokeLinecap: "round" as const,
|
||||||
|
strokeLinejoin: "round" as const,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MachinesIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<rect x="2" y="4" width="20" height="13" rx="2" />
|
||||||
|
<path d="M8 21h8M12 17v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionsIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodesIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M8 6 3 12l5 6M16 6l5 6-5 6M13 4l-2 16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogoutIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||||
|
<path d="m10.7 12.3 8.3-8.3M16 6l3 3M14 8l2 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrashIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 16v-4M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<rect x="9" y="9" width="12" height="12" rx="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoinIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StopIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<rect x="5" y="5" width="14" height="14" rx="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlusIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EyeIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EyeOffIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 7 10 7a18 18 0 0 1-2.16 3.19M6.6 6.6A18 18 0 0 0 2 11s3.5 7 10 7a9 9 0 0 0 5.4-1.6" />
|
||||||
|
<path d="m9.5 9.5a3 3 0 0 0 4.2 4.2M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShuffleIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
dashboard/src/components/layout/layout.css
Normal file
215
dashboard/src/components/layout/layout.css
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/* ============================================================= App shell === */
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
|
grid-template-rows: var(--topbar-h) 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"sidebar topbar"
|
||||||
|
"sidebar main";
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================ Sidebar === */
|
||||||
|
.sidebar {
|
||||||
|
grid-area: sidebar;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.sidebar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: var(--topbar-h);
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.sidebar__logo {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-press));
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--accent-ink);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.sidebar__name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.sidebar__name small {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.sidebar__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar__section {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 14px 10px 6px;
|
||||||
|
}
|
||||||
|
.navlink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
color var(--dur-fast) var(--ease);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.navlink:hover {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.navlink--active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent-ring);
|
||||||
|
}
|
||||||
|
.navlink--disabled {
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.navlink__icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.navlink__soon {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= Topbar === */
|
||||||
|
.topbar {
|
||||||
|
grid-area: topbar;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.topbar__spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.relay {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.relay__pip {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.relay--live .relay__pip {
|
||||||
|
background: var(--ok);
|
||||||
|
box-shadow: 0 0 8px var(--ok);
|
||||||
|
animation: gc-live 1.8s var(--ease) infinite;
|
||||||
|
}
|
||||||
|
.relay--down .relay__pip {
|
||||||
|
background: var(--bad);
|
||||||
|
}
|
||||||
|
.relay__label.mono {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.topbar__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.topbar__id {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.topbar__username {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================== Main === */
|
||||||
|
.main {
|
||||||
|
grid-area: main;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
padding: 22px 24px 40px;
|
||||||
|
max-width: 1320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.page__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.page__titles h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
.page__subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.page__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-gate {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
25
dashboard/src/components/ui/Badge.tsx
Normal file
25
dashboard/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { StatusTone } from "./status";
|
||||||
|
import { StatusDot } from "./StatusDot";
|
||||||
|
|
||||||
|
type BadgeTone = StatusTone | "accent";
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
tone?: BadgeTone;
|
||||||
|
/** Render a leading status dot inside the badge. */
|
||||||
|
dot?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pill label using the status vocabulary. With `dot`, pairs the label with a
|
||||||
|
* matching StatusDot so the dot+label convention reads consistently.
|
||||||
|
*/
|
||||||
|
export function Badge({ tone = "neutral", dot = false, children }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span className={`badge badge--${tone}`}>
|
||||||
|
{dot && tone !== "accent" && <StatusDot tone={tone} />}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
dashboard/src/components/ui/Button.tsx
Normal file
53
dashboard/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
type Variant = "primary" | "ghost" | "danger";
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: Variant;
|
||||||
|
/** Compact 28px height for table-row actions and tight toolbars. */
|
||||||
|
size?: "sm" | "md";
|
||||||
|
/** Stretch to fill the container width (e.g. login submit). */
|
||||||
|
block?: boolean;
|
||||||
|
/** Show a spinner and disable while an async action is in flight. */
|
||||||
|
loading?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one button. Variants map to the design language:
|
||||||
|
* - primary: accent-solid, the single high-signal action per surface
|
||||||
|
* - ghost: bordered, secondary
|
||||||
|
* - danger: destructive (delete machine, revoke key)
|
||||||
|
*/
|
||||||
|
export function Button({
|
||||||
|
variant = "ghost",
|
||||||
|
size = "md",
|
||||||
|
block = false,
|
||||||
|
loading = false,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const classes = [
|
||||||
|
"btn",
|
||||||
|
`btn--${variant}`,
|
||||||
|
size === "sm" && "btn--sm",
|
||||||
|
block && "btn--block",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={classes}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading && <span className="btn__spin" aria-hidden="true" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
dashboard/src/components/ui/ConfirmDialog.tsx
Normal file
55
dashboard/src/components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
body: ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** Style the confirm button as destructive. */
|
||||||
|
danger?: boolean;
|
||||||
|
/** Disable controls + spin the confirm button while the action runs. */
|
||||||
|
busy?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small yes/no confirmation built on Modal. */
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
confirmLabel = "Confirm",
|
||||||
|
cancelLabel = "Cancel",
|
||||||
|
danger = false,
|
||||||
|
busy = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={title}
|
||||||
|
onClose={busy ? () => {} : onCancel}
|
||||||
|
dismissable={!busy}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={danger ? "danger" : "primary"}
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={busy}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
dashboard/src/components/ui/Drawer.tsx
Normal file
150
dashboard/src/components/ui/Drawer.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useId, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
hasOpenDialog,
|
||||||
|
isTopDialog,
|
||||||
|
popDialog,
|
||||||
|
pushDialog,
|
||||||
|
} from "./dialogStack";
|
||||||
|
|
||||||
|
interface DrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
title: ReactNode;
|
||||||
|
/** Accessible name when `title` is not plain text. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Optional secondary line under the title (status, id). */
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Sticky footer slot for actions. */
|
||||||
|
footer?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-anchored side panel for read and inspect flows (machine detail and
|
||||||
|
* history) where a modal would over-interrupt. Shares the dialog a11y contract:
|
||||||
|
* Tab focus is trapped, the rest of the page is inert, Escape closes, and focus
|
||||||
|
* returns to the trigger on close.
|
||||||
|
*/
|
||||||
|
export function Drawer({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
subtitle,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
children,
|
||||||
|
}: DrawerProps) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastFocused = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
lastFocused.current = document.activeElement as HTMLElement | null;
|
||||||
|
const token = pushDialog();
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
root?.setAttribute("inert", "");
|
||||||
|
|
||||||
|
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
||||||
|
(first ?? panel)?.focus();
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (!isTopDialog(token)) return;
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !panel) return;
|
||||||
|
const items = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||||
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
panel.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstEl = items[0];
|
||||||
|
const lastEl = items[items.length - 1];
|
||||||
|
const active = document.activeElement as HTMLElement;
|
||||||
|
if (e.shiftKey && (active === firstEl || active === panel)) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl.focus();
|
||||||
|
} else if (!e.shiftKey && active === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey, true);
|
||||||
|
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey, true);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
popDialog(token);
|
||||||
|
if (!hasOpenDialog()) root?.removeAttribute("inert");
|
||||||
|
lastFocused.current?.focus?.();
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="drawer__scrim"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
ref={panelRef}
|
||||||
|
className="drawer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabel ? undefined : titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<header className="drawer__head">
|
||||||
|
<div className="drawer__titles">
|
||||||
|
<h2 className="drawer__title" id={titleId}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{subtitle && <div className="drawer__subtitle">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="iconbtn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close panel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 6l12 12M18 6 6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="drawer__body">{children}</div>
|
||||||
|
{footer && <footer className="drawer__footer">{footer}</footer>}
|
||||||
|
</aside>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
36
dashboard/src/components/ui/Input.tsx
Normal file
36
dashboard/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
/** Render technical/data values in JetBrains Mono. */
|
||||||
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bare styled text input. Compose with <Field> for a labeled control. */
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||||
|
{ mono, className, ...rest },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const classes = ["input", mono && "input--mono", className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return <input ref={ref} className={classes} {...rest} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FieldProps {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label + control wrapper for forms. */
|
||||||
|
export function Field({ label, htmlFor, children }: FieldProps) {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label className="field__label" htmlFor={htmlFor}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
dashboard/src/components/ui/Modal.tsx
Normal file
160
dashboard/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useId, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
hasOpenDialog,
|
||||||
|
isTopDialog,
|
||||||
|
popDialog,
|
||||||
|
pushDialog,
|
||||||
|
} from "./dialogStack";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
title: ReactNode;
|
||||||
|
/** Accessible name for the dialog when `title` is not plain text. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Footer slot, typically the action buttons. */
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** Wider layout for content-heavy dialogs (key management). */
|
||||||
|
wide?: boolean;
|
||||||
|
/** Disable overlay-click and Escape dismissal (e.g. during a pending action). */
|
||||||
|
dismissable?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessible modal dialog. Closes on Escape and overlay click (unless
|
||||||
|
* `dismissable` is false), traps Tab focus inside, marks the rest of the page
|
||||||
|
* inert, and restores focus to the trigger on close.
|
||||||
|
*/
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
wide,
|
||||||
|
dismissable = true,
|
||||||
|
children,
|
||||||
|
}: ModalProps) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastFocused = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
lastFocused.current = document.activeElement as HTMLElement | null;
|
||||||
|
const token = pushDialog();
|
||||||
|
|
||||||
|
// Mark everything outside the dialog inert so focus and clicks can't reach
|
||||||
|
// the page behind. Dialogs are portaled to <body>, so this targets #root.
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
root?.setAttribute("inert", "");
|
||||||
|
|
||||||
|
// Move focus to the first focusable control, falling back to the panel.
|
||||||
|
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
||||||
|
(first ?? panel)?.focus();
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
// Only the topmost dialog reacts (don't close a stack all at once).
|
||||||
|
if (!isTopDialog(token)) return;
|
||||||
|
if (e.key === "Escape" && dismissable) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !panel) return;
|
||||||
|
// Cycle focus within the dialog.
|
||||||
|
const items = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||||
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
panel.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstEl = items[0];
|
||||||
|
const lastEl = items[items.length - 1];
|
||||||
|
const active = document.activeElement as HTMLElement;
|
||||||
|
if (e.shiftKey && (active === firstEl || active === panel)) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl.focus();
|
||||||
|
} else if (!e.shiftKey && active === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey, true);
|
||||||
|
|
||||||
|
// Lock background scroll while the dialog is open.
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey, true);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
popDialog(token);
|
||||||
|
// Only lift inert once the last dialog has closed.
|
||||||
|
if (!hasOpenDialog()) root?.removeAttribute("inert");
|
||||||
|
lastFocused.current?.focus?.();
|
||||||
|
};
|
||||||
|
}, [open, dismissable, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const labelledBy = ariaLabel ? undefined : titleId;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal__overlay"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget && dismissable) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className={`modal${wide ? " modal--wide" : ""}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<header className="modal__head">
|
||||||
|
<h2 className="modal__title" id={titleId}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{dismissable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="iconbtn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close dialog"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 6l12 12M18 6 6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="modal__body">{children}</div>
|
||||||
|
{footer && <footer className="modal__footer">{footer}</footer>}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
27
dashboard/src/components/ui/Panel.tsx
Normal file
27
dashboard/src/components/ui/Panel.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface PanelProps {
|
||||||
|
/** Optional header title. When omitted, no header bar is rendered. */
|
||||||
|
title?: ReactNode;
|
||||||
|
/** Optional right-aligned header slot (actions, counts). */
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** Remove default body padding (e.g. when embedding a flush table). */
|
||||||
|
flush?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bordered surface card. The base building block for content panels. */
|
||||||
|
export function Panel({ title, actions, flush, className, children }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<section className={["panel", className].filter(Boolean).join(" ")}>
|
||||||
|
{(title || actions) && (
|
||||||
|
<header className="panel__header">
|
||||||
|
{title ? <h2 className="panel__title">{title}</h2> : <span />}
|
||||||
|
{actions}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className={flush ? undefined : "panel__body"}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
dashboard/src/components/ui/Spinner.tsx
Normal file
14
dashboard/src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
interface SpinnerProps {
|
||||||
|
/** Optional caption rendered under the ring. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indeterminate loading ring with an optional label. */
|
||||||
|
export function Spinner({ label }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div className="spinner" role="status" aria-live="polite">
|
||||||
|
<span className="spinner__ring" aria-hidden="true" />
|
||||||
|
{label && <span>{label}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
dashboard/src/components/ui/States.tsx
Normal file
30
dashboard/src/components/ui/States.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
title: string;
|
||||||
|
message?: ReactNode;
|
||||||
|
/** Optional action (e.g. a retry button). */
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral "nothing here" placeholder. */
|
||||||
|
export function EmptyState({ title, message, action }: StateProps) {
|
||||||
|
return (
|
||||||
|
<div className="state">
|
||||||
|
<div className="state__title">{title}</div>
|
||||||
|
{message && <div className="state__msg">{message}</div>}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error placeholder — surfaces a failure instead of silently empty. */
|
||||||
|
export function ErrorState({ title, message, action }: StateProps) {
|
||||||
|
return (
|
||||||
|
<div className="state state--error" role="alert">
|
||||||
|
<div className="state__title">{title}</div>
|
||||||
|
{message && <div className="state__msg">{message}</div>}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
dashboard/src/components/ui/StatusDot.tsx
Normal file
24
dashboard/src/components/ui/StatusDot.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { StatusTone } from "./status";
|
||||||
|
|
||||||
|
interface StatusDotProps {
|
||||||
|
tone: StatusTone;
|
||||||
|
/** Accessible label describing what the dot represents. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small colored status dot. `warn` pulses (consent-pending language). When a
|
||||||
|
* `label` is given it is an accessible image; without one (e.g. paired with a
|
||||||
|
* visible label inside a Badge) it is decorative and hidden from assistive tech.
|
||||||
|
*/
|
||||||
|
export function StatusDot({ tone, label }: StatusDotProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`statusdot statusdot--${tone}`}
|
||||||
|
role={label ? "img" : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
aria-hidden={label ? undefined : true}
|
||||||
|
title={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
dashboard/src/components/ui/Table.tsx
Normal file
95
dashboard/src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import "./table.css";
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
/** Unique column key. */
|
||||||
|
key: string;
|
||||||
|
/** Header label. Omit for the status / actions rails. */
|
||||||
|
header?: ReactNode;
|
||||||
|
/** Cell renderer. */
|
||||||
|
render: (row: T) => ReactNode;
|
||||||
|
/** Extra class on the <td> (e.g. dt__status, dt__actions). */
|
||||||
|
cellClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableProps<T> {
|
||||||
|
columns: Column<T>[];
|
||||||
|
rows: T[];
|
||||||
|
rowKey: (row: T) => string;
|
||||||
|
/** Optional per-row activation (opens detail). Bound to click, Enter, Space. */
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Accessible label for the row's primary activation, e.g. the hostname. */
|
||||||
|
rowLabel?: (row: T) => string;
|
||||||
|
/** Cap the staggered fade-in so large lists don't crawl in. */
|
||||||
|
maxStaggerRows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dense, console-style data table. Sticky header, hover highlight, hover-
|
||||||
|
* revealed row actions, and a staggered fade-in on mount (capped so big lists
|
||||||
|
* appear promptly). Column-driven so callers compose cells declaratively.
|
||||||
|
*/
|
||||||
|
export function Table<T>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
onRowClick,
|
||||||
|
rowLabel,
|
||||||
|
maxStaggerRows = 14,
|
||||||
|
}: TableProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className="dt-wrap">
|
||||||
|
<table className="dt">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<th key={c.key} className={c.cellClass}>
|
||||||
|
{c.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const delay = i < maxStaggerRows ? `${i * 22}ms` : "0ms";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={rowKey(row)}
|
||||||
|
style={{
|
||||||
|
animationDelay: delay,
|
||||||
|
cursor: onRowClick ? "pointer" : undefined,
|
||||||
|
}}
|
||||||
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
tabIndex={onRowClick ? 0 : undefined}
|
||||||
|
aria-label={
|
||||||
|
onRowClick && rowLabel
|
||||||
|
? `Open detail for ${rowLabel(row)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onKeyDown={
|
||||||
|
onRowClick
|
||||||
|
? (e) => {
|
||||||
|
// Activate on Enter or Space, the standard for a
|
||||||
|
// button-like row. Space must not scroll the page.
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onRowClick(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<td key={c.key} className={c.cellClass}>
|
||||||
|
{c.render(row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
dashboard/src/components/ui/TableSkeleton.tsx
Normal file
54
dashboard/src/components/ui/TableSkeleton.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
interface TableSkeletonProps {
|
||||||
|
/** Header labels, rendered in the sticky head so columns line up. */
|
||||||
|
headers: string[];
|
||||||
|
/** Number of placeholder rows. */
|
||||||
|
rows?: number;
|
||||||
|
/** Per-column placeholder bar widths (CSS lengths). Falls back to a default. */
|
||||||
|
widths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton table that mirrors the real table's layout while data loads. Shows
|
||||||
|
* the column structure so the page does not jump when rows arrive, and reads as
|
||||||
|
* progress without a blocking spinner.
|
||||||
|
*/
|
||||||
|
export function TableSkeleton({
|
||||||
|
headers,
|
||||||
|
rows = 8,
|
||||||
|
widths = [],
|
||||||
|
}: TableSkeletonProps) {
|
||||||
|
const colWidths =
|
||||||
|
widths.length === headers.length
|
||||||
|
? widths
|
||||||
|
: headers.map((_, i) => (i === 0 ? "8px" : `${60 + ((i * 23) % 40)}%`));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt-wrap" aria-hidden="true">
|
||||||
|
<table className="dt">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{headers.map((h, i) => (
|
||||||
|
<th key={i} className={i === 0 ? "dt__status" : undefined}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
|
<tr key={r}>
|
||||||
|
{headers.map((_, c) => (
|
||||||
|
<td key={c} className={c === 0 ? "dt__status" : undefined}>
|
||||||
|
<span
|
||||||
|
className={`dt__skel${c === 0 ? " dt__skel--dot" : ""}`}
|
||||||
|
style={c === 0 ? undefined : { width: colWidths[c] }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
dashboard/src/components/ui/dialogStack.ts
Normal file
28
dashboard/src/components/ui/dialogStack.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// A tiny module-level stack so only the topmost open dialog (Modal or Drawer)
|
||||||
|
// reacts to Escape and owns the background `inert` toggle. This keeps stacked
|
||||||
|
// dialogs (e.g. a confirm on top of a management modal) from all closing at once.
|
||||||
|
|
||||||
|
const stack: symbol[] = [];
|
||||||
|
|
||||||
|
/** Push a dialog onto the stack. Returns its token. */
|
||||||
|
export function pushDialog(): symbol {
|
||||||
|
const token = Symbol("dialog");
|
||||||
|
stack.push(token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a dialog from the stack by token. */
|
||||||
|
export function popDialog(token: symbol): void {
|
||||||
|
const i = stack.lastIndexOf(token);
|
||||||
|
if (i !== -1) stack.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `token` is the topmost open dialog. */
|
||||||
|
export function isTopDialog(token: symbol): boolean {
|
||||||
|
return stack.length > 0 && stack[stack.length - 1] === token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when any dialog is open. */
|
||||||
|
export function hasOpenDialog(): boolean {
|
||||||
|
return stack.length > 0;
|
||||||
|
}
|
||||||
137
dashboard/src/components/ui/status.ts
Normal file
137
dashboard/src/components/ui/status.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Central status-language mapping. Every status indicator in the app resolves
|
||||||
|
// through here so the dot color + label vocabulary stays consistent:
|
||||||
|
// ok = online / granted / success -> --ok (green)
|
||||||
|
// warn = pending (gets the consent pulse) -> --warn (amber)
|
||||||
|
// bad = denied / offline / error -> --bad (red)
|
||||||
|
// neutral = not_required / unknown -> --neutral (slate)
|
||||||
|
|
||||||
|
export type StatusTone = "ok" | "warn" | "bad" | "neutral";
|
||||||
|
|
||||||
|
/** Badge tones available to features (StatusTone plus the brand `accent`). */
|
||||||
|
export type BadgeTone = StatusTone | "accent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a user role to a badge tone. `admin` is the elevated, distinct tone and
|
||||||
|
* gets the brand `accent` so it reads as "privileged" at a glance; `operator`
|
||||||
|
* is a normal active role (`ok`); `viewer` is the least-privileged, muted
|
||||||
|
* (`neutral`). An unknown role falls back to `neutral`.
|
||||||
|
*/
|
||||||
|
export function roleTone(role: string): BadgeTone {
|
||||||
|
switch (role) {
|
||||||
|
case "admin":
|
||||||
|
return "accent";
|
||||||
|
case "operator":
|
||||||
|
return "ok";
|
||||||
|
case "viewer":
|
||||||
|
default:
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title-case label for a role; passes unknown roles through verbatim. */
|
||||||
|
export function roleLabel(role: string): string {
|
||||||
|
switch (role) {
|
||||||
|
case "admin":
|
||||||
|
return "Admin";
|
||||||
|
case "operator":
|
||||||
|
return "Operator";
|
||||||
|
case "viewer":
|
||||||
|
return "Viewer";
|
||||||
|
default:
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a user's enabled flag to a status tone. An enabled account is healthy
|
||||||
|
* (`ok`); a disabled one is a deliberate block and reads as `bad` so it stands
|
||||||
|
* out in the table (a disabled user is an exception worth seeing).
|
||||||
|
*/
|
||||||
|
export function userStatusTone(enabled: boolean): StatusTone {
|
||||||
|
return enabled ? "ok" : "bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a user's enabled flag. */
|
||||||
|
export function userStatusLabel(enabled: boolean): string {
|
||||||
|
return enabled ? "Active" : "Disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a machine `status` string to a tone. */
|
||||||
|
export function machineTone(status: string): StatusTone {
|
||||||
|
return status === "online" ? "ok" : "bad";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an attended-consent state to a tone. `pending` pulses. */
|
||||||
|
export function consentTone(state: string): StatusTone {
|
||||||
|
switch (state) {
|
||||||
|
case "granted":
|
||||||
|
return "ok";
|
||||||
|
case "pending":
|
||||||
|
return "warn";
|
||||||
|
case "denied":
|
||||||
|
return "bad";
|
||||||
|
case "not_required":
|
||||||
|
default:
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human label for an attended-consent state. Kept here next to `consentTone`
|
||||||
|
* so the color and the words for a given state never drift apart. `pending` is
|
||||||
|
* phrased as the active wait it represents (a tech is blocked on it).
|
||||||
|
*/
|
||||||
|
export function consentLabel(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case "granted":
|
||||||
|
return "Granted";
|
||||||
|
case "pending":
|
||||||
|
return "Awaiting consent";
|
||||||
|
case "denied":
|
||||||
|
return "Denied";
|
||||||
|
case "not_required":
|
||||||
|
return "Not required";
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a support-code lifecycle status to a tone. `pending` is the live,
|
||||||
|
* waiting-to-be-redeemed state and gets the same `warn` pulse the
|
||||||
|
* awaiting-consent state uses — it reads as "active, watch this". A redeemed
|
||||||
|
* (`connected`) code is a positive terminal-for-the-tech outcome -> `ok`.
|
||||||
|
* `completed`/`cancelled` are spent and read as muted `neutral`.
|
||||||
|
*/
|
||||||
|
export function codeTone(status: string): StatusTone {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "warn";
|
||||||
|
case "connected":
|
||||||
|
return "ok";
|
||||||
|
case "completed":
|
||||||
|
case "cancelled":
|
||||||
|
default:
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human label for a support-code status. Next to `codeTone` so wording and
|
||||||
|
* color never drift. `pending` is phrased as the active wait (the tech is
|
||||||
|
* watching for the end user to redeem it).
|
||||||
|
*/
|
||||||
|
export function codeLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "Awaiting redeem";
|
||||||
|
case "connected":
|
||||||
|
return "Redeemed";
|
||||||
|
case "completed":
|
||||||
|
return "Completed";
|
||||||
|
case "cancelled":
|
||||||
|
return "Cancelled";
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
dashboard/src/components/ui/table.css
Normal file
196
dashboard/src/components/ui/table.css
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/* ============================================================ Data table === */
|
||||||
|
.dt {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.dt thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--panel-2);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 36px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dt tbody td {
|
||||||
|
padding: 0 14px;
|
||||||
|
height: var(--row-h);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dt tbody tr {
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
animation: gc-row-in var(--dur) var(--ease) both;
|
||||||
|
}
|
||||||
|
.dt tbody tr:hover {
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.dt tbody tr:hover .dt__rowactions,
|
||||||
|
.dt tbody tr:focus-within .dt__rowactions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
/* Keyboard focus on the row itself reads as a clear inset ring. */
|
||||||
|
.dt tbody tr:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: var(--panel-2);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status-dot column — fixed narrow left rail. */
|
||||||
|
.dt__status {
|
||||||
|
width: 30px;
|
||||||
|
padding-left: 16px !important;
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection column — fixed narrow rail to the left of the status dot. */
|
||||||
|
.dt__select {
|
||||||
|
width: 34px;
|
||||||
|
padding-left: 16px !important;
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
/* Generous hit target around the checkbox; the label also stops row-click. */
|
||||||
|
.dt__checkwrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
margin: -6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dt__check {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dt__check:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cell affordances. */
|
||||||
|
.dt__mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.dt__strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.dt__muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right-aligned row actions. Dimmed at rest, full on row hover/focus, but
|
||||||
|
always present and reachable by keyboard and touch (never pointer-events:none,
|
||||||
|
which would hide them from Tab and tap). */
|
||||||
|
.dt__actions {
|
||||||
|
width: 1%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.dt__rowactions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
/* When any action button is keyboard-focused, surface the whole group. */
|
||||||
|
.dt__rowactions:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
/* Touch devices have no hover: keep actions fully legible at all times. */
|
||||||
|
.dt__rowactions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-wrap {
|
||||||
|
max-height: calc(100vh - 230px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading rows: preview the table shape instead of a bare spinner. */
|
||||||
|
.dt__skel {
|
||||||
|
display: inline-block;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--border) 0%,
|
||||||
|
var(--border-strong) 50%,
|
||||||
|
var(--border) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: gc-shimmer 1.4s var(--ease) infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.dt__skel--dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search / toolbar above the table. */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.searchbox {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 320px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.searchbox__icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 11px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-faint);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.searchbox .input {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 34px;
|
||||||
|
}
|
||||||
|
.toolbar__count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.toolbar__count .mono {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bulk-action bar: replaces the count readout when rows are selected. */
|
||||||
|
.bulkbar {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.bulkbar__count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.bulkbar__count .mono {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
25
dashboard/src/components/ui/toast-context.ts
Normal file
25
dashboard/src/components/ui/toast-context.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
export type ToastTone = "success" | "error" | "info";
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
tone: ToastTone;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastApi {
|
||||||
|
success: (title: string, message?: string) => void;
|
||||||
|
error: (title: string, message?: string) => void;
|
||||||
|
info: (title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToastContext = createContext<ToastApi | null>(null);
|
||||||
|
|
||||||
|
/** Imperative toast notifications. Auto-dismiss after a few seconds. */
|
||||||
|
export function useToast(): ToastApi {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast must be used within <ToastProvider>");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
116
dashboard/src/components/ui/toast.tsx
Normal file
116
dashboard/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
ToastContext,
|
||||||
|
type ToastApi,
|
||||||
|
type ToastItem,
|
||||||
|
type ToastTone,
|
||||||
|
} from "./toast-context";
|
||||||
|
|
||||||
|
const AUTO_DISMISS_MS = 4500;
|
||||||
|
|
||||||
|
/** Mounts the toast stack and provides the imperative toast API to descendants. */
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
const nextId = useRef(1);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(tone: ToastTone, title: string, message?: string) => {
|
||||||
|
const id = nextId.current++;
|
||||||
|
setToasts((prev) => [...prev, { id, tone, title, message }]);
|
||||||
|
window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
|
||||||
|
},
|
||||||
|
[dismiss],
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = useMemo<ToastApi>(
|
||||||
|
() => ({
|
||||||
|
success: (title, message) => push("success", title, message),
|
||||||
|
error: (title, message) => push("error", title, message),
|
||||||
|
info: (title, message) => push("info", title, message),
|
||||||
|
}),
|
||||||
|
[push],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={api}>
|
||||||
|
{children}
|
||||||
|
{/* Polite region for success/info; errors below are assertive. */}
|
||||||
|
<div className="toast-stack">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`toast toast--${t.tone}`}
|
||||||
|
role={t.tone === "error" ? "alert" : "status"}
|
||||||
|
aria-live={t.tone === "error" ? "assertive" : "polite"}
|
||||||
|
>
|
||||||
|
<span className={`toast__icon toast__icon--${t.tone}`} aria-hidden="true">
|
||||||
|
<ToastGlyph tone={t.tone} />
|
||||||
|
</span>
|
||||||
|
<div className="toast__body">
|
||||||
|
<div className="toast__title">{t.title}</div>
|
||||||
|
{t.message && <div className="toast__msg">{t.message}</div>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="iconbtn"
|
||||||
|
onClick={() => dismiss(t.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 6l12 12M18 6 6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastGlyph({ tone }: { tone: ToastTone }) {
|
||||||
|
const common = {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeLinecap: "round" as const,
|
||||||
|
strokeLinejoin: "round" as const,
|
||||||
|
};
|
||||||
|
if (tone === "success") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tone === "error") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 8v5M12 16h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 11v5M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
454
dashboard/src/components/ui/ui.css
Normal file
454
dashboard/src/components/ui/ui.css
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
/* ------------------------------------------------------------------ Button */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
border-color var(--dur-fast) var(--ease),
|
||||||
|
color var(--dur-fast) var(--ease),
|
||||||
|
opacity var(--dur-fast) var(--ease);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn--sm {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
.btn--primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-press);
|
||||||
|
}
|
||||||
|
.btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn--ghost:hover:not(:disabled) {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.btn--danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--bad-line);
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
.btn--danger:hover:not(:disabled) {
|
||||||
|
background: var(--bad-soft);
|
||||||
|
border-color: var(--bad);
|
||||||
|
}
|
||||||
|
.btn--block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.btn__spin {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
opacity: 0.85;
|
||||||
|
animation: gc-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- Status dot/badge */
|
||||||
|
.statusdot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.statusdot--ok {
|
||||||
|
background: var(--ok);
|
||||||
|
box-shadow: 0 0 6px var(--ok-soft);
|
||||||
|
}
|
||||||
|
.statusdot--warn {
|
||||||
|
background: var(--warn);
|
||||||
|
animation: gc-pulse 1.6s var(--ease) infinite;
|
||||||
|
}
|
||||||
|
.statusdot--bad {
|
||||||
|
background: var(--bad);
|
||||||
|
}
|
||||||
|
.statusdot--neutral {
|
||||||
|
background: var(--neutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.badge--ok {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.badge--warn {
|
||||||
|
color: var(--warn);
|
||||||
|
background: var(--warn-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.badge--bad {
|
||||||
|
color: var(--bad);
|
||||||
|
background: var(--bad-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.badge--neutral {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--neutral-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.badge--accent {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Card / Panel */
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
}
|
||||||
|
.panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.panel__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.panel__body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Spinner */
|
||||||
|
.spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.spinner__ring {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border-strong);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: gc-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes gc-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------- Empty / Error states */
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.state__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.state__msg {
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.state--error .state__title {
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- Modal */
|
||||||
|
/* Shared icon button (modal close, toast dismiss). 28px square hit target. */
|
||||||
|
.iconbtn {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--dur-fast) var(--ease),
|
||||||
|
background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.iconbtn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.iconbtn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: oklch(15% 0.01 var(--brand-hue) / 0.66);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
animation: gc-fade 120ms var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes gc-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
animation: gc-pop 140ms var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes gc-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modal--wide {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.modal__body {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.modal__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- Drawer */
|
||||||
|
.drawer__scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: oklch(15% 0.01 var(--brand-hue) / 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
z-index: var(--z-drawer);
|
||||||
|
animation: gc-fade 120ms var(--ease);
|
||||||
|
}
|
||||||
|
.drawer {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel);
|
||||||
|
border-left: 1px solid var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
animation: gc-drawer-in var(--dur-panel) var(--ease-out);
|
||||||
|
}
|
||||||
|
.drawer__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.drawer__titles {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.drawer__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.drawer__subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.drawer__body {
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.drawer__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- Toast */
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: var(--z-toast);
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
font-size: 13px;
|
||||||
|
animation: gc-toast-in 180ms var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes gc-toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toast__icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.toast__icon--success {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
}
|
||||||
|
.toast__icon--error {
|
||||||
|
color: var(--bad);
|
||||||
|
background: var(--bad-soft);
|
||||||
|
}
|
||||||
|
.toast__icon--info {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.toast__body {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.toast__title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.toast__msg {
|
||||||
|
color: var(--text-muted);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- Input */
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.field__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease),
|
||||||
|
box-shadow var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.input--mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
106
dashboard/src/features/auth/LoginPage.tsx
Normal file
106
dashboard/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { Field, Input } from "../../components/ui/Input";
|
||||||
|
import "./login.css";
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from?: { pathname: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { user, login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Already authenticated — bounce to the app.
|
||||||
|
if (user) return <Navigate to="/machines" replace />;
|
||||||
|
|
||||||
|
const from = (location.state as LocationState | null)?.from?.pathname ?? "/machines";
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setError(
|
||||||
|
err.status === 401
|
||||||
|
? "Invalid username or password."
|
||||||
|
: err.message,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError("Could not sign in. Please try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<div className="login__scanlines" aria-hidden="true" />
|
||||||
|
<form className="login__card" onSubmit={handleSubmit}>
|
||||||
|
<div className="login__brand">
|
||||||
|
<span className="login__logo" aria-hidden="true">
|
||||||
|
GC
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="login__title">GuruConnect</div>
|
||||||
|
<div className="login__sub">Operator Console</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Username" htmlFor="username">
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Password" htmlFor="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="login__error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
block
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!username || !password}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="login__foot mono">GuruConnect · Operator Console</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
dashboard/src/features/auth/login.css
Normal file
91
dashboard/src/features/auth/login.css
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
.login {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
1100px 520px at 50% -10%,
|
||||||
|
oklch(78% 0.13 184 / 0.08),
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faint console scanlines for control-room texture. */
|
||||||
|
.login__scanlines {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
oklch(93% 0.008 var(--brand-hue) / 0.016) 0px,
|
||||||
|
oklch(93% 0.008 var(--brand-hue) / 0.016) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 3px
|
||||||
|
);
|
||||||
|
mask-image: radial-gradient(70% 60% at 50% 40%, black, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 28px 26px 22px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.login__logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-press));
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--accent-ink);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.login__title {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.login__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--bad);
|
||||||
|
background: var(--bad-soft);
|
||||||
|
border: 1px solid var(--bad-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__foot {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
61
dashboard/src/features/codes/CancelCodeDialog.tsx
Normal file
61
dashboard/src/features/codes/CancelCodeDialog.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import type { SupportCode } from "../../api/types";
|
||||||
|
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
|
||||||
|
import { useToast } from "../../components/ui/toast-context";
|
||||||
|
import { useCancelCode } from "./hooks";
|
||||||
|
|
||||||
|
interface CancelCodeDialogProps {
|
||||||
|
/** The code to cancel, or null when the dialog is closed. */
|
||||||
|
code: SupportCode | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm + cancel a support code. Cancelling is consequential: a code cannot
|
||||||
|
* be un-cancelled, and if it has not been redeemed yet the end user can no
|
||||||
|
* longer use it. We confirm first, then invalidate the list so the row drops.
|
||||||
|
*/
|
||||||
|
export function CancelCodeDialog({ code, onClose }: CancelCodeDialogProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const cancel = useCancelCode();
|
||||||
|
const open = code != null;
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!code) return;
|
||||||
|
cancel.mutate(code.code, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Code cancelled", `${code.code} can no longer be used.`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
"Could not cancel code",
|
||||||
|
err instanceof ApiError ? err.message : "The relay did not respond.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={open}
|
||||||
|
title="Cancel this code?"
|
||||||
|
danger
|
||||||
|
busy={cancel.isPending}
|
||||||
|
confirmLabel="Cancel code"
|
||||||
|
cancelLabel="Keep it"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onCancel={onClose}
|
||||||
|
body={
|
||||||
|
code ? (
|
||||||
|
<p>
|
||||||
|
This permanently revokes <strong className="mono">{code.code}</strong>.{" "}
|
||||||
|
{code.status === "connected"
|
||||||
|
? "An attended session is bound to it; cancelling ends that connection."
|
||||||
|
: "The end user will not be able to redeem it. This cannot be undone."}
|
||||||
|
</p>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
dashboard/src/features/codes/GenerateCodeModal.tsx
Normal file
153
dashboard/src/features/codes/GenerateCodeModal.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import type { SupportCode } from "../../api/types";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { Modal } from "../../components/ui/Modal";
|
||||||
|
import { Spinner } from "../../components/ui/Spinner";
|
||||||
|
import { ErrorState } from "../../components/ui/States";
|
||||||
|
import { CopyIcon } from "../../components/layout/icons";
|
||||||
|
import { useClipboard } from "../../lib/useClipboard";
|
||||||
|
import { useGenerateCode } from "./hooks";
|
||||||
|
import "./codes.css";
|
||||||
|
|
||||||
|
interface GenerateCodeModalProps {
|
||||||
|
/** Whether the generate dialog is open. */
|
||||||
|
open: boolean;
|
||||||
|
/** Operator name to attribute the code to (server stamps `created_by`). */
|
||||||
|
technicianName?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate-a-code flow. Opening the dialog mints a fresh code immediately, then
|
||||||
|
* reveals it large in JetBrains Mono so the tech can read it to the end user
|
||||||
|
* over the phone. The code is the single high-signal element on this surface;
|
||||||
|
* everything else is secondary. There is no per-second countdown — the
|
||||||
|
* SupportCode the API returns has no `expires_at`, and a redeem/cancel surfaces
|
||||||
|
* through the table's poll, so a timer here would be both impossible to source
|
||||||
|
* accurately and a needless render storm.
|
||||||
|
*/
|
||||||
|
export function GenerateCodeModal({
|
||||||
|
open,
|
||||||
|
technicianName,
|
||||||
|
onClose,
|
||||||
|
}: GenerateCodeModalProps) {
|
||||||
|
const generate = useGenerateCode();
|
||||||
|
const { copied, copy } = useClipboard();
|
||||||
|
const [result, setResult] = useState<SupportCode | null>(null);
|
||||||
|
// Minting a code is a durable single-use side effect. Guard it behind a ref so
|
||||||
|
// StrictMode's mount->cleanup->mount double-invoke can't fire two real POSTs
|
||||||
|
// per open; re-arm on close so the next open mints fresh.
|
||||||
|
const mintedFor = useRef(false);
|
||||||
|
|
||||||
|
// Mint once when the dialog opens; reset on close so a re-open mints a fresh
|
||||||
|
// code. Minting in an effect (not on a button click) lets the dialog own the
|
||||||
|
// loading/error/success states cleanly, mirroring JoinSessionModal.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setResult(null);
|
||||||
|
generate.reset();
|
||||||
|
mintedFor.current = false; // re-arm for the next open
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mintedFor.current) return; // StrictMode remount: already minted
|
||||||
|
mintedFor.current = true;
|
||||||
|
let cancelled = false;
|
||||||
|
generate
|
||||||
|
.mutateAsync({ technician_name: technicianName })
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setResult(res);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Surfaced via generate.isError below.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// Mint exactly once per open. The mutation object is stable.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Support code"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<Button variant="primary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{generate.isPending && !result ? (
|
||||||
|
<div className="codegen__loading">
|
||||||
|
<Spinner label="Generating code…" />
|
||||||
|
</div>
|
||||||
|
) : generate.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
title="Could not generate a code"
|
||||||
|
message={
|
||||||
|
generate.error instanceof ApiError
|
||||||
|
? generate.error.message
|
||||||
|
: "The relay did not return a code. Check the relay status, then try again."
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() =>
|
||||||
|
void generate
|
||||||
|
.mutateAsync({ technician_name: technicianName })
|
||||||
|
.then(setResult)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : result ? (
|
||||||
|
<>
|
||||||
|
<p className="codegen__lede">
|
||||||
|
Read this code to the end user. It starts an attended support session
|
||||||
|
and can be used once.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="codegen__codewrap">
|
||||||
|
<output className="codegen__code" aria-label={`Support code ${spell(result.code)}`}>
|
||||||
|
{result.code}
|
||||||
|
</output>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void copy(result.code)}
|
||||||
|
aria-label={copied ? "Code copied to clipboard" : "Copy code to clipboard"}
|
||||||
|
>
|
||||||
|
<CopyIcon width={14} height={14} />
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="codegen__hint">
|
||||||
|
It stays active until the user redeems it or you cancel it. Once
|
||||||
|
redeemed it cannot be used again.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spell a grouped code out for the screen-reader label so it is announced
|
||||||
|
* character by character ("K, 7, P, ...") instead of as a mangled word. The
|
||||||
|
* visible code stays the compact `XXX-XXX-XXX` form.
|
||||||
|
*/
|
||||||
|
function spell(code: string): string {
|
||||||
|
return code
|
||||||
|
.replace(/-/g, " ")
|
||||||
|
.split("")
|
||||||
|
.filter((c) => c !== " ")
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
249
dashboard/src/features/codes/SupportCodesPage.tsx
Normal file
249
dashboard/src/features/codes/SupportCodesPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import type { SupportCode } from "../../api/types";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { PageHeader } from "../../components/layout/PageHeader";
|
||||||
|
import { PlusIcon, RefreshIcon, SearchIcon, TrashIcon } from "../../components/layout/icons";
|
||||||
|
import { Badge } from "../../components/ui/Badge";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { Input } from "../../components/ui/Input";
|
||||||
|
import { Panel } from "../../components/ui/Panel";
|
||||||
|
import { EmptyState, ErrorState } from "../../components/ui/States";
|
||||||
|
import { codeLabel, codeTone } from "../../components/ui/status";
|
||||||
|
import { Table, type Column } from "../../components/ui/Table";
|
||||||
|
import { TableSkeleton } from "../../components/ui/TableSkeleton";
|
||||||
|
import { absoluteTime, relativeTime } from "../../lib/time";
|
||||||
|
import { CancelCodeDialog } from "./CancelCodeDialog";
|
||||||
|
import { GenerateCodeModal } from "./GenerateCodeModal";
|
||||||
|
import { useSupportCodes } from "./hooks";
|
||||||
|
import "./codes.css";
|
||||||
|
|
||||||
|
/** A code is still cancellable only while it is pending or connected. */
|
||||||
|
function canCancel(status: string): boolean {
|
||||||
|
return status === "pending" || status === "connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportCodesPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const codesQuery = useSupportCodes();
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [cancelFor, setCancelFor] = useState<SupportCode | null>(null);
|
||||||
|
|
||||||
|
const { data } = codesQuery;
|
||||||
|
const codes = useMemo(() => data ?? [], [data]);
|
||||||
|
|
||||||
|
// Newest first: the in-memory map the server returns has no guaranteed order,
|
||||||
|
// and the code a tech just generated should be at the top where they expect
|
||||||
|
// it.
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...codes].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||||
|
),
|
||||||
|
[codes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (!q) return sorted;
|
||||||
|
return sorted.filter(
|
||||||
|
(c) =>
|
||||||
|
c.code.toLowerCase().includes(q) ||
|
||||||
|
c.created_by.toLowerCase().includes(q) ||
|
||||||
|
(c.client_machine?.toLowerCase().includes(q) ?? false),
|
||||||
|
);
|
||||||
|
}, [sorted, filter]);
|
||||||
|
|
||||||
|
const pendingCount = useMemo(
|
||||||
|
() => codes.filter((c) => c.status === "pending").length,
|
||||||
|
[codes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: Column<SupportCode>[] = [
|
||||||
|
{
|
||||||
|
key: "code",
|
||||||
|
header: "Code",
|
||||||
|
render: (c) => <span className="cdt__code">{c.code}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
render: (c) => (
|
||||||
|
<Badge tone={codeTone(c.status)} dot>
|
||||||
|
{codeLabel(c.status)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bound",
|
||||||
|
header: "Bound to",
|
||||||
|
render: (c) =>
|
||||||
|
c.client_machine || c.client_name ? (
|
||||||
|
<div className="cdt__bound">
|
||||||
|
<span className="dt__strong">
|
||||||
|
{c.client_machine ?? c.client_name}
|
||||||
|
</span>
|
||||||
|
{c.client_machine && c.client_name && (
|
||||||
|
<span className="cdt__boundsub">{c.client_name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="dt__muted">Not redeemed</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_by",
|
||||||
|
header: "Created by",
|
||||||
|
render: (c) => <span className="dt__strong">{c.created_by}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
header: "Created",
|
||||||
|
render: (c) => (
|
||||||
|
<span className="dt__mono" title={absoluteTime(c.created_at)}>
|
||||||
|
{relativeTime(c.created_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "",
|
||||||
|
cellClass: "dt__actions",
|
||||||
|
render: (c) => {
|
||||||
|
const cancellable = canCancel(c.status);
|
||||||
|
return (
|
||||||
|
<span className="dt__rowactions" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCancelFor(c)}
|
||||||
|
disabled={!cancellable}
|
||||||
|
title={
|
||||||
|
cancellable
|
||||||
|
? undefined
|
||||||
|
: `${codeLabel(c.status)} codes cannot be cancelled`
|
||||||
|
}
|
||||||
|
aria-label={`Cancel code ${c.code}`}
|
||||||
|
>
|
||||||
|
<TrashIcon width={14} height={14} />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Support codes"
|
||||||
|
subtitle="One-time codes for attended support. Generate a code, read it to the end user, and they redeem it to start a session."
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void codesQuery.refetch()}
|
||||||
|
loading={codesQuery.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshIcon width={15} height={15} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => setGenerating(true)}>
|
||||||
|
<PlusIcon width={15} height={15} />
|
||||||
|
Generate code
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Panel flush>
|
||||||
|
<div style={{ padding: "14px 16px 0" }}>
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="searchbox">
|
||||||
|
<span className="searchbox__icon">
|
||||||
|
<SearchIcon width={15} height={15} />
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by code, machine, or creator"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
aria-label="Filter support codes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar__count">
|
||||||
|
<span className="mono">{pendingCount}</span> awaiting redeem ·{" "}
|
||||||
|
<span className="mono">{codes.length}</span> active
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{codesQuery.isLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="visually-hidden" role="status">
|
||||||
|
Loading support codes
|
||||||
|
</span>
|
||||||
|
<TableSkeleton
|
||||||
|
headers={[
|
||||||
|
"Code",
|
||||||
|
"Status",
|
||||||
|
"Bound to",
|
||||||
|
"Created by",
|
||||||
|
"Created",
|
||||||
|
"",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : codesQuery.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
title="Could not load support codes"
|
||||||
|
message={
|
||||||
|
codesQuery.error instanceof ApiError
|
||||||
|
? codesQuery.error.message
|
||||||
|
: "The GuruConnect relay did not respond. Check the relay status, then retry."
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button variant="primary" onClick={() => void codesQuery.refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
filter ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No matching codes"
|
||||||
|
message={`Nothing matches "${filter}". Clear the filter to see every active code.`}
|
||||||
|
action={
|
||||||
|
<Button variant="ghost" onClick={() => setFilter("")}>
|
||||||
|
Clear filter
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No active codes"
|
||||||
|
message="Generate a code, read it to the end user over the phone, and they redeem it to start an attended session. Each code works once."
|
||||||
|
action={
|
||||||
|
<Button variant="primary" onClick={() => setGenerating(true)}>
|
||||||
|
<PlusIcon width={15} height={15} />
|
||||||
|
Generate code
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Table columns={columns} rows={filtered} rowKey={(c) => c.code} />
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<GenerateCodeModal
|
||||||
|
open={generating}
|
||||||
|
technicianName={user?.username}
|
||||||
|
onClose={() => setGenerating(false)}
|
||||||
|
/>
|
||||||
|
<CancelCodeDialog code={cancelFor} onClose={() => setCancelFor(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
dashboard/src/features/codes/codes.css
Normal file
80
dashboard/src/features/codes/codes.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/* ===================================================== Support codes table */
|
||||||
|
|
||||||
|
/* The code in the row: mono, accent, slightly larger than body so it reads as
|
||||||
|
the identifier it is. Tracks the table's mono idiom but with brand color. */
|
||||||
|
.cdt__code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-feature-settings: "ss01", "zero";
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bound-to cell: machine over a dimmer client name, the same two-line idiom
|
||||||
|
the sessions table uses for machine/agent-id. */
|
||||||
|
.cdt__bound {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.cdt__boundsub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================== Generate-code dialog */
|
||||||
|
|
||||||
|
.codegen__loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codegen__lede {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hero: the code itself, large, mono, accent, with a copy button. This is
|
||||||
|
read aloud over the phone, so it is the single dominant element on the
|
||||||
|
surface and is sized for unmistakable legibility. */
|
||||||
|
.codegen__codewrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 22px 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid var(--accent-ring);
|
||||||
|
}
|
||||||
|
.codegen__code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
/* ss01 = stylistic alt; zero = slashed zero. The unambiguous alphabet has no
|
||||||
|
0, but the feature is harmless and keeps mono rendering consistent. */
|
||||||
|
font-feature-settings: "ss01", "zero";
|
||||||
|
font-size: clamp(28px, 7vw, 38px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--accent);
|
||||||
|
user-select: all;
|
||||||
|
/* Never wrap the grouped code across lines — it must read as one token. */
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codegen__hint {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
55
dashboard/src/features/codes/hooks.ts
Normal file
55
dashboard/src/features/codes/hooks.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import * as codesApi from "../../api/codes";
|
||||||
|
import type { CreateCodeRequest } from "../../api/types";
|
||||||
|
|
||||||
|
const CODES_KEY = ["codes"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the active support codes. Polls on a short interval because codes are
|
||||||
|
* short-lived: a `pending` code can be redeemed (-> `connected`) or expire out
|
||||||
|
* of the active set at any moment, and a tech who just read a code aloud is
|
||||||
|
* watching for exactly that transition. The interval is tight (like the
|
||||||
|
* sessions poll) so the redeem shows up on its own without a manual refresh.
|
||||||
|
*/
|
||||||
|
export function useSupportCodes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: CODES_KEY,
|
||||||
|
queryFn: ({ signal }) => codesApi.listCodes(signal),
|
||||||
|
refetchInterval: 7_000,
|
||||||
|
staleTime: 3_500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new support code, then invalidate the list so the new `pending`
|
||||||
|
* code appears in the table. The created code is returned to the caller so the
|
||||||
|
* generate flow can surface it prominently (it is read to the end user).
|
||||||
|
*/
|
||||||
|
export function useGenerateCode() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: CreateCodeRequest) => codesApi.createCode(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: CODES_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel (revoke) a support code, then invalidate the list so the row drops out
|
||||||
|
* of the active set. Cancelling an un-redeemed code is irreversible, so the UI
|
||||||
|
* confirms first; this hook is the action behind that confirmation.
|
||||||
|
*/
|
||||||
|
export function useCancelCode() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (code: string) => codesApi.cancelCode(code),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: CODES_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
112
dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx
Normal file
112
dashboard/src/features/machines/BulkRemoveMachinesDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import type { BulkRemoveItem } from "../../api/types";
|
||||||
|
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
|
||||||
|
import { useToast } from "../../components/ui/toast-context";
|
||||||
|
import { useBulkRemoveMachines } from "./hooks";
|
||||||
|
|
||||||
|
interface BulkRemoveMachinesDialogProps {
|
||||||
|
/** Selected agent_ids to remove, or empty when the dialog is closed. */
|
||||||
|
agentIds: string[];
|
||||||
|
/** Whether the dialog is open. Kept explicit so an empty list can stay open. */
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called after a successful batch so the page can clear its selection. */
|
||||||
|
onRemoved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count outcomes by status for a compact "12 removed, 1 not found" summary. */
|
||||||
|
function summarize(results: BulkRemoveItem[]): string {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const r of results) counts.set(r.status, (counts.get(r.status) ?? 0) + 1);
|
||||||
|
const order = ["removed", "not_found", "invalid", "error"];
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
removed: "removed",
|
||||||
|
not_found: "not found",
|
||||||
|
invalid: "invalid",
|
||||||
|
error: "errored",
|
||||||
|
};
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const status of order) {
|
||||||
|
const n = counts.get(status);
|
||||||
|
if (n) parts.push(`${n} ${labels[status] ?? status}`);
|
||||||
|
}
|
||||||
|
// Surface any unexpected status the server may add in the future.
|
||||||
|
for (const [status, n] of counts) {
|
||||||
|
if (!order.includes(status)) parts.push(`${n} ${status}`);
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm + bulk-remove the selected machines (Task 5). On confirm the selected
|
||||||
|
* agent_ids are purged in one request; the per-id summary the server returns is
|
||||||
|
* surfaced as a toast (e.g. "12 removed, 1 not found") so a partial outcome is
|
||||||
|
* visible rather than silently swallowed.
|
||||||
|
*/
|
||||||
|
export function BulkRemoveMachinesDialog({
|
||||||
|
agentIds,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onRemoved,
|
||||||
|
}: BulkRemoveMachinesDialogProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const bulkRemove = useBulkRemoveMachines();
|
||||||
|
const count = agentIds.length;
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (count === 0) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bulkRemove.mutate(agentIds, {
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const summary = summarize(res.results);
|
||||||
|
if (res.removed === res.requested) {
|
||||||
|
toast.success(
|
||||||
|
`Removed ${res.removed} ${res.removed === 1 ? "machine" : "machines"}`,
|
||||||
|
summary || undefined,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Partial: some ids were not found / invalid. Report as info, not an
|
||||||
|
// error — the requested removals that could happen, did.
|
||||||
|
toast.info(
|
||||||
|
`Removed ${res.removed} of ${res.requested}`,
|
||||||
|
summary || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onRemoved();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
"Could not remove machines",
|
||||||
|
err instanceof ApiError
|
||||||
|
? `${err.message}${err.code ? ` (${err.code})` : ""}`
|
||||||
|
: "The server did not respond. No machines were removed.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={open}
|
||||||
|
title={`Remove ${count} ${count === 1 ? "machine" : "machines"}?`}
|
||||||
|
danger
|
||||||
|
busy={bulkRemove.isPending}
|
||||||
|
confirmLabel={`Remove ${count}`}
|
||||||
|
cancelLabel="Keep machines"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onCancel={onClose}
|
||||||
|
body={
|
||||||
|
<p style={{ marginTop: 0 }}>
|
||||||
|
Remove the {count} selected{" "}
|
||||||
|
{count === 1 ? "machine" : "machines"} from the GuruConnect console.
|
||||||
|
Their live sessions are dropped and the rows disappear from the list.
|
||||||
|
Any that are genuinely still in service re-appear when their agents
|
||||||
|
next check in.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
dashboard/src/features/machines/DeleteMachineDialog.tsx
Normal file
133
dashboard/src/features/machines/DeleteMachineDialog.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import type { Machine } from "../../api/types";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { Modal } from "../../components/ui/Modal";
|
||||||
|
import { useToast } from "../../components/ui/toast-context";
|
||||||
|
import { useDeleteMachine } from "./hooks";
|
||||||
|
|
||||||
|
interface DeleteMachineDialogProps {
|
||||||
|
machine: Machine | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INTENTIONALLY UNWIRED. This legacy per-row delete dialog was superseded by
|
||||||
|
* the admin-only purge Remove (RemoveMachineDialog / BulkRemoveMachinesDialog)
|
||||||
|
* and currently has no caller. It is kept — not deleted — because it is the
|
||||||
|
* only remaining caller pattern for the `uninstall`/`export` machine-delete
|
||||||
|
* params, pending a future "full uninstall/export" admin action that will
|
||||||
|
* re-wire it. Do not treat its lack of references as a wiring bug.
|
||||||
|
*
|
||||||
|
* Destructive machine removal with two options:
|
||||||
|
* - uninstall: also command the agent to uninstall (only meaningful online)
|
||||||
|
* - export: return full history in the delete response before removal
|
||||||
|
*/
|
||||||
|
export function DeleteMachineDialog({ machine, onClose }: DeleteMachineDialogProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const del = useDeleteMachine();
|
||||||
|
const [uninstall, setUninstall] = useState(false);
|
||||||
|
const [exportHistory, setExportHistory] = useState(false);
|
||||||
|
|
||||||
|
// Reset options each time a new machine is targeted.
|
||||||
|
useEffect(() => {
|
||||||
|
if (machine) {
|
||||||
|
setUninstall(false);
|
||||||
|
setExportHistory(false);
|
||||||
|
}
|
||||||
|
}, [machine]);
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (!machine) return;
|
||||||
|
del.mutate(
|
||||||
|
{ agentId: machine.agent_id, params: { uninstall, export: exportHistory } },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (exportHistory && res.history) {
|
||||||
|
downloadHistory(machine.hostname, res.history);
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
"Machine deleted",
|
||||||
|
res.uninstall_sent
|
||||||
|
? "Uninstall command sent to the agent."
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
"Could not delete machine",
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: "The server did not respond. The machine was not deleted.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={machine != null}
|
||||||
|
title="Delete machine"
|
||||||
|
onClose={del.isPending ? () => {} : onClose}
|
||||||
|
dismissable={!del.isPending}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={del.isPending}>
|
||||||
|
Keep machine
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleConfirm} loading={del.isPending}>
|
||||||
|
Delete machine
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p style={{ marginTop: 0 }}>
|
||||||
|
Permanently delete{" "}
|
||||||
|
<span className="mono">{machine?.hostname}</span> from GuruConnect,
|
||||||
|
including its registration and full history. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="optline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={uninstall}
|
||||||
|
onChange={(e) => setUninstall(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Also uninstall the agent
|
||||||
|
{machine && machine.status !== "online" && (
|
||||||
|
<em className="optline__note">
|
||||||
|
{" "}
|
||||||
|
(offline now; queued until the agent next checks in)
|
||||||
|
</em>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="optline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportHistory}
|
||||||
|
onChange={(e) => setExportHistory(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Export full history (download JSON) before removal</span>
|
||||||
|
</label>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadHistory(hostname: string, history: unknown) {
|
||||||
|
const blob = new Blob([JSON.stringify(history, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${hostname}-history-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
72
dashboard/src/features/machines/KeyRevealModal.tsx
Normal file
72
dashboard/src/features/machines/KeyRevealModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { Modal } from "../../components/ui/Modal";
|
||||||
|
import { CopyIcon } from "../../components/layout/icons";
|
||||||
|
import { useClipboard } from "../../lib/useClipboard";
|
||||||
|
|
||||||
|
interface KeyRevealModalProps {
|
||||||
|
/** The plaintext `cak_...` key, or null when closed. */
|
||||||
|
plaintextKey: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy-once key reveal. The server returns the plaintext key exactly once on
|
||||||
|
* creation; this is the only place it is ever shown. The user is warned and
|
||||||
|
* given a copy button. Closing dismisses it for good.
|
||||||
|
*/
|
||||||
|
export function KeyRevealModal({ plaintextKey, onClose }: KeyRevealModalProps) {
|
||||||
|
const { copied, copy } = useClipboard();
|
||||||
|
const open = plaintextKey != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Agent key created"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={<Button variant="primary" onClick={onClose}>Done</Button>}
|
||||||
|
>
|
||||||
|
<div className="keyreveal__warn" role="alert">
|
||||||
|
<svg
|
||||||
|
className="keyreveal__warnicon"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M10.3 3.7 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z" />
|
||||||
|
<path d="M12 9v4M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Copy this key now. You will not see it again.</strong>
|
||||||
|
<span>
|
||||||
|
The key is shown only at creation and cannot be recovered. If you
|
||||||
|
lose it, revoke it and create a new one.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="keyreveal__value">
|
||||||
|
<code className="keyreveal__key">{plaintextKey}</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => plaintextKey && void copy(plaintextKey)}
|
||||||
|
aria-label={copied ? "Key copied to clipboard" : "Copy key to clipboard"}
|
||||||
|
>
|
||||||
|
<CopyIcon width={14} height={14} />
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="keyreveal__hint">
|
||||||
|
Use this key to enroll the agent as a persistent, individually revocable
|
||||||
|
identity.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
dashboard/src/features/machines/MachineDetailDrawer.tsx
Normal file
153
dashboard/src/features/machines/MachineDetailDrawer.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import type { Machine } from "../../api/types";
|
||||||
|
import { Badge } from "../../components/ui/Badge";
|
||||||
|
import { Drawer } from "../../components/ui/Drawer";
|
||||||
|
import { Spinner } from "../../components/ui/Spinner";
|
||||||
|
import { EmptyState, ErrorState } from "../../components/ui/States";
|
||||||
|
import { machineTone } from "../../components/ui/status";
|
||||||
|
import { StatusDot } from "../../components/ui/StatusDot";
|
||||||
|
import { absoluteTime, formatDuration, relativeTime } from "../../lib/time";
|
||||||
|
import { useMachineHistory } from "./hooks";
|
||||||
|
|
||||||
|
interface MachineDetailDrawerProps {
|
||||||
|
machine: Machine | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mdetail__k">{label}</div>
|
||||||
|
<div className="mdetail__v">{children}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and inspect surface for a single machine: facts plus session and event
|
||||||
|
* history. A side drawer (not a modal): inspecting a machine is a lightweight,
|
||||||
|
* non-blocking read, and the list stays visible behind it for context.
|
||||||
|
*/
|
||||||
|
export function MachineDetailDrawer({ machine, onClose }: MachineDetailDrawerProps) {
|
||||||
|
const history = useMachineHistory(machine?.agent_id ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={machine != null}
|
||||||
|
ariaLabel={`Machine detail: ${machine?.hostname ?? ""}`}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{machine && (
|
||||||
|
<StatusDot tone={machineTone(machine.status)} label={machine.status} />
|
||||||
|
)}
|
||||||
|
<span className="mono">{machine?.hostname}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
subtitle={machine ? `Agent ${machine.agent_id}` : undefined}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{machine && (
|
||||||
|
<div className="mdetail__grid">
|
||||||
|
<Row label="Status">
|
||||||
|
<Badge tone={machineTone(machine.status)} dot>
|
||||||
|
{machine.status}
|
||||||
|
</Badge>
|
||||||
|
</Row>
|
||||||
|
<Row label="OS version">{machine.os_version ?? "Unknown"}</Row>
|
||||||
|
<Row label="Connection">
|
||||||
|
{machine.is_persistent ? (
|
||||||
|
<Badge tone="accent">Persistent</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="neutral">Attended</Badge>
|
||||||
|
)}{" "}
|
||||||
|
{machine.is_elevated && <Badge tone="ok">Elevated</Badge>}
|
||||||
|
</Row>
|
||||||
|
<Row label="First seen">
|
||||||
|
<span className="mono" title={absoluteTime(machine.first_seen)}>
|
||||||
|
{relativeTime(machine.first_seen)}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="Last seen">
|
||||||
|
<span className="mono" title={absoluteTime(machine.last_seen)}>
|
||||||
|
{relativeTime(machine.last_seen)}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mdetail__section">
|
||||||
|
<h3>Session history</h3>
|
||||||
|
{history.isLoading ? (
|
||||||
|
<Spinner label="Loading history" />
|
||||||
|
) : history.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
title="Could not load history"
|
||||||
|
message={
|
||||||
|
history.error instanceof ApiError
|
||||||
|
? history.error.message
|
||||||
|
: "The server did not respond. Try reopening this panel."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : !history.data || history.data.sessions.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No sessions yet"
|
||||||
|
message="Support and managed sessions for this machine will be listed here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="minitable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{history.data.sessions.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td className="mono" title={absoluteTime(s.started_at)}>
|
||||||
|
{relativeTime(s.started_at)}
|
||||||
|
</td>
|
||||||
|
<td className="mono">{formatDuration(s.duration_secs)}</td>
|
||||||
|
<td>{s.is_support_session ? "Support" : "Managed"}</td>
|
||||||
|
<td className="mono">{s.support_code ?? "None"}</td>
|
||||||
|
<td>{s.status}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{history.data && history.data.events.length > 0 && (
|
||||||
|
<div className="mdetail__section">
|
||||||
|
<h3>Recent events</h3>
|
||||||
|
<table className="minitable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Viewer</th>
|
||||||
|
<th>IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{history.data.events.slice(0, 25).map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className="mono" title={absoluteTime(e.timestamp)}>
|
||||||
|
{relativeTime(e.timestamp)}
|
||||||
|
</td>
|
||||||
|
<td>{e.event_type}</td>
|
||||||
|
<td>{e.viewer_name ?? "None"}</td>
|
||||||
|
<td className="mono">{e.ip_address ?? "None"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
dashboard/src/features/machines/MachineKeysModal.tsx
Normal file
197
dashboard/src/features/machines/MachineKeysModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import type { KeyMetadata, Machine } from "../../api/types";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
|
||||||
|
import { Modal } from "../../components/ui/Modal";
|
||||||
|
import { Badge } from "../../components/ui/Badge";
|
||||||
|
import { Spinner } from "../../components/ui/Spinner";
|
||||||
|
import { EmptyState, ErrorState } from "../../components/ui/States";
|
||||||
|
import { useToast } from "../../components/ui/toast-context";
|
||||||
|
import { absoluteTime, relativeTime } from "../../lib/time";
|
||||||
|
import {
|
||||||
|
useCreateMachineKey,
|
||||||
|
useMachineKeys,
|
||||||
|
useRevokeMachineKey,
|
||||||
|
} from "./hooks";
|
||||||
|
import { KeyRevealModal } from "./KeyRevealModal";
|
||||||
|
|
||||||
|
interface MachineKeysModalProps {
|
||||||
|
machine: Machine | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyState(k: KeyMetadata): { tone: "ok" | "neutral"; label: string } {
|
||||||
|
return k.revoked_at
|
||||||
|
? { tone: "neutral", label: "Revoked" }
|
||||||
|
: { tone: "ok", label: "Active" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only per-agent key management. Lists key metadata (never the secret),
|
||||||
|
* mints new keys (revealed once via KeyRevealModal), and revokes existing keys.
|
||||||
|
*/
|
||||||
|
export function MachineKeysModal({ machine, onClose }: MachineKeysModalProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const agentId = machine?.agent_id ?? "";
|
||||||
|
|
||||||
|
const keysQuery = useMachineKeys(machine?.agent_id ?? null, machine != null);
|
||||||
|
const createKey = useCreateMachineKey(agentId);
|
||||||
|
const revokeKey = useRevokeMachineKey(agentId);
|
||||||
|
|
||||||
|
const [revealKey, setRevealKey] = useState<string | null>(null);
|
||||||
|
const [pendingRevoke, setPendingRevoke] = useState<KeyMetadata | null>(null);
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
createKey.mutate(undefined, {
|
||||||
|
onSuccess: (created) => {
|
||||||
|
setRevealKey(created.key);
|
||||||
|
toast.success("Key created", "Copy it now — it is shown only once.");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
"Could not create key",
|
||||||
|
err instanceof ApiError ? err.message : "Unexpected error.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRevoke() {
|
||||||
|
if (!pendingRevoke) return;
|
||||||
|
const id = pendingRevoke.id;
|
||||||
|
revokeKey.mutate(id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Key revoked");
|
||||||
|
setPendingRevoke(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
"Could not revoke key",
|
||||||
|
err instanceof ApiError ? err.message : "Unexpected error.",
|
||||||
|
);
|
||||||
|
setPendingRevoke(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = keysQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
open={machine != null && revealKey == null}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
Agent keys ·{" "}
|
||||||
|
<span className="mono" style={{ fontWeight: 500 }}>
|
||||||
|
{machine?.hostname}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
ariaLabel={`Agent keys for ${machine?.hostname ?? "machine"}`}
|
||||||
|
onClose={onClose}
|
||||||
|
wide
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCreate}
|
||||||
|
loading={createKey.isPending}
|
||||||
|
>
|
||||||
|
Create key
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{keysQuery.isLoading ? (
|
||||||
|
<div style={{ padding: "24px 0", display: "grid", placeItems: "center" }}>
|
||||||
|
<Spinner label="Loading keys" />
|
||||||
|
</div>
|
||||||
|
) : keysQuery.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
title="Failed to load keys"
|
||||||
|
message={
|
||||||
|
keysQuery.error instanceof ApiError
|
||||||
|
? keysQuery.error.message
|
||||||
|
: "Unexpected error."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No keys issued"
|
||||||
|
message="This machine has no per-agent keys. Create one to enroll it as a persistent identity."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="minitable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Key ID</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last used</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.map((k) => {
|
||||||
|
const s = keyState(k);
|
||||||
|
return (
|
||||||
|
<tr key={k.id} className={k.revoked_at ? "key--revoked" : undefined}>
|
||||||
|
<td>
|
||||||
|
<Badge tone={s.tone} dot>
|
||||||
|
{s.label}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="mono" title={k.id}>
|
||||||
|
{k.id}
|
||||||
|
</td>
|
||||||
|
<td className="mono" title={absoluteTime(k.created_at)}>
|
||||||
|
{relativeTime(k.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="mono" title={absoluteTime(k.last_used_at)}>
|
||||||
|
{k.last_used_at ? relativeTime(k.last_used_at) : "never"}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
{!k.revoked_at && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPendingRevoke(k)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<KeyRevealModal plaintextKey={revealKey} onClose={() => setRevealKey(null)} />
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingRevoke != null}
|
||||||
|
title="Revoke agent key"
|
||||||
|
danger
|
||||||
|
busy={revokeKey.isPending}
|
||||||
|
confirmLabel="Revoke key"
|
||||||
|
body={
|
||||||
|
<span>
|
||||||
|
Revoking this key immediately blocks any agent authenticating with
|
||||||
|
it. This cannot be undone. Key{" "}
|
||||||
|
<code className="mono">{pendingRevoke?.id}</code>.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onConfirm={handleRevoke}
|
||||||
|
onCancel={() => setPendingRevoke(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user