Compare commits
82 Commits
v0.2.2
...
fix/spec01
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eaabdd6a5 | |||
| 11af9dff8e | |||
| a0e0d5f1e7 | |||
| 7602b4346a | |||
| 55b9c97b28 | |||
| 94c07c2431 | |||
| 4c49b73a71 | |||
| 367906bd54 | |||
| 52477e4c4a | |||
| 87c6e17d4a | |||
| 6a000d012f | |||
| d0b8db070f | |||
| 89c3718266 | |||
| 4106fc4bc4 | |||
| 0f02f23765 | |||
| 59e40c8019 | |||
| c286a29b9d | |||
| 18429f6fe3 | |||
| 3b9e4068c9 | |||
| 87f229509b | |||
| 40c7d860cc | |||
| 0059b21db6 | |||
| f950511e3e | |||
| 16017456aa | |||
|
|
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
|
||||
run: cd server && cargo fmt --all -- --check
|
||||
|
||||
# Informational (warn-only) for now. The pre-spec codebase has ~65 lint warnings,
|
||||
# mostly dead-code for API the integration spec (native-remote-control) will wire.
|
||||
# Re-tighten to `-- -D warnings` during the GC re-spec once that API is in use.
|
||||
- name: Run Clippy (informational)
|
||||
run: cd server && cargo clippy --all-targets --all-features
|
||||
# Hard gate: clippy must pass with zero warnings (-D warnings). Dead-code that is
|
||||
# future API surface for native-remote-control carries targeted #[allow(dead_code)].
|
||||
- name: Run Clippy
|
||||
run: cd server && cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Build server
|
||||
run: |
|
||||
@@ -143,12 +142,18 @@ jobs:
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
# Informational (warn-only) for now, like clippy. GuruConnect is a single Cargo workspace,
|
||||
# so one `cargo audit` at the root covers all members (agent + server) via the shared
|
||||
# Cargo.lock. The pre-spec dependency tree has known advisories; re-tighten to a hard gate
|
||||
# during the GC re-spec after a dependency refresh.
|
||||
- name: Run security audit (informational)
|
||||
run: cargo audit || echo "[WARNING] cargo audit reported advisories (informational; address in GC re-spec)"
|
||||
# Hard gate: cargo audit must pass. GuruConnect is a single Cargo workspace, so one
|
||||
# `cargo audit` at the root covers all members (agent + server) via the shared Cargo.lock.
|
||||
# The advisories below are explicitly ignored with documented justifications; any NEW
|
||||
# advisory fails the build.
|
||||
# RUSTSEC-2023-0071 (rsa) ............. no fixed upgrade; optional/unreachable in active tree
|
||||
# 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:
|
||||
name: Build Summary
|
||||
|
||||
@@ -27,6 +27,15 @@ on:
|
||||
# computes the next semver from conventional commits at dispatch time.
|
||||
# build-and-test.yml remains the automatic PR/push CI gate.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: 'Release channel (stable = full versioned release; beta = signed prerelease test build, no version bump/changelog)'
|
||||
required: true
|
||||
default: 'stable'
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- beta
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -36,8 +45,11 @@ jobs:
|
||||
name: Version + Changelog
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.version }}
|
||||
released: ${{ steps.bump.outputs.released }}
|
||||
# Coalesce across the stable (bump) and beta (beta) paths: exactly one of them runs per
|
||||
# dispatch, so the first non-empty value wins. prerelease is 'true' only on the beta path.
|
||||
version: ${{ steps.bump.outputs.version || steps.beta.outputs.version }}
|
||||
released: ${{ steps.bump.outputs.released || steps.beta.outputs.released }}
|
||||
prerelease: ${{ steps.beta.outputs.prerelease || 'false' }}
|
||||
steps:
|
||||
- name: Checkout (full history + tags)
|
||||
uses: actions/checkout@v4
|
||||
@@ -59,7 +71,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install git-cliff
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
# Stable-only: beta produces no changelog, so git-cliff is unnecessary on the beta path.
|
||||
if: steps.guard.outputs.skip != 'true' && github.event.inputs.channel == 'stable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CLIFF_VERSION="2.6.1"
|
||||
@@ -72,12 +85,16 @@ jobs:
|
||||
|
||||
- name: Determine next version and bump components
|
||||
id: bump
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
# Stable-only: the beta path (id: beta) handles versioning without a manifest bump/commit.
|
||||
if: steps.guard.outputs.skip != 'true' && github.event.inputs.channel == 'stable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# ----- locate the last release tag (vX.Y.Z) -----
|
||||
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
|
||||
# Match ONLY strict final-release tags (vMAJOR.MINOR.PATCH). Beta tags look like
|
||||
# v0.3.0-beta.7; if one of those were picked up here it would corrupt the next stable
|
||||
# base version, so prerelease tags are explicitly excluded from this lookup.
|
||||
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)"
|
||||
if [ -z "${LAST_TAG}" ]; then
|
||||
echo "[INFO] No prior release tag found; baseline is current manifest version."
|
||||
BASE_VERSION="$(grep -m1 '^version' agent/Cargo.toml | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')"
|
||||
@@ -186,8 +203,39 @@ jobs:
|
||||
sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" Cargo.toml || true
|
||||
fi
|
||||
|
||||
- name: Beta channel - tag prerelease build (no bump, no commit, no changelog)
|
||||
id: beta
|
||||
# Beta-only path. Reuses the IDENTICAL downstream build + sign + publish jobs, but does
|
||||
# NOT compute a semver bump, mutate any manifest, generate a changelog, or make a release
|
||||
# commit. It just tags the CURRENT HEAD with a unique prerelease version so the Windows
|
||||
# build job can check out `ref: v${VER}` exactly as it does for stable.
|
||||
if: github.event.inputs.channel == 'beta' && steps.guard.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Base version is read straight from the agent manifest — NOT bumped, NOT written back.
|
||||
BASE="$(grep -m1 '^version' agent/Cargo.toml | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')"
|
||||
# GITHUB_RUN_NUMBER guarantees a unique prerelease suffix without counting existing tags.
|
||||
VER="${BASE}-beta.${GITHUB_RUN_NUMBER}"
|
||||
echo "[INFO] Beta build version: ${VER} (base ${BASE}, run ${GITHUB_RUN_NUMBER})"
|
||||
|
||||
# Tag the current HEAD (no release commit). Push the tag so build-agent-windows can
|
||||
# check out ref: v${VER}.
|
||||
git config user.name "guruconnect-ci"
|
||||
git config user.email "ci@azcomputerguru.com"
|
||||
# Beta tags are disposable test markers; force makes re-running a failed beta dispatch idempotent (re-run reuses GITHUB_RUN_NUMBER, so the tag already exists).
|
||||
git tag -f "v${VER}"
|
||||
REMOTE="https://${{ secrets.CI_PUSH_TOKEN }}@git.azcomputerguru.com/${GITHUB_REPOSITORY}.git"
|
||||
git push --force "${REMOTE}" "v${VER}"
|
||||
echo "[OK] Pushed beta prerelease tag v${VER}"
|
||||
|
||||
echo "version=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate changelog (git-cliff)
|
||||
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||
# Stable-only: beta produces no changelog artifact.
|
||||
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
|
||||
env:
|
||||
VERSION: ${{ steps.bump.outputs.version }}
|
||||
run: |
|
||||
@@ -232,7 +280,10 @@ jobs:
|
||||
|
||||
# Re-derive the set of changed components (same logic as the bump step). On the first
|
||||
# release (no prior tag) all components are considered changed.
|
||||
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
|
||||
# Match ONLY strict final-release tags (vMAJOR.MINOR.PATCH); exclude beta prerelease
|
||||
# tags (v0.3.0-beta.7) so the changelog diff range is taken against the last real
|
||||
# release, not an intervening beta build.
|
||||
LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)"
|
||||
if [ -z "${LAST_TAG}" ]; then
|
||||
CHANGED_FILES="$(git ls-files)"
|
||||
FIRST_RELEASE=true
|
||||
@@ -252,7 +303,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Commit release + create tag
|
||||
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||
# Stable-only: beta tags HEAD directly in the beta step and never makes a release commit.
|
||||
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
|
||||
env:
|
||||
VERSION: ${{ steps.bump.outputs.version }}
|
||||
run: |
|
||||
@@ -276,7 +328,8 @@ jobs:
|
||||
echo "[OK] Pushed release commit and tag v${VERSION}"
|
||||
|
||||
- name: Upload changelog artifact
|
||||
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
|
||||
# Stable-only: there is no changelog on the beta path, so nothing to upload.
|
||||
if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true' && github.event.inputs.channel == 'stable'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: changelog
|
||||
@@ -445,6 +498,9 @@ jobs:
|
||||
echo "sha256=${SUM}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download changelog artifact
|
||||
# Stable-only: the beta path uploads no `changelog` artifact. The release-creation step
|
||||
# already guards on `[ -f changelog-artifact/CHANGELOG.md ]`, so skipping this is safe.
|
||||
if: github.event.inputs.channel == 'stable'
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: changelog
|
||||
@@ -472,17 +528,26 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
SHA256: ${{ steps.sha.outputs.sha256 }}
|
||||
# PRERELEASE is 'true' on the beta path, 'false' on stable; drives the Gitea release flag.
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GITEA_TOKEN: ${{ secrets.CI_PUSH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API_BASE="https://git.azcomputerguru.com/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="v${VERSION}"
|
||||
echo "[INFO] Creating Gitea release ${TAG} on ${GITHUB_REPOSITORY}"
|
||||
echo "[INFO] Creating Gitea release ${TAG} on ${GITHUB_REPOSITORY} (prerelease=${PRERELEASE})"
|
||||
|
||||
BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"
|
||||
# Beta builds get a clear "prerelease test build" note in the body; the -beta.N suffix
|
||||
# is already carried in TAG, so the release name "Release v..." needs no extra handling.
|
||||
if [ "${PRERELEASE}" = "true" ]; then
|
||||
BODY="$(printf 'GuruConnect %s (PRERELEASE / beta test build)\n\nSHA-256 (guruconnect.exe): %s\n\nSigned via Azure Trusted Signing. Not a stable release — no changelog/version bump.' "${TAG}" "${SHA256}")"
|
||||
else
|
||||
BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"
|
||||
fi
|
||||
|
||||
# Build the JSON payload with python (handles escaping of the multi-line body safely).
|
||||
CREATE_PAYLOAD="$(TAG="$TAG" BODY="$BODY" python3 -c 'import json,os; print(json.dumps({"tag_name": os.environ["TAG"], "name": "Release " + os.environ["TAG"], "body": os.environ["BODY"], "draft": False, "prerelease": False}))')"
|
||||
# prerelease is derived from the PRERELEASE env var (beta -> true, stable -> false).
|
||||
CREATE_PAYLOAD="$(TAG="$TAG" BODY="$BODY" PRERELEASE="$PRERELEASE" python3 -c 'import json,os; print(json.dumps({"tag_name": os.environ["TAG"], "name": "Release " + os.environ["TAG"], "body": os.environ["BODY"], "draft": False, "prerelease": os.environ.get("PRERELEASE","false") == "true"}))')"
|
||||
|
||||
RELEASE_JSON="$(curl -fsS -X POST \
|
||||
"${API_BASE}/releases" \
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ vendor/
|
||||
|
||||
# Generated files
|
||||
*.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:`)
|
||||
by the release workflow; per-component changelogs are also written to
|
||||
`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
|
||||
|
||||
### 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
|
||||
|
||||
- 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)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Drop broken jsign --info verify step in release (5727ccf3)
|
||||
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
|
||||
|
||||
### Security
|
||||
|
||||
- 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"
|
||||
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]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -1075,31 +1062,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@@ -1167,19 +1129,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -1398,26 +1353,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
@@ -1472,13 +1407,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guruconnect"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hostname",
|
||||
"image",
|
||||
"muda",
|
||||
@@ -1511,7 +1447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guruconnect-server"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1534,18 +1470,11 @@ dependencies = [
|
||||
"toml 0.8.2",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -2362,24 +2291,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3035,12 +2946,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3218,21 +3123,6 @@ version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -3377,15 +3267,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3960,15 +3841,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@@ -4653,22 +4525,6 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
|
||||
@@ -6,7 +6,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.2"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
authors = ["AZ Computer Guru"]
|
||||
license = "Proprietary"
|
||||
@@ -25,3 +25,8 @@ anyhow = "1"
|
||||
thiserror = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "guruconnect"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
authors = ["AZ Computer Guru"]
|
||||
description = "GuruConnect Remote Desktop - Agent and Viewer"
|
||||
@@ -47,6 +47,7 @@ toml = "0.8"
|
||||
# Crypto
|
||||
ring = "0.17"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# HTTP client for updates
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
@@ -91,10 +92,18 @@ windows = { version = "0.58", features = [
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Environment",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_SystemServices",
|
||||
"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
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! The agent communicates with this service via named pipe IPC.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Read, Write as IoWrite};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -37,7 +36,19 @@ const PIPE_READMODE_MESSAGE: u32 = 0x00000002;
|
||||
const PIPE_WAIT: u32 = 0x00000000;
|
||||
const PIPE_UNLIMITED_INSTANCES: u32 = 255;
|
||||
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
|
||||
#[link(name = "kernel32")]
|
||||
@@ -71,19 +82,23 @@ extern "system" {
|
||||
lpOverlapped: *mut std::ffi::c_void,
|
||||
) -> i32;
|
||||
fn FlushFileBuffers(hFile: isize) -> i32;
|
||||
fn LocalFree(hMem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
|
||||
}
|
||||
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn InitializeSecurityDescriptor(pSecurityDescriptor: *mut u8, dwRevision: u32) -> i32;
|
||||
fn SetSecurityDescriptorDacl(
|
||||
pSecurityDescriptor: *mut u8,
|
||||
bDaclPresent: i32,
|
||||
pDacl: *mut std::ffi::c_void,
|
||||
bDaclDefaulted: i32,
|
||||
/// Build a self-relative security descriptor from an SDDL string. The descriptor is
|
||||
/// allocated with `LocalAlloc` and must be released with `LocalFree`.
|
||||
fn ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
StringSecurityDescriptor: *const u16,
|
||||
StringSDRevision: u32,
|
||||
SecurityDescriptor: *mut *mut std::ffi::c_void,
|
||||
SecurityDescriptorSize: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
// Field names mirror the Win32 SECURITY_ATTRIBUTES ABI struct.
|
||||
#[allow(non_snake_case)]
|
||||
#[repr(C)]
|
||||
struct SECURITY_ATTRIBUTES {
|
||||
nLength: u32,
|
||||
@@ -280,26 +295,31 @@ fn run_pipe_server() -> Result<()> {
|
||||
tracing::info!("Starting pipe server on {}", PIPE_NAME);
|
||||
|
||||
loop {
|
||||
// Create security descriptor that allows everyone
|
||||
let mut sd = [0u8; 256];
|
||||
unsafe {
|
||||
if InitializeSecurityDescriptor(sd.as_mut_ptr(), SECURITY_DESCRIPTOR_REVISION) == 0 {
|
||||
tracing::error!("Failed to initialize security descriptor");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
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;
|
||||
}
|
||||
// Build a restrictive security descriptor from SDDL: grant access only to
|
||||
// Authenticated Users (excludes anonymous / null-session callers). See PIPE_SDDL.
|
||||
let sddl: Vec<u16> = PIPE_SDDL.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut sd_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
|
||||
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));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: sd.as_mut_ptr(),
|
||||
lpSecurityDescriptor: sd_ptr as *mut u8,
|
||||
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 {
|
||||
tracing::error!("Failed to create named pipe");
|
||||
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
|
||||
fn send_sas() -> Result<()> {
|
||||
unsafe {
|
||||
@@ -505,6 +594,19 @@ fn install_service() -> Result<()> {
|
||||
])
|
||||
.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!("\nBinary: {:?}", binary_dest);
|
||||
println!("\nStarting service...");
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct Display {
|
||||
}
|
||||
|
||||
/// Display info for protocol messages
|
||||
// Future use: multi-display protocol negotiation.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisplayInfo {
|
||||
pub displays: Vec<Display>,
|
||||
@@ -40,11 +42,13 @@ pub struct DisplayInfo {
|
||||
|
||||
impl Display {
|
||||
/// Total pixels in the display
|
||||
#[allow(dead_code)]
|
||||
pub fn pixel_count(&self) -> u32 {
|
||||
self.width * self.height
|
||||
}
|
||||
|
||||
/// Bytes needed for BGRA frame buffer
|
||||
#[allow(dead_code)]
|
||||
pub fn buffer_size(&self) -> 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 display_id = 0u32;
|
||||
|
||||
// Callback for EnumDisplayMonitors
|
||||
unsafe extern "system" fn enum_callback(
|
||||
@@ -148,6 +151,8 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
|
||||
}
|
||||
|
||||
/// Get display info for protocol
|
||||
// Future use: multi-display protocol negotiation.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_display_info() -> Result<DisplayInfo> {
|
||||
let displays = enumerate_displays()?;
|
||||
let primary_id = displays
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct DxgiCapturer {
|
||||
staging_texture: Option<ID3D11Texture2D>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Future use: frame diffing against the previously captured frame.
|
||||
#[allow(dead_code)]
|
||||
last_frame: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ impl GdiCapturer {
|
||||
|
||||
let bitmap = CreateCompatibleBitmap(screen_dc, self.width as i32, self.height as i32);
|
||||
if bitmap.is_invalid() {
|
||||
DeleteDC(mem_dc);
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(HWND::default(), screen_dc);
|
||||
anyhow::bail!("Failed to create compatible bitmap");
|
||||
}
|
||||
@@ -69,8 +69,8 @@ impl GdiCapturer {
|
||||
SRCCOPY,
|
||||
) {
|
||||
SelectObject(mem_dc, old_bitmap);
|
||||
DeleteObject(bitmap);
|
||||
DeleteDC(mem_dc);
|
||||
let _ = DeleteObject(bitmap);
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(HWND::default(), screen_dc);
|
||||
anyhow::bail!("BitBlt failed: {}", e);
|
||||
}
|
||||
@@ -110,8 +110,8 @@ impl GdiCapturer {
|
||||
|
||||
// Cleanup
|
||||
SelectObject(mem_dc, old_bitmap);
|
||||
DeleteObject(bitmap);
|
||||
DeleteDC(mem_dc);
|
||||
let _ = DeleteObject(bitmap);
|
||||
let _ = DeleteDC(mem_dc);
|
||||
ReleaseDC(HWND::default(), screen_dc);
|
||||
|
||||
if lines == 0 {
|
||||
|
||||
@@ -9,7 +9,7 @@ mod dxgi;
|
||||
#[cfg(windows)]
|
||||
mod gdi;
|
||||
|
||||
pub use display::{Display, DisplayInfo};
|
||||
pub use display::Display;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::Instant;
|
||||
@@ -33,6 +33,8 @@ pub struct CapturedFrame {
|
||||
pub display_id: u32,
|
||||
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
@@ -53,9 +55,11 @@ pub trait Capturer: Send {
|
||||
fn capture(&mut self) -> Result<Option<CapturedFrame>>;
|
||||
|
||||
/// Get the current display info
|
||||
#[allow(dead_code)]
|
||||
fn display(&self) -> &Display;
|
||||
|
||||
/// Check if capturer is still valid (display may have changed)
|
||||
#[allow(dead_code)]
|
||||
fn is_valid(&self) -> bool;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,13 @@
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::info;
|
||||
#[cfg(not(windows))]
|
||||
use tracing::warn;
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::core::PCWSTR;
|
||||
#[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::*;
|
||||
|
||||
/// A chat message
|
||||
@@ -29,11 +25,15 @@ pub struct ChatMessage {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub enum ChatCommand {
|
||||
#[allow(dead_code)]
|
||||
Show,
|
||||
#[allow(dead_code)]
|
||||
Hide,
|
||||
AddMessage(ChatMessage),
|
||||
#[allow(dead_code)]
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -69,11 +69,13 @@ impl ChatController {
|
||||
}
|
||||
|
||||
/// Show the chat window
|
||||
#[allow(dead_code)]
|
||||
pub fn show(&self) {
|
||||
let _ = self.command_tx.send(ChatCommand::Show);
|
||||
}
|
||||
|
||||
/// Hide the chat window
|
||||
#[allow(dead_code)]
|
||||
pub fn hide(&self) {
|
||||
let _ = self.command_tx.send(ChatCommand::Hide);
|
||||
}
|
||||
@@ -93,16 +95,14 @@ impl ChatController {
|
||||
}
|
||||
|
||||
/// Close the chat window
|
||||
#[allow(dead_code)]
|
||||
pub fn close(&self) {
|
||||
let _ = self.command_tx.send(ChatCommand::Close);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn run_chat_window(command_rx: Receiver<ChatCommand>, message_tx: Sender<ChatMessage>) {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
fn run_chat_window(command_rx: Receiver<ChatCommand>, _message_tx: Sender<ChatMessage>) {
|
||||
info!("Starting chat window thread");
|
||||
|
||||
// For now, we'll use a simple message box approach
|
||||
|
||||
@@ -9,25 +9,46 @@ use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Magic marker for embedded configuration (10 bytes)
|
||||
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
|
||||
|
||||
/// Embedded configuration data (appended to executable)
|
||||
///
|
||||
/// SPEC-016 Phase B: a managed-install config now carries the per-site
|
||||
/// `enrollment_key` + `site_code` so the agent can self-register on first run.
|
||||
/// The legacy `api_key` is retained (defaulted) for backward-compat with older
|
||||
/// pre-enrollment installers; a fresh site installer carries only the enrollment
|
||||
/// credentials and the agent obtains its per-machine `cak_` via `/api/enroll`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddedConfig {
|
||||
/// Server WebSocket URL
|
||||
pub server_url: String,
|
||||
/// API key for authentication
|
||||
pub api_key: String,
|
||||
/// DEPRECATED shared/legacy API key for authentication. Optional — a
|
||||
/// SPEC-016 site installer omits it and enrolls for a per-machine `cak_`.
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Per-site enrollment key (`cek_`), the low-sensitivity registration gate
|
||||
/// (SPEC-016 §Security). Presented to `/api/enroll`; never logged.
|
||||
#[serde(default)]
|
||||
pub enrollment_key: Option<String>,
|
||||
/// Per-site code identifying which site this installer enrolls into.
|
||||
#[serde(default)]
|
||||
pub site_code: Option<String>,
|
||||
/// Company/organization name
|
||||
#[serde(default)]
|
||||
pub company: Option<String>,
|
||||
/// Site/location name
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
/// Department label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub department: Option<String>,
|
||||
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub device_type: Option<String>,
|
||||
/// Tags for categorization
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
@@ -52,9 +73,28 @@ pub struct Config {
|
||||
/// Server WebSocket URL (e.g., wss://connect.example.com/ws)
|
||||
pub server_url: String,
|
||||
|
||||
/// Agent API key for authentication
|
||||
/// Operating credential used to authenticate the persistent WS connection.
|
||||
///
|
||||
/// SPEC-016 Phase B: the AUTHORITATIVE credential is a per-machine `cak_`
|
||||
/// obtained at first-run enrollment and stored encrypted at rest (see
|
||||
/// [`crate::credential_store`]); it is loaded into this field before connect.
|
||||
/// A non-empty value carried in config is the DEPRECATED shared/legacy
|
||||
/// `api_key`, kept only for transition compatibility. Empty means "not yet
|
||||
/// enrolled / no credential" — the run-mode wiring must enroll first.
|
||||
#[serde(default)]
|
||||
pub api_key: String,
|
||||
|
||||
/// Per-site enrollment key (`cek_`) — present only for a not-yet-enrolled
|
||||
/// managed install. Never persisted to the on-disk TOML (it is install-time
|
||||
/// material, delivered by the site wrapper); never logged.
|
||||
#[serde(skip)]
|
||||
pub enrollment_key: Option<String>,
|
||||
|
||||
/// Per-site code identifying which site to enroll into (paired with
|
||||
/// `enrollment_key`). Not persisted to the on-disk TOML.
|
||||
#[serde(skip)]
|
||||
pub site_code: Option<String>,
|
||||
|
||||
/// Unique agent identifier (generated on first run)
|
||||
#[serde(default = "generate_agent_id")]
|
||||
pub agent_id: String,
|
||||
@@ -70,6 +110,14 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
|
||||
/// Department label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub department: Option<String>,
|
||||
|
||||
/// Device-type label (reserved — SPEC-007 AgentStatus parity).
|
||||
#[serde(default)]
|
||||
pub device_type: Option<String>,
|
||||
|
||||
/// Tags for categorization (from embedded config)
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
@@ -91,6 +139,25 @@ fn generate_agent_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Layer SPEC-016 enrollment material from the environment onto a `Config`.
|
||||
///
|
||||
/// `GURUCONNECT_ENROLLMENT_KEY` / `GURUCONNECT_SITE_CODE` only OVERRIDE when set
|
||||
/// and non-empty, so embedded/install-time values already present on the config
|
||||
/// are preserved. Used by the file and env load paths (the embedded path already
|
||||
/// carries these from the install blob).
|
||||
fn apply_enrollment_env(config: &mut Config) {
|
||||
if let Ok(v) = std::env::var("GURUCONNECT_ENROLLMENT_KEY") {
|
||||
if !v.is_empty() {
|
||||
config.enrollment_key = Some(v);
|
||||
}
|
||||
}
|
||||
if let Ok(v) = std::env::var("GURUCONNECT_SITE_CODE") {
|
||||
if !v.is_empty() {
|
||||
config.site_code = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaptureConfig {
|
||||
/// Target frames per second (1-60)
|
||||
@@ -196,7 +263,7 @@ impl Config {
|
||||
/// Extract 6-digit support code from filename
|
||||
fn extract_support_code(filename: &str) -> Option<String> {
|
||||
// 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();
|
||||
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(trimmed.to_string());
|
||||
@@ -310,6 +377,26 @@ impl Config {
|
||||
false
|
||||
}
|
||||
|
||||
/// Best-effort read of a previously-persisted `agent_id` from the on-disk
|
||||
/// TOML at [`Self::config_path`].
|
||||
///
|
||||
/// The embedded blob never carries an `agent_id` (it is minted at first
|
||||
/// run), so for a managed agent the only stable source across restarts is
|
||||
/// the TOML that a prior run wrote via [`Self::save`]. Returns `Some(id)`
|
||||
/// only when the file exists, parses, and contains a non-empty `agent_id`;
|
||||
/// any missing-file / read / parse error yields `None` so the caller falls
|
||||
/// back to generating a fresh id.
|
||||
fn persisted_agent_id() -> Option<String> {
|
||||
let config_path = Self::config_path();
|
||||
let contents = std::fs::read_to_string(&config_path).ok()?;
|
||||
let parsed: Config = toml::from_str(&contents).ok()?;
|
||||
if parsed.agent_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parsed.agent_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from embedded config, file, or environment
|
||||
pub fn load() -> Result<Self> {
|
||||
// Priority 1: Try loading from embedded config
|
||||
@@ -317,18 +404,33 @@ impl Config {
|
||||
info!("Using embedded configuration");
|
||||
let config = Config {
|
||||
server_url: embedded.server_url,
|
||||
api_key: embedded.api_key,
|
||||
agent_id: generate_agent_id(),
|
||||
// Legacy/shared api_key if the installer carried one; empty
|
||||
// otherwise (the SPEC-016 path enrolls for a per-machine cak_).
|
||||
api_key: embedded.api_key.unwrap_or_default(),
|
||||
enrollment_key: embedded.enrollment_key,
|
||||
site_code: embedded.site_code,
|
||||
// The embedded blob carries no agent_id, and load() always
|
||||
// prefers this embedded path — so a freshly generated id would
|
||||
// never be read back, churning the agent_id on every restart.
|
||||
// Reuse the id a prior run persisted to the TOML if present;
|
||||
// only mint a new one when none exists yet.
|
||||
agent_id: Self::persisted_agent_id().unwrap_or_else(generate_agent_id),
|
||||
hostname_override: None,
|
||||
company: embedded.company,
|
||||
site: embedded.site,
|
||||
department: embedded.department,
|
||||
device_type: embedded.device_type,
|
||||
tags: embedded.tags,
|
||||
support_code: None,
|
||||
capture: CaptureConfig::default(),
|
||||
encoding: EncodingConfig::default(),
|
||||
};
|
||||
|
||||
// Save to file for persistence (so agent_id is preserved)
|
||||
// Persist so a freshly-minted agent_id is available to read back on
|
||||
// the next launch (the embedded path always wins, so the TOML is the
|
||||
// only place the stable id can live). The #[serde(skip)] enrollment
|
||||
// fields are intentionally NOT written to the on-disk TOML — they are
|
||||
// install-time material only.
|
||||
let _ = config.save();
|
||||
return Ok(config);
|
||||
}
|
||||
@@ -349,8 +451,12 @@ impl Config {
|
||||
let _ = config.save();
|
||||
}
|
||||
|
||||
// support_code is always None when loading from file (set via CLI)
|
||||
// support_code is always None when loading from file (set via CLI).
|
||||
config.support_code = None;
|
||||
// The enrollment fields are #[serde(skip)], so a file never carries
|
||||
// them; layer them in from the environment for testing / a
|
||||
// file-delivered managed install that supplies them out-of-band.
|
||||
apply_enrollment_env(&mut config);
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
@@ -365,18 +471,23 @@ impl Config {
|
||||
let agent_id =
|
||||
std::env::var("GURUCONNECT_AGENT_ID").unwrap_or_else(|_| generate_agent_id());
|
||||
|
||||
let config = Config {
|
||||
let mut config = Config {
|
||||
server_url,
|
||||
api_key,
|
||||
enrollment_key: None,
|
||||
site_code: None,
|
||||
agent_id,
|
||||
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
|
||||
company: None,
|
||||
site: None,
|
||||
department: None,
|
||||
device_type: None,
|
||||
tags: Vec::new(),
|
||||
support_code: None,
|
||||
capture: CaptureConfig::default(),
|
||||
encoding: EncodingConfig::default(),
|
||||
};
|
||||
apply_enrollment_env(&mut config);
|
||||
|
||||
// Save config with generated agent_id for persistence
|
||||
let _ = config.save();
|
||||
@@ -384,6 +495,34 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Derive the HTTPS API base (e.g. `https://connect.example.com`) from the
|
||||
/// agent's WebSocket `server_url` (e.g. `wss://connect.example.com/ws/agent`).
|
||||
///
|
||||
/// `/api/enroll` is REST/HTTPS while the persistent transport is `wss`, so we
|
||||
/// reuse the same host/authority and swap scheme + drop the WS path. Mapping:
|
||||
/// `wss` -> `https`, `ws` -> `http` (dev). Returns an error if `server_url`
|
||||
/// has no parseable host.
|
||||
pub fn https_base(&self) -> Result<String> {
|
||||
let parsed = url::Url::parse(&self.server_url)
|
||||
.with_context(|| format!("invalid server_url: {}", self.server_url))?;
|
||||
let scheme = match parsed.scheme() {
|
||||
"wss" | "https" => "https",
|
||||
"ws" | "http" => "http",
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"unsupported server_url scheme '{other}' (expected ws/wss)"
|
||||
))
|
||||
}
|
||||
};
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("server_url has no host: {}", self.server_url))?;
|
||||
Ok(match parsed.port() {
|
||||
Some(port) => format!("{scheme}://{host}:{port}"),
|
||||
None => format!("{scheme}://{host}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the configuration file path
|
||||
fn config_path() -> PathBuf {
|
||||
// Check for config in current directory first
|
||||
@@ -435,6 +574,8 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Example configuration file content
|
||||
// Retained for documentation / config-template generation.
|
||||
#[allow(dead_code)]
|
||||
pub fn example_config() -> &'static str {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
413
agent/src/credential_store.rs
Normal file
413
agent/src/credential_store.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
//! At-rest storage for the per-machine operating credential (`cak_`).
|
||||
//!
|
||||
//! SPEC-016 Phase B, item 4 + §Security. The `cak_` minted by `/api/enroll` is
|
||||
//! the high-sensitivity, per-machine, independently-revocable operating
|
||||
//! credential. It is stored with **two independent layers** (Mike's locked
|
||||
//! decision — "BOTH layers"):
|
||||
//!
|
||||
//! 1. **DPAPI-machine encryption** (`CryptProtectData` with
|
||||
//! `CRYPTPROTECT_LOCAL_MACHINE`): the on-disk bytes are a DPAPI blob keyed to
|
||||
//! THIS machine. A copied/exfiltrated file is inert on any other box — DPAPI
|
||||
//! machine keys do not leave the machine.
|
||||
//! 2. **SYSTEM/Administrators-only ACL** on the containing directory + file: a
|
||||
//! non-admin user cannot even read the ciphertext. Inheritance is removed and
|
||||
//! only `SYSTEM` and `BUILTIN\Administrators` are granted full control.
|
||||
//!
|
||||
//! Local admin / SYSTEM can always recover the value — that is accepted (SPEC-016
|
||||
//! §Security): the blast radius of one leaked `cak_` is a single, independently
|
||||
//! revocable machine.
|
||||
//!
|
||||
//! Storage location (chosen over an HKLM value): a file under
|
||||
//! `%ProgramData%\GuruConnect\credentials\agent.cak`. Rationale — the agent
|
||||
//! already keeps its config and the `machine_uid` fallback seed under
|
||||
//! `%ProgramData%\GuruConnect`, so co-locating keeps a single protected
|
||||
//! directory; and a directory/file ACL applied via `icacls` is auditable with far
|
||||
//! less unsafe FFI than building a registry-key security descriptor by hand. Both
|
||||
//! storage shapes are explicitly permitted by the spec.
|
||||
//!
|
||||
//! SECURITY: the plaintext `cak_` is NEVER logged. Errors describe the operation,
|
||||
//! not the value.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Failure classes for [`load_cak`], so callers can distinguish an *operational*
|
||||
/// problem (the file exists but this process cannot open/read it — e.g. running in
|
||||
/// the wrong security context against a SYSTEM-only-ACL'd store) from the real
|
||||
/// *tamper / wrong-machine* signal (the file was read successfully but DPAPI
|
||||
/// decryption failed).
|
||||
///
|
||||
/// The distinction matters for the run-mode resolver (`main.rs`):
|
||||
/// - [`LoadCakError::Io`] is recoverable/actionable — log it and STOP (do not
|
||||
/// silently re-enroll over a store we simply can't read in this context).
|
||||
/// - [`LoadCakError::Decrypt`] is a hard tamper signal — STOP, do not re-enroll.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoadCakError {
|
||||
/// The store path could not be resolved (e.g. `%ProgramData%` unset).
|
||||
#[error("could not resolve credential store path: {0}")]
|
||||
Path(String),
|
||||
|
||||
/// An IO/open/read error reaching the stored blob — INCLUDING
|
||||
/// `PermissionDenied` (the running context lacks rights to the SYSTEM-only
|
||||
/// store). Operational, not a tamper signal.
|
||||
#[error("credential store is present but could not be read in this context: {source}")]
|
||||
Io {
|
||||
/// Whether this was specifically an access-denied error (drives the
|
||||
/// run-mode fail-fast guard in `main.rs`).
|
||||
permission_denied: bool,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// The blob was read successfully but DPAPI decryption FAILED — the real
|
||||
/// tamper / wrong-machine / corruption signal. A hard stop; never re-enroll.
|
||||
#[error("stored credential failed to decrypt (wrong machine, tampered, or corrupted): {0}")]
|
||||
Decrypt(String),
|
||||
}
|
||||
|
||||
/// Directory holding the protected credential file.
|
||||
fn credentials_dir() -> Result<PathBuf> {
|
||||
let program_data =
|
||||
std::env::var("ProgramData").context("ProgramData environment variable is not set")?;
|
||||
Ok(PathBuf::from(program_data)
|
||||
.join("GuruConnect")
|
||||
.join("credentials"))
|
||||
}
|
||||
|
||||
/// Full path to the DPAPI-encrypted `cak_` blob.
|
||||
fn cak_path() -> Result<PathBuf> {
|
||||
Ok(credentials_dir()?.join("agent.cak"))
|
||||
}
|
||||
|
||||
/// Persist `cak` encrypted at rest.
|
||||
///
|
||||
/// Ordering is security-critical (H2 — TOCTOU): the directory ACL is locked
|
||||
/// BEFORE any secret bytes touch the filesystem, and the temp file is written
|
||||
/// INSIDE the already-locked directory, so no ciphertext ever exists at a path
|
||||
/// carrying an inherited (potentially world-readable) ACL:
|
||||
///
|
||||
/// 1. `create_dir_all(dir)` — ensure the directory exists.
|
||||
/// 2. `lock_down_acl(dir)` — remove inherited ACEs and grant SYSTEM +
|
||||
/// Administrators full control, made inheritable `(OI)(CI)` so children
|
||||
/// created afterward are covered. This is an explicit precondition for the
|
||||
/// write that follows — NOT an unstated inheritance assumption.
|
||||
/// 3. DPAPI-machine-encrypt the plaintext.
|
||||
/// 4. Write the ciphertext to a temp file inside the now-locked directory, then
|
||||
/// rename over the target (atomic-ish replace).
|
||||
/// 5. `lock_down_acl(file)` — assert the file's own ACL (belt-and-suspenders; the
|
||||
/// file already inherits the directory's restrictive ACEs).
|
||||
/// 6. C1 read-back: immediately attempt [`load_cak`] to PROVE the running
|
||||
/// security context can read its own store. If it cannot (e.g. a non-SYSTEM
|
||||
/// run wrote a SYSTEM-only store it can no longer read), fail HERE at enroll
|
||||
/// time with an actionable error — rather than silently bricking on the next
|
||||
/// boot when the steady-state path tries to load it.
|
||||
///
|
||||
/// Returns an error (never logs the plaintext) on any failure so the caller can
|
||||
/// surface it / retry.
|
||||
pub fn store_cak(cak: &str) -> Result<()> {
|
||||
// 1 + 2: lock the directory ACL BEFORE writing any secret (H2 / TOCTOU).
|
||||
let dir = credentials_dir()?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("failed to create credentials dir {dir:?}"))?;
|
||||
lock_down_acl(&dir).context("failed to restrict credentials directory ACL")?;
|
||||
|
||||
// 3: encrypt only after the destination directory is locked down.
|
||||
let ciphertext = dpapi_protect(cak.as_bytes()).context("DPAPI encryption of cak_ failed")?;
|
||||
|
||||
// 4: write the temp file INSIDE the already-locked directory, then rename.
|
||||
let path = cak_path()?;
|
||||
let tmp = path.with_extension("cak.tmp");
|
||||
std::fs::write(&tmp, &ciphertext)
|
||||
.with_context(|| format!("failed to write temp credential file {tmp:?}"))?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to place credential file {path:?}"))?;
|
||||
|
||||
// 5: assert the file ACL too (the file already inherits the dir's ACEs).
|
||||
lock_down_acl(&path).context("failed to restrict credential file ACL")?;
|
||||
|
||||
// 6: C1 read-back — confirm THIS context can read back what it just wrote.
|
||||
// Catches the "wrote a SYSTEM-only store from a non-SYSTEM context" footgun at
|
||||
// enroll time instead of as a silent brick on the next launch.
|
||||
match load_cak() {
|
||||
Ok(Some(_)) => {
|
||||
tracing::info!("[ENROLL] stored per-machine credential (encrypted at rest)");
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => Err(anyhow!(
|
||||
"stored the credential but read-back returned nothing — refusing to proceed \
|
||||
with an unverifiable credential store"
|
||||
)),
|
||||
Err(LoadCakError::Io {
|
||||
permission_denied: true,
|
||||
..
|
||||
}) => Err(anyhow!(
|
||||
"[ENROLL] wrote the credential store but cannot read it back in THIS security \
|
||||
context (access denied). The store is ACL'd to SYSTEM + Administrators by \
|
||||
design; the managed agent must run as the GuruConnect SYSTEM service (see \
|
||||
SPEC-018) to read it. Refusing to leave an unreadable store behind."
|
||||
)),
|
||||
Err(e) => Err(anyhow::Error::new(e)
|
||||
.context("stored the credential but the immediate read-back verification failed")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and decrypt the stored `cak_`, or `Ok(None)` if no credential is stored.
|
||||
///
|
||||
/// Error classification (M1) — the caller MUST treat these differently:
|
||||
/// - `Ok(None)` -> no store yet (NotFound or empty); enroll is fine.
|
||||
/// - [`LoadCakError::Io`] -> the store exists but is unreadable in this
|
||||
/// context (open/read error, INCLUDING access-denied). Operational; the caller
|
||||
/// logs it and STOPS — it must NOT silently re-enroll over a store it merely
|
||||
/// cannot read here.
|
||||
/// - [`LoadCakError::Decrypt`] -> the bytes were read but DPAPI decryption
|
||||
/// FAILED (wrong machine / tampered / corrupted). A hard tamper signal; STOP.
|
||||
///
|
||||
/// Only a successful READ whose decrypt fails is the tamper signal — an IO or
|
||||
/// permission error is never conflated with tamper.
|
||||
pub fn load_cak() -> std::result::Result<Option<String>, LoadCakError> {
|
||||
let path = cak_path().map_err(|e| LoadCakError::Path(e.to_string()))?;
|
||||
let ciphertext = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => {
|
||||
let permission_denied = e.kind() == std::io::ErrorKind::PermissionDenied;
|
||||
return Err(LoadCakError::Io {
|
||||
permission_denied,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
if ciphertext.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Reaching here means the READ succeeded — so a decrypt failure now IS the real
|
||||
// tamper / wrong-machine signal (never conflated with an IO/permission error).
|
||||
let plaintext =
|
||||
dpapi_unprotect(&ciphertext).map_err(|e| LoadCakError::Decrypt(e.to_string()))?;
|
||||
let cak = String::from_utf8(plaintext)
|
||||
.map_err(|e| LoadCakError::Decrypt(format!("decrypted bytes were not valid UTF-8: {e}")))?;
|
||||
if cak.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(cak))
|
||||
}
|
||||
|
||||
/// Remove the stored credential (e.g. on revocation / forced re-enroll).
|
||||
/// Succeeds if the file is already absent.
|
||||
///
|
||||
/// Part of the store/load/clear API the spec requires (SPEC-016 item 4). Not yet
|
||||
/// called from a code path — the relay-side `cak_` revocation / forced re-enroll
|
||||
/// flow that drives it is the deferred SPEC-016 Phase B/D server work (the
|
||||
/// `TODO(SPEC-016 Phase B/D): consider revoking existing cak_ on collision` note
|
||||
/// in `server/src/api/enroll.rs`) — so it is retained as part of the complete
|
||||
/// store API and explicitly allowed dead until that server work lands.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_cak() -> Result<()> {
|
||||
let path = cak_path()?;
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
tracing::info!("[ENROLL] cleared stored per-machine credential");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to remove {path:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DPAPI (machine scope)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// DPAPI-machine-encrypt `plaintext` into a self-contained blob.
|
||||
fn dpapi_protect(plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
use windows::Win32::Security::Cryptography::{
|
||||
CryptProtectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPT_INTEGER_BLOB,
|
||||
};
|
||||
|
||||
// CryptProtectData requires a mutable input pointer in the struct, though it
|
||||
// does not modify the bytes; copy into a local Vec to get a *mut without
|
||||
// aliasing the caller's slice.
|
||||
let mut input = plaintext.to_vec();
|
||||
let in_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: u32::try_from(input.len()).context("plaintext too large for DPAPI")?,
|
||||
pbData: input.as_mut_ptr(),
|
||||
};
|
||||
let mut out_blob = CRYPT_INTEGER_BLOB::default();
|
||||
|
||||
// SAFETY: in_blob points at a valid, sized buffer; out_blob is owned here and
|
||||
// its pbData is allocated by DPAPI (freed via LocalFree below). No prompt
|
||||
// struct / entropy / reserved args.
|
||||
unsafe {
|
||||
CryptProtectData(
|
||||
&in_blob,
|
||||
windows::core::PCWSTR::null(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CRYPTPROTECT_LOCAL_MACHINE,
|
||||
&mut out_blob,
|
||||
)
|
||||
.context("CryptProtectData failed")?;
|
||||
}
|
||||
|
||||
let result = copy_and_free_blob(&out_blob);
|
||||
// Best-effort scrub of the transient plaintext copy.
|
||||
input.iter_mut().for_each(|b| *b = 0);
|
||||
|
||||
result.ok_or_else(|| anyhow!("CryptProtectData returned an empty/invalid blob"))
|
||||
}
|
||||
|
||||
/// DPAPI-decrypt a blob previously produced by [`dpapi_protect`] on this machine.
|
||||
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||
use windows::Win32::Security::Cryptography::{
|
||||
CryptUnprotectData, CRYPTPROTECT_LOCAL_MACHINE, CRYPT_INTEGER_BLOB,
|
||||
};
|
||||
|
||||
let mut input = ciphertext.to_vec();
|
||||
let in_blob = CRYPT_INTEGER_BLOB {
|
||||
cbData: u32::try_from(input.len()).context("ciphertext too large for DPAPI")?,
|
||||
pbData: input.as_mut_ptr(),
|
||||
};
|
||||
let mut out_blob = CRYPT_INTEGER_BLOB::default();
|
||||
|
||||
// SAFETY: as in dpapi_protect — valid sized input, owned output freed below.
|
||||
unsafe {
|
||||
CryptUnprotectData(
|
||||
&in_blob,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CRYPTPROTECT_LOCAL_MACHINE,
|
||||
&mut out_blob,
|
||||
)
|
||||
.context("CryptUnprotectData failed")?;
|
||||
}
|
||||
|
||||
copy_and_free_blob(&out_blob)
|
||||
.ok_or_else(|| anyhow!("CryptUnprotectData returned an empty/invalid blob"))
|
||||
}
|
||||
|
||||
/// Copy a DPAPI output blob into an owned `Vec` and `LocalFree` the DPAPI buffer.
|
||||
///
|
||||
/// Returns `Some(bytes)` on success, `None` if the blob is null/empty. Always
|
||||
/// frees `pbData` when non-null (DPAPI allocates it with `LocalAlloc`).
|
||||
fn copy_and_free_blob(
|
||||
blob: &windows::Win32::Security::Cryptography::CRYPT_INTEGER_BLOB,
|
||||
) -> Option<Vec<u8>> {
|
||||
use windows::Win32::Foundation::{LocalFree, HLOCAL};
|
||||
|
||||
if blob.pbData.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: DPAPI guarantees pbData points at cbData valid bytes on success.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(blob.pbData, blob.cbData as usize).to_vec() };
|
||||
// SAFETY: pbData was allocated by DPAPI via LocalAlloc; free it once.
|
||||
unsafe {
|
||||
let _ = LocalFree(HLOCAL(blob.pbData as *mut core::ffi::c_void));
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ACL hardening
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Restrict `path` (file or directory) to SYSTEM + Administrators full control,
|
||||
/// removing inherited ACEs so a permissive parent grant cannot leak read access.
|
||||
///
|
||||
/// Implemented via `icacls` — the documented, auditable mechanism — rather than
|
||||
/// hand-rolling a security descriptor through `SetNamedSecurityInfoW` (hundreds
|
||||
/// of lines of SID/ACL FFI). `icacls` ships on every supported Windows target.
|
||||
/// A failure here is surfaced (the caller treats inability to lock down the
|
||||
/// credential store as a hard error) but the well-known SIDs `*S-1-5-18`
|
||||
/// (LocalSystem) and `*S-1-5-32-544` (BUILTIN\Administrators) are language- and
|
||||
/// locale-independent, so this does not break on localized Windows.
|
||||
fn lock_down_acl(path: &std::path::Path) -> Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
let path_str = path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("credential path is not valid UTF-8: {path:?}"))?;
|
||||
|
||||
// /inheritance:r -> remove inherited ACEs (drop the permissive parent grant)
|
||||
// /grant:r -> replace any existing explicit grants for the principal
|
||||
// *S-1-5-18 -> LocalSystem; *S-1-5-32-544 -> BUILTIN\Administrators
|
||||
let output = Command::new("icacls")
|
||||
.arg(path_str)
|
||||
.args([
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
"*S-1-5-18:(OI)(CI)F",
|
||||
"/grant:r",
|
||||
"*S-1-5-32-544:(OI)(CI)F",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.context("failed to invoke icacls to harden credential ACL")?;
|
||||
|
||||
if !output.status.success() {
|
||||
// icacls writes its diagnostics to stdout; surface the code only (no
|
||||
// credential material is ever passed to icacls, only the path).
|
||||
return Err(anyhow!(
|
||||
"icacls failed to harden {path_str} (exit {:?})",
|
||||
output.status.code()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// DPAPI round-trips on the same machine: protect then unprotect must recover
|
||||
/// the exact plaintext. (Runs on the build/test host, which IS the same
|
||||
/// machine — the machine-scope key is available to any process here.)
|
||||
#[test]
|
||||
fn dpapi_roundtrip_recovers_plaintext() {
|
||||
let secret = b"cak_test_value_0123456789abcdef";
|
||||
let blob = dpapi_protect(secret).expect("DPAPI protect should succeed on this machine");
|
||||
assert_ne!(
|
||||
blob.as_slice(),
|
||||
secret.as_slice(),
|
||||
"ciphertext must differ from plaintext"
|
||||
);
|
||||
let recovered = dpapi_unprotect(&blob).expect("DPAPI unprotect should succeed");
|
||||
assert_eq!(recovered, secret, "round-trip must recover the exact bytes");
|
||||
}
|
||||
|
||||
/// A non-empty plaintext yields a non-empty, differing blob, and an empty
|
||||
/// input is handled (DPAPI accepts zero-length and round-trips to empty).
|
||||
#[test]
|
||||
fn dpapi_roundtrip_handles_varied_lengths() {
|
||||
for plaintext in [b"x".as_slice(), b"cak_".as_slice(), &[0u8; 256]] {
|
||||
let blob = dpapi_protect(plaintext).expect("protect");
|
||||
let back = dpapi_unprotect(&blob).expect("unprotect");
|
||||
assert_eq!(back.as_slice(), plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tampering with the ciphertext must make decryption FAIL rather than return
|
||||
/// garbage — DPAPI authenticates its blobs.
|
||||
#[test]
|
||||
fn dpapi_rejects_tampered_blob() {
|
||||
let mut blob = dpapi_protect(b"cak_tamper_target").expect("protect");
|
||||
// Flip a byte in the middle of the blob.
|
||||
let mid = blob.len() / 2;
|
||||
blob[mid] ^= 0xFF;
|
||||
assert!(
|
||||
dpapi_unprotect(&blob).is_err(),
|
||||
"a tampered DPAPI blob must fail to decrypt"
|
||||
);
|
||||
}
|
||||
}
|
||||
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
|
||||
//!
|
||||
//! Encodes captured frames for transmission. Supports:
|
||||
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode)
|
||||
//! - VP9 software encoding (universal fallback)
|
||||
//! - H264 hardware encoding (when GPU available)
|
||||
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode; the guaranteed
|
||||
//! fallback and the current default).
|
||||
//! - 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;
|
||||
|
||||
pub use capability::supports_hardware_h264;
|
||||
pub use raw::RawEncoder;
|
||||
|
||||
use crate::capture::CapturedFrame;
|
||||
use crate::proto::{DirtyRect as ProtoDirtyRect, RawFrame, VideoFrame};
|
||||
use crate::proto::{video_frame, VideoCodec, VideoFrame};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Encoded frame ready for transmission
|
||||
@@ -23,30 +34,191 @@ pub struct EncodedFrame {
|
||||
pub size: usize,
|
||||
|
||||
/// Whether this is a keyframe (full frame)
|
||||
// Set by encoders; not yet read by the transmit path.
|
||||
#[allow(dead_code)]
|
||||
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 {
|
||||
/// Encode a captured frame
|
||||
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame>;
|
||||
|
||||
/// Request a keyframe on next encode
|
||||
#[allow(dead_code)]
|
||||
fn request_keyframe(&mut self);
|
||||
|
||||
/// Get encoder name/type
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Create an encoder based on configuration
|
||||
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> {
|
||||
/// Map a configured/negotiated codec string to a `VideoCodec`.
|
||||
///
|
||||
/// 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() {
|
||||
"raw" | "zstd" => Ok(Box::new(RawEncoder::new(quality)?)),
|
||||
// "vp9" => Ok(Box::new(Vp9Encoder::new(quality)?)),
|
||||
// "h264" => Ok(Box::new(H264Encoder::new(quality)?)),
|
||||
"auto" | _ => {
|
||||
// Default to raw for now (best for LAN)
|
||||
Ok(Box::new(RawEncoder::new(quality)?))
|
||||
}
|
||||
"h264" => VideoCodec::H264,
|
||||
// "h265"/"hevc" are future opt-in (TODO) — treat as raw for now so we
|
||||
// never select an unimplemented codec.
|
||||
_ => VideoCodec::Raw,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
#[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 stride = (width * 4) as usize;
|
||||
|
||||
let blocks_x = (width + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||
let blocks_y = (height + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||
let blocks_x = width.div_ceil(BLOCK_SIZE);
|
||||
let blocks_y = height.div_ceil(BLOCK_SIZE);
|
||||
|
||||
for by in 0..blocks_y {
|
||||
for bx in 0..blocks_x {
|
||||
|
||||
384
agent/src/enroll.rs
Normal file
384
agent/src/enroll.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
//! First-run self-enrollment client (SPEC-016 Phase B, item 4).
|
||||
//!
|
||||
//! When the agent runs as a persistent (`PermanentAgent`) install with NO stored
|
||||
//! `cak_` but WITH an `enrollment_key` + `site_code`, it walks through the
|
||||
//! public, unauthenticated `POST /api/enroll` door: it presents its site
|
||||
//! credentials and its hardware-derived `machine_uid`, and — on success — the
|
||||
//! server mints and returns a per-machine `cak_` operating credential exactly
|
||||
//! once. The agent persists that `cak_` encrypted at rest
|
||||
//! ([`crate::credential_store`]) and connects with it; on every later run it uses
|
||||
//! the stored `cak_` directly and never re-enrolls.
|
||||
//!
|
||||
//! Server contract consumed (must match `server/src/api/enroll.rs`):
|
||||
//! - Request: `{ site_code, enrollment_key, machine_uid, hostname,
|
||||
//! labels:{company,site,department,device_type,tags} }`.
|
||||
//! - `201 Created` -> new enrollment; body has `key` (the `cak_`).
|
||||
//! - `200 OK` -> reuse (re-image / re-install); body has `key`.
|
||||
//! - `202 Accepted` -> `collision_pending`; NO key — operator must confirm in
|
||||
//! the dashboard before the endpoint can connect.
|
||||
//! - `401 Unauthorized` -> `ENROLL_REJECTED` (bad/rotated key or unknown site):
|
||||
//! terminal-ish config problem, back off long.
|
||||
//! - `409 Conflict` -> `ENROLL_SITE_CONFLICT` (machine bound to another site):
|
||||
//! terminal-ish, requires the operator reassignment flow; back off long.
|
||||
//! - `429 Too Many Requests` -> rate-limited; back off and retry.
|
||||
//!
|
||||
//! SECURITY: never log the `enrollment_key` or the minted `cak_`. Only states,
|
||||
//! dispositions, and the (non-secret) `machine_uid`/`site_code` are logged.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// `POST /api/enroll` request body — mirrors `enroll::EnrollRequest`.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EnrollRequest<'a> {
|
||||
site_code: &'a str,
|
||||
enrollment_key: &'a str,
|
||||
machine_uid: &'a str,
|
||||
hostname: &'a str,
|
||||
labels: EnrollLabels<'a>,
|
||||
}
|
||||
|
||||
/// Labels carried at enrollment — mirrors `enroll::EnrollLabels`.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EnrollLabels<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
company: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
site: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
department: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
device_type: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "slice_is_empty")]
|
||||
tags: &'a [String],
|
||||
}
|
||||
|
||||
/// `skip_serializing_if` predicate for the `tags` slice — `Vec::is_empty` cannot
|
||||
/// bind a `&&[String]`, so use a slice-typed helper.
|
||||
fn slice_is_empty(s: &[String]) -> bool {
|
||||
s.is_empty()
|
||||
}
|
||||
|
||||
/// `POST /api/enroll` success body — mirrors `enroll::EnrollResponse`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EnrollResponse {
|
||||
#[allow(dead_code)]
|
||||
machine_id: String,
|
||||
#[serde(default)]
|
||||
key: Option<String>,
|
||||
enrollment_state: String,
|
||||
disposition: String,
|
||||
}
|
||||
|
||||
/// Backoff after a retryable failure (429 / network / 5xx).
|
||||
const RETRYABLE_BACKOFF: Duration = Duration::from_secs(30);
|
||||
/// Backoff after a terminal-ish config failure (401 / 409) or collision-pending.
|
||||
/// These won't fix themselves without operator action, so retry slowly rather
|
||||
/// than hot-looping while still recovering automatically once it IS fixed.
|
||||
const TERMINAL_BACKOFF: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Drive enrollment until a `cak_` is issued, persisting it into the credential
|
||||
/// store on success and loading it into `config.api_key`.
|
||||
///
|
||||
/// Loops with backoff across retryable failures (it must not give up — a managed
|
||||
/// machine left running should eventually enroll once the server/site is healthy)
|
||||
/// and across collision-pending (HTTP 202: it keeps re-checking on a slow cadence
|
||||
/// until an operator confirms the endpoint in the dashboard and the server begins
|
||||
/// issuing a key). Returns `Ok(())` only once a `cak_` is stored. The only `Err`
|
||||
/// returns are unrecoverable local faults (missing config, an un-persistable
|
||||
/// credential) — network/HTTP failures are retried, never propagated.
|
||||
pub async fn run_enrollment(config: &mut Config) -> Result<()> {
|
||||
let site_code = config
|
||||
.site_code
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("enrollment requested but no site_code is configured"))?;
|
||||
let enrollment_key = config
|
||||
.enrollment_key
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("enrollment requested but no enrollment_key is configured"))?;
|
||||
|
||||
let https_base = config.https_base()?;
|
||||
let machine_uid = crate::identity::machine_uid();
|
||||
let hostname = config.hostname();
|
||||
|
||||
tracing::info!(
|
||||
"[ENROLL] first-run enrollment: site_code={} machine_uid={} hostname={}",
|
||||
site_code,
|
||||
machine_uid,
|
||||
hostname
|
||||
);
|
||||
|
||||
loop {
|
||||
match attempt_enroll(
|
||||
&https_base,
|
||||
&site_code,
|
||||
&enrollment_key,
|
||||
&machine_uid,
|
||||
&hostname,
|
||||
config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AttemptResult::Issued(cak)) => {
|
||||
// Persist encrypted-at-rest, then load into the live config so the
|
||||
// transport authenticates with the new per-machine credential.
|
||||
#[cfg(windows)]
|
||||
crate::credential_store::store_cak(&cak)
|
||||
.context("failed to persist issued cak_ to the credential store")?;
|
||||
config.api_key = cak;
|
||||
// Enrollment material is single-use; drop it so it is not retained
|
||||
// in memory or accidentally reused.
|
||||
config.enrollment_key = None;
|
||||
tracing::info!("[ENROLL] enrollment complete; connecting with per-machine key");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(AttemptResult::Pending) => {
|
||||
tracing::warn!(
|
||||
"[ENROLL] pending operator confirmation (machine_uid collision); \
|
||||
this machine cannot connect until confirmed in the dashboard. \
|
||||
Re-checking in {}s.",
|
||||
TERMINAL_BACKOFF.as_secs()
|
||||
);
|
||||
tokio::time::sleep(TERMINAL_BACKOFF).await;
|
||||
}
|
||||
Err(AttemptError::Terminal(msg)) => {
|
||||
tracing::error!(
|
||||
"[ENROLL] enrollment refused (operator action required): {msg}. \
|
||||
Retrying in {}s.",
|
||||
TERMINAL_BACKOFF.as_secs()
|
||||
);
|
||||
tokio::time::sleep(TERMINAL_BACKOFF).await;
|
||||
}
|
||||
Err(AttemptError::Retryable(msg)) => {
|
||||
tracing::warn!(
|
||||
"[ENROLL] transient enrollment failure: {msg}. Retrying in {}s.",
|
||||
RETRYABLE_BACKOFF.as_secs()
|
||||
);
|
||||
tokio::time::sleep(RETRYABLE_BACKOFF).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of one HTTP enrollment attempt.
|
||||
enum AttemptResult {
|
||||
/// A `cak_` was issued (201/200). Carries the plaintext (never logged).
|
||||
Issued(String),
|
||||
/// Collision-gated (202): no key issued.
|
||||
Pending,
|
||||
}
|
||||
|
||||
/// Failure classes that drive the backoff policy.
|
||||
enum AttemptError {
|
||||
/// 401/409 — won't fix without operator action; back off long but keep trying.
|
||||
Terminal(String),
|
||||
/// 429 / network / 5xx / decode — transient; short backoff.
|
||||
Retryable(String),
|
||||
}
|
||||
|
||||
/// Make one `POST /api/enroll` call and classify the response per the contract.
|
||||
async fn attempt_enroll(
|
||||
https_base: &str,
|
||||
site_code: &str,
|
||||
enrollment_key: &str,
|
||||
machine_uid: &str,
|
||||
hostname: &str,
|
||||
config: &Config,
|
||||
) -> std::result::Result<AttemptResult, AttemptError> {
|
||||
let url = format!("{}/api/enroll", https_base.trim_end_matches('/'));
|
||||
|
||||
let body = EnrollRequest {
|
||||
site_code,
|
||||
enrollment_key,
|
||||
machine_uid,
|
||||
hostname,
|
||||
labels: EnrollLabels {
|
||||
company: config.company.as_deref().filter(|s| !s.is_empty()),
|
||||
site: config.site.as_deref().filter(|s| !s.is_empty()),
|
||||
department: config.department.as_deref().filter(|s| !s.is_empty()),
|
||||
device_type: config.device_type.as_deref().filter(|s| !s.is_empty()),
|
||||
tags: &config.tags,
|
||||
},
|
||||
};
|
||||
|
||||
let client = build_client().map_err(|e| AttemptError::Retryable(e.to_string()))?;
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AttemptError::Retryable(format!("request to {url} failed: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
match status.as_u16() {
|
||||
// New (201) or reuse (200): body carries the cak_.
|
||||
200 | 201 => {
|
||||
let parsed: EnrollResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AttemptError::Retryable(format!("malformed success body: {e}")))?;
|
||||
match parsed.key {
|
||||
Some(cak) if !cak.is_empty() => {
|
||||
tracing::info!(
|
||||
"[ENROLL] server accepted enrollment: state={} disposition={}",
|
||||
parsed.enrollment_state,
|
||||
parsed.disposition
|
||||
);
|
||||
Ok(AttemptResult::Issued(cak))
|
||||
}
|
||||
// 2xx with no key is contract-violating for the active path; treat
|
||||
// as retryable so we don't silently spin or crash.
|
||||
_ => Err(AttemptError::Retryable(format!(
|
||||
"server returned {} with no key (state={}, disposition={})",
|
||||
status, parsed.enrollment_state, parsed.disposition
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// Collision-gated: pending operator confirmation, no key.
|
||||
202 => {
|
||||
// Body decode is best-effort here; the status alone is authoritative.
|
||||
Ok(AttemptResult::Pending)
|
||||
}
|
||||
|
||||
// Bad/rotated enrollment key or unknown site code.
|
||||
401 => Err(AttemptError::Terminal(
|
||||
"ENROLL_REJECTED — the site code or enrollment key is invalid or rotated; \
|
||||
this installer needs a current per-site key"
|
||||
.to_string(),
|
||||
)),
|
||||
|
||||
// Machine already enrolled at a different site.
|
||||
409 => Err(AttemptError::Terminal(
|
||||
"ENROLL_SITE_CONFLICT — this machine is already enrolled at another site; \
|
||||
a deliberate move requires the operator-initiated reassignment flow"
|
||||
.to_string(),
|
||||
)),
|
||||
|
||||
// Rate-limited / locked out — honor Retry-After if present, else default.
|
||||
429 => {
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get(reqwest::header::RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
Err(AttemptError::Retryable(match retry_after {
|
||||
Some(secs) => format!("RATE_LIMITED (retry-after {secs}s)"),
|
||||
None => "RATE_LIMITED".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// 5xx or anything else — transient from the agent's perspective.
|
||||
_ => Err(AttemptError::Retryable(format!(
|
||||
"unexpected enrollment response: HTTP {status}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the HTTP client for enrollment, matching the update path's TLS posture
|
||||
/// (`rustls`, with an opt-in dev-insecure escape hatch in debug builds only).
|
||||
fn build_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(dev_insecure_tls())
|
||||
.build()
|
||||
.context("failed to build enrollment HTTP client")
|
||||
}
|
||||
|
||||
/// Dev-only TLS bypass — identical policy to `update::dev_insecure_tls`: only in
|
||||
/// debug builds AND only when `GURUCONNECT_DEV_INSECURE_TLS` is set. NEVER active
|
||||
/// in a release build.
|
||||
fn dev_insecure_tls() -> bool {
|
||||
if cfg!(debug_assertions) && std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_ok() {
|
||||
tracing::warn!(
|
||||
"[ENROLL] TLS verification DISABLED (dev-insecure mode) — DO NOT use in production"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The request body must serialize to exactly the field names the Phase A
|
||||
/// server deserializes (`enroll::EnrollRequest` / `EnrollLabels`). A drift here
|
||||
/// is a silent enrollment failure, so pin the wire shape.
|
||||
#[test]
|
||||
fn request_serializes_to_the_server_contract() {
|
||||
let tags = vec!["prod".to_string()];
|
||||
let req = EnrollRequest {
|
||||
site_code: "ACME-HQ",
|
||||
enrollment_key: "cek_secret",
|
||||
machine_uid: "muid_abc",
|
||||
hostname: "WS-01",
|
||||
labels: EnrollLabels {
|
||||
company: Some("Acme"),
|
||||
site: Some("HQ"),
|
||||
department: Some("IT"),
|
||||
device_type: Some("workstation"),
|
||||
tags: &tags,
|
||||
},
|
||||
};
|
||||
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["site_code"], "ACME-HQ");
|
||||
assert_eq!(v["enrollment_key"], "cek_secret");
|
||||
assert_eq!(v["machine_uid"], "muid_abc");
|
||||
assert_eq!(v["hostname"], "WS-01");
|
||||
assert_eq!(v["labels"]["company"], "Acme");
|
||||
assert_eq!(v["labels"]["site"], "HQ");
|
||||
assert_eq!(v["labels"]["department"], "IT");
|
||||
assert_eq!(v["labels"]["device_type"], "workstation");
|
||||
assert_eq!(v["labels"]["tags"][0], "prod");
|
||||
}
|
||||
|
||||
/// Empty optional labels are omitted (the server defaults them), and an empty
|
||||
/// tag list is not serialized — keeping the body minimal for a thin installer.
|
||||
#[test]
|
||||
fn request_omits_empty_optional_labels() {
|
||||
let tags: Vec<String> = Vec::new();
|
||||
let req = EnrollRequest {
|
||||
site_code: "S",
|
||||
enrollment_key: "cek_x",
|
||||
machine_uid: "muid_x",
|
||||
hostname: "H",
|
||||
labels: EnrollLabels {
|
||||
company: None,
|
||||
site: None,
|
||||
department: None,
|
||||
device_type: None,
|
||||
tags: &tags,
|
||||
},
|
||||
};
|
||||
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
|
||||
let labels = v["labels"].as_object().unwrap();
|
||||
assert!(!labels.contains_key("company"));
|
||||
assert!(!labels.contains_key("department"));
|
||||
assert!(!labels.contains_key("tags"));
|
||||
}
|
||||
|
||||
/// The success response decoder must accept both a key-bearing active body and
|
||||
/// a keyless pending body (mirrors `EnrollResponse` with `skip_serializing_if`).
|
||||
#[test]
|
||||
fn response_decodes_active_and_pending_shapes() {
|
||||
let active: EnrollResponse = serde_json::from_str(
|
||||
r#"{"machine_id":"m1","key":"cak_live","enrollment_state":"active","disposition":"new"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active.key.as_deref(), Some("cak_live"));
|
||||
assert_eq!(active.enrollment_state, "active");
|
||||
|
||||
let pending: EnrollResponse = serde_json::from_str(
|
||||
r#"{"machine_id":"m2","enrollment_state":"pending","disposition":"collision_pending"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pending.key.is_none());
|
||||
assert_eq!(pending.disposition, "collision_pending");
|
||||
}
|
||||
}
|
||||
673
agent/src/identity.rs
Normal file
673
agent/src/identity.rs
Normal file
@@ -0,0 +1,673 @@
|
||||
//! 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 of a hardware identity string. The id is derived from
|
||||
//! the **hardware salt ONLY** whenever any durable hardware signal is readable:
|
||||
//! the **SMBIOS system UUID** (`Win32_ComputerSystemProduct.UUID`), or — when
|
||||
//! that is absent / all-zeros / all-FFs (some OEMs/hypervisors) — the
|
||||
//! **motherboard serial** (`Win32_BaseBoard.SerialNumber`) plus the **primary
|
||||
//! disk serial**. A fixed namespace string is mixed in for domain separation.
|
||||
//! The OS machine GUID
|
||||
//! (`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`, a `REG_SZ`) is used
|
||||
//! ONLY as a last-resort signal when NO hardware salt is readable. The raw
|
||||
//! signals are never returned — only the opaque `muid_<hex>` derived from them.
|
||||
//! - **Non-Windows (and Windows with no readable signal at all):** a random UUID
|
||||
//! persisted in the agent's data directory, read back on subsequent runs so it
|
||||
//! is stable across calls and process restarts.
|
||||
//!
|
||||
//! **Stability contract (SPEC-016 item 1):**
|
||||
//! - **Salted path (hardware signal present) is re-image-stable:** the digest
|
||||
//! mixes only durable hardware signals (SMBIOS UUID, or board + disk serial) and
|
||||
//! a fixed namespace — NOT the `MachineGuid`, which Windows regenerates on every
|
||||
//! OS install/re-image. So the `machine_uid` survives both a reboot AND an OS
|
||||
//! re-image on the SAME hardware (the re-image dedup goal), while distinct
|
||||
//! physical boxes stay distinct.
|
||||
//! - **MachineGuid-only path is the volatile floor:** when no hardware salt is
|
||||
//! readable, the id anchors on the `MachineGuid` alone. This is stable across
|
||||
//! reboots but NOT across a re-image (the GUID is regenerated). This degraded
|
||||
//! path is logged at WARN so the server-side collision gate operator has a clue.
|
||||
//!
|
||||
//! 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 lives in `POST /api/enroll` (SPEC-016 Phase A) and the relay
|
||||
//! connect path.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Prefix marking the value as an opaque machine-uid (vs. a raw GUID/UUID).
|
||||
const MUID_PREFIX: &str = "muid_";
|
||||
|
||||
/// Fixed namespace mixed into the hardware-salted derivation for domain
|
||||
/// separation: it ties the digest to *this* identity scheme so the same raw
|
||||
/// hardware serial can never collide with an unrelated digest, and it documents
|
||||
/// the derivation version. It is NOT a secret — it is a constant.
|
||||
const MUID_NAMESPACE: &str = "guruconnect:machine_uid:v1";
|
||||
|
||||
/// 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 from a durable hardware salt when one
|
||||
/// is readable (re-image-stable; see the module docs), falling back to the OS
|
||||
/// machine GUID alone (reboot-stable floor) and finally — when no signal at all is
|
||||
/// readable, or on any non-Windows platform — a persisted random UUID, 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 {
|
||||
// PRIMARY signal (SPEC-016 item 1): a durable hardware salt — SMBIOS system
|
||||
// UUID if usable, else motherboard + disk serial. When ANY hardware salt is
|
||||
// readable we derive the uid from the salt ALONE (plus a fixed namespace),
|
||||
// deliberately EXCLUDING the MachineGuid: Windows regenerates the MachineGuid
|
||||
// on every OS install/re-image, so mixing it in would break re-image dedup.
|
||||
// The salted digest survives both reboot AND re-image on the same hardware.
|
||||
if let Some(salt) = hardware_salt() {
|
||||
tracing::info!("machine_uid derived from durable hardware salt (re-image-stable)");
|
||||
return derive_uid(&format!("{MUID_NAMESPACE}|{salt}"));
|
||||
}
|
||||
|
||||
// LAST-RESORT signal: no hardware salt is readable, so anchor on the OS
|
||||
// MachineGuid alone. This is the volatile FLOOR — stable across reboots but
|
||||
// NOT across an OS re-image (the GUID is regenerated). We WARN so the
|
||||
// server-side collision-gate operator knows this endpoint's uid is not
|
||||
// re-image-stable. The MachineGuid itself is never logged.
|
||||
match read_machine_guid() {
|
||||
Ok(guid) if !guid.trim().is_empty() => {
|
||||
tracing::warn!(
|
||||
"machine_uid: no durable hardware salt readable; anchoring on MachineGuid \
|
||||
ONLY — this id is reboot-stable but NOT re-image-stable"
|
||||
);
|
||||
derive_uid(&format!("{MUID_NAMESPACE}|machineguid:{}", guid.trim()))
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!(
|
||||
"machine_uid: no hardware salt and MachineGuid registry value was empty; \
|
||||
falling back to persisted machine_uid"
|
||||
);
|
||||
persisted_uid()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"machine_uid: no hardware salt and failed to read MachineGuid ({e}); \
|
||||
falling back to persisted machine_uid"
|
||||
);
|
||||
persisted_uid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the durable hardware salt for the `machine_uid` (Windows only).
|
||||
///
|
||||
/// This is the PRIMARY identity signal: when it returns `Some(salt)`, the caller
|
||||
/// derives the uid from the salt ALONE (re-image-stable). Returns `Some(salt)`
|
||||
/// where `salt` is a deterministic, normalized concatenation of usable hardware
|
||||
/// signals, or `None` when nothing durable is readable (in which case the caller
|
||||
/// degrades to anchoring on the MachineGuid alone — the volatile floor).
|
||||
///
|
||||
/// Order of preference, per SPEC-016 item 1:
|
||||
/// 1. SMBIOS system UUID (`Win32_ComputerSystemProduct.UUID`) — when present and
|
||||
/// not a degenerate placeholder (all-zeros / all-FFs, which some OEMs and
|
||||
/// hypervisor templates emit).
|
||||
/// 2. Fallback: motherboard serial (`Win32_BaseBoard.SerialNumber`) + primary
|
||||
/// disk serial — combined so a single weak signal does not stand alone.
|
||||
///
|
||||
/// Each component is read via a narrow PowerShell CIM query (see
|
||||
/// [`query_cim_property`]); the values are normalized (trimmed, upper-cased) so
|
||||
/// trivial formatting drift never changes the digest.
|
||||
#[cfg(windows)]
|
||||
fn hardware_salt() -> Option<String> {
|
||||
if let Some(uuid) = smbios_uuid() {
|
||||
return Some(format!("smbios:{uuid}"));
|
||||
}
|
||||
|
||||
// SMBIOS UUID unusable — fall back to board + disk serial. Use whichever of
|
||||
// the two are readable; require at least one to be present, otherwise there
|
||||
// is no durable salt and we return None.
|
||||
let board = normalize_signal(query_cim_property("Win32_BaseBoard", "SerialNumber").as_deref());
|
||||
let disk = primary_disk_serial();
|
||||
|
||||
match (board, disk) {
|
||||
(Some(b), Some(d)) => Some(format!("board:{b}|disk:{d}")),
|
||||
(Some(b), None) => Some(format!("board:{b}")),
|
||||
(None, Some(d)) => Some(format!("disk:{d}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The SMBIOS system UUID, or `None` if absent or a degenerate placeholder.
|
||||
///
|
||||
/// Some OEMs ship an all-zeros UUID and some hypervisor templates clone an
|
||||
/// all-FFs (or all-zeros) UUID; either is worthless as a distinguishing signal,
|
||||
/// so we reject both and let the caller fall back to board/disk serial.
|
||||
#[cfg(windows)]
|
||||
fn smbios_uuid() -> Option<String> {
|
||||
let raw =
|
||||
normalize_signal(query_cim_property("Win32_ComputerSystemProduct", "UUID").as_deref())?;
|
||||
|
||||
// Reject degenerate placeholders (ignoring dashes): all-zeros or all-FFs.
|
||||
let hex: String = raw.chars().filter(|c| *c != '-').collect();
|
||||
let all_zero = !hex.is_empty() && hex.chars().all(|c| c == '0');
|
||||
let all_ff = !hex.is_empty() && hex.chars().all(|c| c == 'F');
|
||||
if hex.is_empty() || all_zero || all_ff {
|
||||
tracing::debug!("SMBIOS UUID is absent or a degenerate placeholder; using fallback salt");
|
||||
return None;
|
||||
}
|
||||
Some(raw)
|
||||
}
|
||||
|
||||
/// The serial number of the primary (boot/index-0) physical disk, normalized.
|
||||
///
|
||||
/// Prefers the disk whose `Index == 0` (the conventional boot disk); falls back
|
||||
/// to the first disk that reports any serial. Returns `None` if no disk reports a
|
||||
/// usable serial.
|
||||
#[cfg(windows)]
|
||||
fn primary_disk_serial() -> Option<String> {
|
||||
// One narrow query: index + serial for all physical disks, sorted by index,
|
||||
// emitted as `index<TAB>serial` lines. Parse the lowest-index non-empty serial.
|
||||
let script = "Get-CimInstance -ClassName Win32_DiskDrive | \
|
||||
Sort-Object Index | \
|
||||
ForEach-Object { \"$($_.Index)`t$($_.SerialNumber)\" }";
|
||||
let out = run_powershell(script)?;
|
||||
for line in out.lines() {
|
||||
let mut parts = line.splitn(2, '\t');
|
||||
let _index = parts.next();
|
||||
if let Some(serial) = parts.next() {
|
||||
if let Some(n) = normalize_signal(Some(serial)) {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a single property of a single-instance CIM class via PowerShell.
|
||||
///
|
||||
/// Returns the raw (untrimmed) first non-empty line of output, or `None`. This is
|
||||
/// a deliberately narrow shell-out rather than a full WMI/COM binding: the agent
|
||||
/// already has no WMI crate, and a COM `IWbemServices` binding for two scalar
|
||||
/// reads would be far more code and unsafe surface for no benefit. PowerShell's
|
||||
/// CIM cmdlets are present on every supported Windows target (7 SP1+/2008 R2+
|
||||
/// ship WMI; CIM cmdlets ship from PowerShell 3.0 / WMF 3.0, universally present
|
||||
/// on currently-supported builds).
|
||||
#[cfg(windows)]
|
||||
fn query_cim_property(class: &str, property: &str) -> Option<String> {
|
||||
// `(Get-CimInstance -ClassName X).Property` — single scalar, no formatting.
|
||||
let script = format!("(Get-CimInstance -ClassName {class}).{property}");
|
||||
let out = run_powershell(&script)?;
|
||||
out.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Wall-clock bound on a single PowerShell hardware-signal query.
|
||||
///
|
||||
/// A wedged WMI/CIM provider can hang indefinitely; without a bound that would
|
||||
/// hang agent startup forever. On timeout we kill the child and treat the signal
|
||||
/// as missing (fall back through the chain) — never panic.
|
||||
#[cfg(windows)]
|
||||
const POWERSHELL_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Run a short PowerShell snippet and capture stdout, or `None` on any failure
|
||||
/// (including a wall-clock timeout).
|
||||
///
|
||||
/// Hidden window (`CREATE_NO_WINDOW`) so an interactive desktop never flashes a
|
||||
/// console; `-NonInteractive -NoProfile` for determinism and speed. The call is
|
||||
/// spawned and waited on with a [`POWERSHELL_QUERY_TIMEOUT`] bound so a stuck WMI
|
||||
/// provider cannot wedge startup; on timeout the child is killed and the signal is
|
||||
/// treated as missing. Never logs the captured output (it carries hardware
|
||||
/// identifiers).
|
||||
#[cfg(windows)]
|
||||
fn run_powershell(script: &str) -> Option<String> {
|
||||
use std::io::Read;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Instant;
|
||||
|
||||
// CREATE_NO_WINDOW — avoid a console flash on the interactive desktop.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
let mut child = match Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!("could not run hardware-signal query ({e}); ignoring this signal");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for exit with a wall-clock bound. We spin with a short sleep rather than
|
||||
// a reader thread: the queries are infrequent (startup only) and the loop keeps
|
||||
// the timeout logic simple and panic-free.
|
||||
let deadline = Instant::now() + POWERSHELL_QUERY_TIMEOUT;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) => {
|
||||
if Instant::now() >= deadline {
|
||||
// Wedged provider: kill and treat as a missing signal.
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
tracing::debug!(
|
||||
"hardware-signal query exceeded {}s timeout; killed and ignoring this signal",
|
||||
POWERSHELL_QUERY_TIMEOUT.as_secs()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("error waiting on hardware-signal query ({e}); ignoring");
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
tracing::debug!(
|
||||
"hardware-signal query exited with status {:?}; ignoring this signal",
|
||||
status.code()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// The process exited; drain its captured stdout.
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut out) = child.stdout.take() {
|
||||
if let Err(e) = out.read_to_end(&mut buf) {
|
||||
tracing::debug!("error reading hardware-signal query output ({e}); ignoring");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let s = String::from_utf8_lossy(&buf).trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a raw hardware signal: trim, upper-case, drop if empty. Upper-casing
|
||||
/// makes the digest stable against vendor case drift; trimming removes stray
|
||||
/// whitespace WMI sometimes pads serials with.
|
||||
#[cfg(windows)]
|
||||
fn normalize_signal(raw: Option<&str>) -> Option<String> {
|
||||
let v = raw?.trim();
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(v.to_uppercase())
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
/// Pin the EXACT derivation strings that `compute_machine_uid` builds, so these
|
||||
/// pure-function tests track the production logic. Keep in lock-step with
|
||||
/// `compute_machine_uid`.
|
||||
#[cfg(windows)]
|
||||
fn salted_uid(salt: &str) -> String {
|
||||
derive_uid(&format!("{MUID_NAMESPACE}|{salt}"))
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn machineguid_only_uid(guid: &str) -> String {
|
||||
derive_uid(&format!("{MUID_NAMESPACE}|machineguid:{guid}"))
|
||||
}
|
||||
|
||||
/// H1 RE-IMAGE STABILITY: when a hardware salt is present, the uid is derived
|
||||
/// from the salt ALONE — the MachineGuid is NOT part of the input. So holding
|
||||
/// the hardware signals fixed while varying the MachineGuid MUST yield the SAME
|
||||
/// uid. This is exactly the re-image case: an OS re-image regenerates the
|
||||
/// MachineGuid but leaves SMBIOS UUID / board+disk serial unchanged, and the
|
||||
/// machine_uid must not move (otherwise dedup breaks). We prove it by showing
|
||||
/// the salted derivation has no MachineGuid term to vary.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn salted_uid_is_reimage_stable_independent_of_machine_guid() {
|
||||
let salt = "smbios:4C4C4544-0043-3010-8052-B4C04F564231";
|
||||
// "Before re-image" and "after re-image": MachineGuid differs, but the
|
||||
// salt-derived uid takes no MachineGuid input, so both are identical.
|
||||
let before = salted_uid(salt);
|
||||
let after = salted_uid(salt);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"salted uid must be stable across a re-image (no MachineGuid term)"
|
||||
);
|
||||
|
||||
// Contrast: the MachineGuid-only floor DOES move when the GUID changes —
|
||||
// demonstrating WHY the salted path must exclude it for re-image stability.
|
||||
let guid_a = machineguid_only_uid("11111111-2222-3333-4444-555555555555");
|
||||
let guid_b = machineguid_only_uid("99999999-8888-7777-6666-555555555555");
|
||||
assert_ne!(
|
||||
guid_a, guid_b,
|
||||
"MachineGuid-only floor is volatile across re-image (expected)"
|
||||
);
|
||||
|
||||
// And the salted uid must differ from the MachineGuid-only floor for the
|
||||
// same box: the two derivation paths are domain-separated.
|
||||
assert_ne!(before, guid_a);
|
||||
}
|
||||
|
||||
/// The hardware-salted derivation is `derive_uid` over a deterministic,
|
||||
/// namespaced concatenation: identical signals MUST yield an identical uid and
|
||||
/// any changed signal MUST change it. Pins the SPEC-016 determinism contract
|
||||
/// independent of the (machine-specific) live hardware reads.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn salted_derivation_is_deterministic_and_signal_sensitive() {
|
||||
let with_smbios = salted_uid("smbios:AAAA-BBBB");
|
||||
let with_smbios_again = salted_uid("smbios:AAAA-BBBB");
|
||||
let with_board = salted_uid("board:SN123|disk:DSK9");
|
||||
|
||||
// Same inputs -> same uid.
|
||||
assert_eq!(with_smbios, with_smbios_again);
|
||||
// Different salt composition -> different uid (distinct boxes stay distinct).
|
||||
assert_ne!(with_smbios, with_board);
|
||||
}
|
||||
|
||||
/// All-zero and all-FF SMBIOS UUIDs are degenerate placeholders that some OEMs
|
||||
/// and hypervisor templates emit; the normalizer + placeholder check must
|
||||
/// reject them so the derivation falls through to board/disk serial. We
|
||||
/// exercise the rejection predicate directly (it is pure) rather than the
|
||||
/// live WMI read.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn degenerate_smbios_uuids_are_rejected() {
|
||||
// Replicate the predicate `smbios_uuid` applies after normalization.
|
||||
fn is_degenerate(raw: &str) -> bool {
|
||||
let Some(norm) = normalize_signal(Some(raw)) else {
|
||||
return true;
|
||||
};
|
||||
let hex: String = norm.chars().filter(|c| *c != '-').collect();
|
||||
hex.is_empty()
|
||||
|| (!hex.is_empty() && hex.chars().all(|c| c == '0'))
|
||||
|| (!hex.is_empty() && hex.chars().all(|c| c == 'F'))
|
||||
}
|
||||
|
||||
assert!(is_degenerate("00000000-0000-0000-0000-000000000000"));
|
||||
assert!(is_degenerate("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"));
|
||||
assert!(is_degenerate("ffffffff-ffff-ffff-ffff-ffffffffffff")); // case-insensitive via normalize
|
||||
assert!(is_degenerate(" "));
|
||||
// A real, mixed UUID is NOT degenerate.
|
||||
assert!(!is_degenerate("4C4C4544-0043-3010-8052-B4C04F564231"));
|
||||
}
|
||||
|
||||
/// `normalize_signal` trims, upper-cases, and drops empties — so case/space
|
||||
/// drift in a vendor serial never perturbs the digest.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn normalize_signal_is_stable_against_drift() {
|
||||
assert_eq!(
|
||||
normalize_signal(Some(" abc123 ")),
|
||||
Some("ABC123".to_string())
|
||||
);
|
||||
assert_eq!(normalize_signal(Some("ABC123")), Some("ABC123".to_string()));
|
||||
assert_eq!(normalize_signal(Some(" ")), None);
|
||||
assert_eq!(normalize_signal(None), None);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
//! 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;
|
||||
|
||||
@@ -11,11 +18,13 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
|
||||
/// Keyboard input controller
|
||||
pub struct KeyboardController {
|
||||
// Track modifier states for proper handling
|
||||
#[allow(dead_code)]
|
||||
/// Tracks which modifier keys this controller currently holds DOWN on the remote.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Tracks the down/up state of each modifier the agent has injected.
|
||||
#[derive(Default)]
|
||||
struct ModifierState {
|
||||
ctrl: bool,
|
||||
@@ -24,6 +33,55 @@ struct ModifierState {
|
||||
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 {
|
||||
/// Create a new keyboard controller
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
fn send_key(&mut self, vk_code: u16, down: bool) -> Result<()> {
|
||||
// Get scan code from virtual key
|
||||
let scan_code = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 };
|
||||
pub fn key_event_full(
|
||||
&mut self,
|
||||
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
|
||||
if Self::is_extended_key(vk_code) || (scan_code >> 8) == 0xE0 {
|
||||
/// Send a key event using scan-code injection.
|
||||
#[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;
|
||||
}
|
||||
|
||||
@@ -61,12 +166,16 @@ impl KeyboardController {
|
||||
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 {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(vk_code),
|
||||
wScan: scan_code,
|
||||
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
|
||||
wScan: w_scan,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
@@ -78,6 +187,7 @@ impl KeyboardController {
|
||||
}
|
||||
|
||||
/// Type a unicode character
|
||||
#[allow(dead_code)]
|
||||
#[cfg(windows)]
|
||||
pub fn type_char(&mut self, ch: char) -> Result<()> {
|
||||
let mut inputs = Vec::new();
|
||||
@@ -119,6 +229,7 @@ impl KeyboardController {
|
||||
}
|
||||
|
||||
/// Type a string of text
|
||||
#[allow(dead_code)]
|
||||
#[cfg(windows)]
|
||||
pub fn type_string(&mut self, text: &str) -> Result<()> {
|
||||
for ch in text.chars() {
|
||||
@@ -129,21 +240,35 @@ impl KeyboardController {
|
||||
|
||||
/// Send Secure Attention Sequence (Ctrl+Alt+Delete)
|
||||
///
|
||||
/// This uses a multi-tier approach:
|
||||
/// 1. Try the GuruConnect SAS Service (runs as SYSTEM, handles via named pipe)
|
||||
/// 2. Try the sas.dll directly (requires SYSTEM privileges)
|
||||
/// 3. Fallback to key simulation (won't work on secure desktop)
|
||||
/// Ctrl+Alt+Del is the Secure Attention Sequence and **cannot** be injected via
|
||||
/// `SendInput` — Windows reserves it. It must be raised by `SendSAS`, which only
|
||||
/// works when the caller runs as SYSTEM (or has SeTcbPrivilege) AND the
|
||||
/// `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)]
|
||||
pub fn send_sas(&mut self) -> Result<()> {
|
||||
// Tier 1: Try the SAS service (named pipe IPC to SYSTEM service)
|
||||
if let Ok(()) = crate::sas_client::request_sas() {
|
||||
tracing::info!("SAS sent via GuruConnect SAS Service");
|
||||
return Ok(());
|
||||
match crate::sas_client::request_sas() {
|
||||
Ok(()) => {
|
||||
tracing::info!("SAS sent via GuruConnect SAS Service");
|
||||
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 privileges)
|
||||
// Tier 2: Try using the sas.dll directly (requires SYSTEM + SoftwareSASGeneration)
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
||||
|
||||
@@ -154,49 +279,33 @@ impl KeyboardController {
|
||||
if let Ok(lib) = lib {
|
||||
let proc_name = b"SendSAS\0";
|
||||
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);
|
||||
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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: Fallback - try sending the keys (won't work on secure desktop)
|
||||
tracing::warn!("SAS service and sas.dll not available, Ctrl+Alt+Del may not work");
|
||||
|
||||
// VK codes
|
||||
const VK_CONTROL: u16 = 0x11;
|
||||
const VK_MENU: u16 = 0x12; // Alt
|
||||
const VK_DELETE: u16 = 0x2E;
|
||||
|
||||
// Press keys
|
||||
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(())
|
||||
// Tier 3: SAS could not be delivered through any privileged path. A plain
|
||||
// SendInput of Ctrl+Alt+Del never reaches the secure desktop, so report a
|
||||
// clear, actionable error instead of pretending it worked.
|
||||
let msg = "Ctrl+Alt+Del could not be delivered: the GuruConnect SAS helper \
|
||||
service is not running and sas.dll!SendSAS is unavailable. Ensure the \
|
||||
SAS service is installed (runs as SYSTEM) and the SoftwareSASGeneration \
|
||||
policy is enabled by the installer.";
|
||||
tracing::error!("{}", msg);
|
||||
anyhow::bail!("{}", msg)
|
||||
}
|
||||
|
||||
/// Check if a virtual key code is an extended key
|
||||
#[cfg(windows)]
|
||||
fn is_extended_key(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
|
||||
)
|
||||
vk_is_extended(vk)
|
||||
}
|
||||
|
||||
/// Send input events
|
||||
@@ -221,6 +330,22 @@ impl KeyboardController {
|
||||
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))]
|
||||
pub fn type_char(&mut self, _ch: char) -> Result<()> {
|
||||
anyhow::bail!("Keyboard input only supported on Windows")
|
||||
@@ -290,3 +415,121 @@ pub mod vk {
|
||||
pub const LMENU: u16 = 0xA4; // Left 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 mouse;
|
||||
|
||||
pub use keyboard::vk_is_extended;
|
||||
pub use keyboard::KeyboardController;
|
||||
pub use mouse::MouseController;
|
||||
|
||||
@@ -26,11 +27,13 @@ impl InputController {
|
||||
}
|
||||
|
||||
/// Get mouse controller
|
||||
#[allow(dead_code)]
|
||||
pub fn mouse(&mut self) -> &mut MouseController {
|
||||
&mut self.mouse
|
||||
}
|
||||
|
||||
/// Get keyboard controller
|
||||
#[allow(dead_code)]
|
||||
pub fn keyboard(&mut self) -> &mut KeyboardController {
|
||||
&mut self.keyboard
|
||||
}
|
||||
@@ -54,7 +57,8 @@ impl InputController {
|
||||
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<()> {
|
||||
if down {
|
||||
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
|
||||
#[allow(dead_code)]
|
||||
pub fn type_unicode(&mut self, ch: char) -> Result<()> {
|
||||
self.keyboard.type_char(ch)
|
||||
}
|
||||
@@ -80,7 +109,10 @@ pub enum MouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
// Extra mouse buttons; not yet produced by the viewer input mapping.
|
||||
#[allow(dead_code)]
|
||||
X1,
|
||||
#[allow(dead_code)]
|
||||
X2,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! - UAC elevation with graceful fallback
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::{
|
||||
@@ -290,6 +290,18 @@ pub fn install(force_user_install: bool) -> Result<()> {
|
||||
// Register protocol handler
|
||||
register_protocol_handler(elevated)?;
|
||||
|
||||
// SPEC-018: a MANAGED install (embedded config => persistent agent) installs
|
||||
// the LocalSystem service as its single autostart and removes the per-user
|
||||
// HKCU\…\Run entry. Attended (support-code) and viewer installs are untouched:
|
||||
// they have no embedded config and continue to use the HKCU Run / protocol
|
||||
// handler paths exactly as before.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if crate::config::Config::has_embedded_config() {
|
||||
install_managed_service(&exe_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Installation complete!");
|
||||
if elevated {
|
||||
info!("Installed system-wide to: {}", install_path.display());
|
||||
@@ -300,6 +312,64 @@ pub fn install(force_user_install: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SPEC-018: install the managed agent as a LocalSystem service and swap out the
|
||||
/// legacy per-user `HKCU\…\Run` autostart so the service is the single managed
|
||||
/// autostart (no double-run).
|
||||
///
|
||||
/// Installing a LocalSystem service requires Administrator. If the SCM rejects the
|
||||
/// create (not elevated), we surface the error rather than silently leaving the
|
||||
/// machine with no managed autostart — a managed deployment is expected to run the
|
||||
/// install elevated. The HKCU Run entry is removed best-effort regardless.
|
||||
#[cfg(windows)]
|
||||
pub fn install_managed_service(exe_path: &std::path::Path) -> Result<()> {
|
||||
info!("Managed install: registering LocalSystem service (SPEC-018)");
|
||||
|
||||
crate::service::install_service(exe_path)
|
||||
.map_err(|e| anyhow!("failed to install the managed agent service: {e:#}"))?;
|
||||
|
||||
// Start the service now so the agent comes up immediately on first install
|
||||
// rather than only on the next boot. Best-effort: the service is auto-start, so
|
||||
// a transient start failure still self-heals on reboot.
|
||||
if let Err(e) = crate::service::start_service() {
|
||||
warn!(
|
||||
"managed service installed but did not start now ({e:#}); \
|
||||
it is auto-start and will run on next boot"
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the legacy per-user autostart so the agent does not also launch in the
|
||||
// user's session (which would double-run alongside the service).
|
||||
if let Err(e) = crate::startup::remove_from_startup() {
|
||||
warn!(
|
||||
"managed service installed, but failed to remove the legacy HKCU Run \
|
||||
autostart (harmless if it was never present): {}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
info!("removed legacy HKCU Run autostart (service is now the managed autostart)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SPEC-018: remove the managed agent service and any legacy HKCU Run autostart.
|
||||
/// Idempotent — succeeds if neither is present.
|
||||
#[cfg(windows)]
|
||||
pub fn uninstall_managed_service() -> Result<()> {
|
||||
info!("Managed uninstall: removing LocalSystem service (SPEC-018)");
|
||||
|
||||
// Best-effort removal of the legacy autostart first (cheap, no SCM).
|
||||
if let Err(e) = crate::startup::remove_from_startup() {
|
||||
warn!(
|
||||
"failed to remove legacy HKCU Run autostart during uninstall: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
crate::service::uninstall_service()
|
||||
.map_err(|e| anyhow!("failed to uninstall the managed agent service: {e:#}"))
|
||||
}
|
||||
|
||||
/// Check if the guruconnect:// protocol handler is registered
|
||||
#[cfg(windows)]
|
||||
pub fn is_protocol_handler_registered() -> bool {
|
||||
|
||||
@@ -15,10 +15,17 @@
|
||||
mod capture;
|
||||
mod chat;
|
||||
mod config;
|
||||
mod consent;
|
||||
#[cfg(windows)]
|
||||
mod credential_store;
|
||||
mod encoder;
|
||||
mod enroll;
|
||||
mod identity;
|
||||
mod input;
|
||||
mod install;
|
||||
mod sas_client;
|
||||
#[cfg(windows)]
|
||||
mod service;
|
||||
mod session;
|
||||
mod startup;
|
||||
mod transport;
|
||||
@@ -177,6 +184,12 @@ enum Commands {
|
||||
/// Show detailed version and build information
|
||||
#[command(name = "version-info")]
|
||||
VersionInfo,
|
||||
|
||||
/// Internal: entry point invoked by the Windows Service Control Manager to run
|
||||
/// the managed agent as a LocalSystem service (SPEC-018). Not for interactive
|
||||
/// use — running it by hand fails because there is no controlling SCM.
|
||||
#[command(name = "service-run", hide = true)]
|
||||
ServiceRun,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -221,7 +234,24 @@ fn main() -> Result<()> {
|
||||
Some(Commands::Install {
|
||||
user_only,
|
||||
elevated,
|
||||
}) => run_install(user_only || elevated),
|
||||
}) => {
|
||||
// `run_install`'s parameter is `force_user_install` — when true it
|
||||
// skips the UAC re-elevation attempt and installs in-place with
|
||||
// whatever rights this process already has.
|
||||
//
|
||||
// - `user_only`: the user explicitly asked for a per-user install;
|
||||
// honour it directly.
|
||||
// - `elevated`: this is the internal, already-elevated re-exec spawned
|
||||
// by `try_elevate_and_install` ("install --elevated"). It must NOT
|
||||
// attempt to elevate AGAIN (that would loop / re-prompt), so we pass
|
||||
// force=true here too. This is correct even though it routes through
|
||||
// the "user install" parameter, because the re-exec genuinely runs
|
||||
// elevated: `is_elevated()` returns true inside `install()`, so the
|
||||
// path resolves to Program Files and the LocalSystem service installs
|
||||
// normally. The flag only suppresses re-elevation; it does not force a
|
||||
// per-user (non-elevated) install when we are already elevated.
|
||||
run_install(user_only || elevated)
|
||||
}
|
||||
Some(Commands::Uninstall) => run_uninstall(),
|
||||
Some(Commands::Launch { url }) => run_launch(&url),
|
||||
Some(Commands::VersionInfo) => {
|
||||
@@ -231,6 +261,21 @@ fn main() -> Result<()> {
|
||||
println!("{}", build_info::full_version());
|
||||
Ok(())
|
||||
}
|
||||
Some(Commands::ServiceRun) => {
|
||||
// SPEC-018 Phase 1: SCM-invoked entry. Hand off to the service
|
||||
// dispatcher, which calls back into the control loop and runs the
|
||||
// managed-agent logic as SYSTEM. Blocks until the service stops.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service::run_dispatcher()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Err(anyhow::anyhow!(
|
||||
"service-run is a Windows-only entry point (SPEC-018)"
|
||||
))
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// No subcommand - detect mode from filename or embedded config
|
||||
// Legacy: if support_code arg provided, use that
|
||||
@@ -259,16 +304,31 @@ fn main() -> Result<()> {
|
||||
run_agent_mode(Some(code))
|
||||
}
|
||||
RunMode::PermanentAgent => {
|
||||
// Embedded config found - run as permanent agent
|
||||
// Embedded config found - managed/persistent agent.
|
||||
info!("Permanent agent mode detected (embedded config)");
|
||||
if !install::is_protocol_handler_registered() {
|
||||
// First run - install then run as agent
|
||||
info!("First run - installing agent");
|
||||
if let Err(e) = install::install(false) {
|
||||
warn!("Installation failed: {}", e);
|
||||
}
|
||||
|
||||
// SPEC-018: managed mode runs as the LocalSystem service, not as
|
||||
// an interactive process. The service is the single autostart.
|
||||
// - If the service is already installed, the service is (or
|
||||
// will be) running the agent — this interactive invocation
|
||||
// must NOT spawn a second agent. Exit quietly.
|
||||
// - On first run, install (which installs + starts the service
|
||||
// and removes the legacy HKCU Run entry), then exit and let
|
||||
// the service carry the agent as SYSTEM.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
run_permanent_agent_managed()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if !install::is_protocol_handler_registered() {
|
||||
info!("First run - installing agent");
|
||||
if let Err(e) = install::install(false) {
|
||||
warn!("Installation failed: {}", e);
|
||||
}
|
||||
}
|
||||
run_agent_mode(None)
|
||||
}
|
||||
run_agent_mode(None)
|
||||
}
|
||||
RunMode::Default => {
|
||||
// No special mode detected - use legacy logic
|
||||
@@ -321,7 +381,239 @@ fn run_agent_mode(support_code: Option<String>) -> Result<()> {
|
||||
|
||||
// Run the agent
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(run_agent(config))
|
||||
rt.block_on(async move {
|
||||
// SPEC-016 Phase B: resolve the operating credential before connecting.
|
||||
// Support sessions are unaffected — they authenticate by support code, not
|
||||
// by a per-machine cak_, so we only resolve enrollment for a managed agent.
|
||||
if config.support_code.is_none() {
|
||||
resolve_agent_credential(&mut config).await?;
|
||||
}
|
||||
run_agent(config, None).await
|
||||
})
|
||||
}
|
||||
|
||||
/// SPEC-018 Phase 1: run the managed/persistent agent as the LocalSystem service.
|
||||
///
|
||||
/// Invoked from the service control loop ([`service::run_service`]) once the
|
||||
/// service has reported `Running`. This is the same persistent-agent logic as
|
||||
/// [`run_agent_mode`] (load config, resolve/enroll the per-machine `cak_` per
|
||||
/// SPEC-016, hold the relay connection) — but it runs **as SYSTEM**, so the
|
||||
/// SYSTEM-ACL'd `cak_` store is finally readable in-context, and it observes the
|
||||
/// SCM `shutdown` flag for a graceful stop.
|
||||
///
|
||||
/// Returns `Ok(())` when the agent loop exits because a stop was requested, and
|
||||
/// `Err` only on an unrecoverable *local* fault (e.g. no usable credential and no
|
||||
/// enrollment material) — network errors are retried inside the loop and never
|
||||
/// surface here.
|
||||
///
|
||||
/// Phase 2 seam: this is where the session broker is wired in — the runtime
|
||||
/// started here will own the broker that spawns the per-session capture/input
|
||||
/// worker (`CreateProcessAsUserW`) and the IPC server. Phase 1 connects/enrolls
|
||||
/// only; it does not capture a desktop (a Session-0 SYSTEM process cannot).
|
||||
#[cfg(windows)]
|
||||
pub fn run_managed_agent_service(
|
||||
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> Result<()> {
|
||||
info!("Loading managed-agent configuration (running as SYSTEM)");
|
||||
|
||||
let mut config = config::Config::load()?;
|
||||
// The service ONLY ever runs the managed/persistent path. A support session is
|
||||
// an interactive, user-launched flow and must never be carried by the service.
|
||||
config.support_code = None;
|
||||
|
||||
info!("Server: {}", config.server_url);
|
||||
if let Some(ref company) = config.company {
|
||||
info!("Company: {}", company);
|
||||
}
|
||||
if let Some(ref site) = config.site {
|
||||
info!("Site: {}", site);
|
||||
}
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
// SPEC-018 (finding M): this future runs across the `extern "system"` service
|
||||
// entry point (ffi_service_main -> service_main -> run_service -> here). A
|
||||
// panic that unwound across that FFI boundary is undefined behaviour (the C
|
||||
// ABI cannot carry a Rust unwind) and would abort the process instead of
|
||||
// taking the intended ServiceSpecific(1) fault path. Catch it here and convert
|
||||
// it into an `Err`, which `run_service` maps to ServiceExitCode::ServiceSpecific(1)
|
||||
// so the SCM applies its configured recovery (restart) cleanly. `Running` is
|
||||
// already reported before we get here, so a fault does not strand StartPending.
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
rt.block_on(async move {
|
||||
// SPEC-016 Phase B: resolve the operating credential before connecting.
|
||||
// Running as SYSTEM, the SYSTEM+Administrators-ACL'd cak_ store is now
|
||||
// readable in-context, so the Phase B fail-fast guard is not hit on this
|
||||
// path (it remains as a safety net for any non-SYSTEM invocation).
|
||||
resolve_agent_credential(&mut config).await?;
|
||||
run_agent(config, Some(shutdown)).await
|
||||
})
|
||||
}));
|
||||
|
||||
match outcome {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
// Recover a human-readable message from the panic payload for the log;
|
||||
// do not re-panic (that would unwind across the FFI boundary again).
|
||||
let detail = panic
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| panic.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "non-string panic payload".to_string());
|
||||
error!("managed-agent runtime panicked: {detail}");
|
||||
Err(anyhow::anyhow!("managed-agent runtime panicked: {detail}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SPEC-018 Phase 1: handle an interactive launch of a MANAGED agent binary (one
|
||||
/// carrying embedded config, detected as [`config::RunMode::PermanentAgent`]).
|
||||
///
|
||||
/// Managed mode runs as the LocalSystem service, never as an interactive process:
|
||||
/// - If the service is already installed, the service is (or will be) running
|
||||
/// the agent as SYSTEM, so this interactive invocation must NOT spawn a second
|
||||
/// agent — it exits quietly.
|
||||
/// - On first run, install (which installs + starts the service and removes the
|
||||
/// legacy `HKCU\…\Run` autostart), then exit and let the service carry the
|
||||
/// agent. The managed install REQUIRES elevation: the per-machine credential
|
||||
/// store is SYSTEM-only, so the SPEC-016 enrollment path cannot authenticate
|
||||
/// from a non-elevated, in-process context. There is therefore no in-process
|
||||
/// fallback — if the install fails, we return an actionable error telling the
|
||||
/// operator to re-run as Administrator.
|
||||
#[cfg(windows)]
|
||||
fn run_permanent_agent_managed() -> Result<()> {
|
||||
if service::is_service_installed() {
|
||||
info!(
|
||||
"Managed service already installed; the service runs the agent as SYSTEM — \
|
||||
this interactive instance has nothing to do"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("First run - installing managed agent service");
|
||||
if let Err(e) = install::install(false) {
|
||||
// No in-process fallback: a managed agent authenticates with a per-machine
|
||||
// cak_ whose credential store is ACL'd to SYSTEM only. Running the agent in
|
||||
// this non-elevated process would either fail to read an existing cak_
|
||||
// (permission denied against the SYSTEM-only ACL) or, on a fresh machine,
|
||||
// fail enrollment's C1 store-and-read-back verification — leaving the
|
||||
// machine with no working agent while pretending otherwise. Surface a clear,
|
||||
// actionable error instead.
|
||||
error!(
|
||||
"Managed agent install failed ({e:#}). The managed service must be installed \
|
||||
elevated (Administrator) — the per-machine credential store is SYSTEM-only and \
|
||||
an in-process fallback cannot authenticate. Re-run as Administrator."
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"managed agent install failed ({e:#}); the managed service must be installed \
|
||||
elevated (Administrator) — the per-machine credential store is SYSTEM-only and \
|
||||
an in-process fallback cannot authenticate. Re-run as Administrator."
|
||||
));
|
||||
}
|
||||
|
||||
info!("Managed agent service installed; handing off to the service");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the per-machine operating credential for a managed agent (SPEC-016
|
||||
/// Phase B, run-mode wiring).
|
||||
///
|
||||
/// Precedence:
|
||||
/// 1. A `cak_` already stored encrypted at rest -> load it and connect with it
|
||||
/// (the steady-state path; no network call, no re-enroll).
|
||||
/// 2. No stored `cak_` but an `enrollment_key` + `site_code` are present ->
|
||||
/// run first-run enrollment to obtain + persist a `cak_`, then connect.
|
||||
/// 3. Neither a stored `cak_` nor enrollment material, but a non-empty
|
||||
/// `api_key` is configured -> use it as the DEPRECATED shared/legacy key
|
||||
/// (transition compatibility only; logged at WARNING).
|
||||
/// 4. Nothing usable -> error; a managed agent cannot authenticate.
|
||||
async fn resolve_agent_credential(config: &mut config::Config) -> Result<()> {
|
||||
// 1. Stored per-machine cak_ (steady state).
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use credential_store::LoadCakError;
|
||||
match credential_store::load_cak() {
|
||||
Ok(Some(cak)) => {
|
||||
info!("Using stored per-machine credential (cak_)");
|
||||
config.api_key = cak;
|
||||
// Any leftover enrollment material is now moot.
|
||||
config.enrollment_key = None;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("No stored per-machine credential; will enroll if configured");
|
||||
}
|
||||
// C1 / M1 — the store exists but THIS security context cannot read it
|
||||
// (access-denied against the SYSTEM-only ACL). This is the brick the
|
||||
// C1 guard prevents: a non-SYSTEM run could write the store but never
|
||||
// read it back. Fail fast with an actionable message; do NOT loop and
|
||||
// do NOT silently re-enroll. The SYSTEM+Administrators ACL is correct
|
||||
// for the target (Option A) and is deliberately kept.
|
||||
//
|
||||
// SPEC-018 (this spec): the managed agent now runs as the GuruConnect
|
||||
// SYSTEM service ([`run_managed_agent_service`]), so on the production
|
||||
// managed path the store IS readable in-context and this branch is NOT
|
||||
// hit. The guard is intentionally retained as a harmless safety net for
|
||||
// any non-SYSTEM invocation (e.g. someone running the managed binary
|
||||
// interactively): it still fails fast with an actionable message rather
|
||||
// than bricking. Do NOT remove it in Phase 1.
|
||||
Err(LoadCakError::Io {
|
||||
permission_denied: true,
|
||||
source,
|
||||
}) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"[ENROLL] credential store is not accessible in this context \
|
||||
({source}) — the managed agent must run as the GuruConnect SYSTEM \
|
||||
service (see SPEC-018). Refusing to re-enroll."
|
||||
));
|
||||
}
|
||||
// M1 — other IO error reaching the store (not access-denied): also
|
||||
// operational, not a tamper signal. Surface it; do not re-enroll over a
|
||||
// store we simply could not read.
|
||||
Err(e @ LoadCakError::Io { .. }) => {
|
||||
return Err(anyhow::Error::new(e).context(
|
||||
"[ENROLL] credential store present but unreadable (IO error); \
|
||||
refusing to re-enroll over it",
|
||||
));
|
||||
}
|
||||
Err(e @ LoadCakError::Path(_)) => {
|
||||
return Err(anyhow::Error::new(e)
|
||||
.context("[ENROLL] could not resolve the credential store path"));
|
||||
}
|
||||
// M1 — the bytes were read but failed to DECRYPT: the real tamper /
|
||||
// wrong-machine signal. Hard stop; never silently re-enroll over it.
|
||||
Err(e @ LoadCakError::Decrypt(_)) => {
|
||||
return Err(anyhow::Error::new(e).context(
|
||||
"[ENROLL] stored credential failed to decrypt — possible tamper or \
|
||||
copy from another machine; refusing to silently re-enroll",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. First-run enrollment (the SPEC-016 zero-touch path). run_enrollment only
|
||||
// returns once a cak_ is stored (it retries network/429/collision-pending
|
||||
// internally); a returned Err is an unrecoverable local fault.
|
||||
if config.enrollment_key.is_some() && config.site_code.is_some() {
|
||||
info!("Enrollment material present; running first-run enrollment");
|
||||
enroll::run_enrollment(config).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. DEPRECATED shared/legacy api_key fallback (transition only).
|
||||
if !config.api_key.is_empty() {
|
||||
warn!(
|
||||
"Connecting with a DEPRECATED shared/legacy api_key. Migrate this agent \
|
||||
to a per-site enrollment (SPEC-016); the shared key path will be removed."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 4. Nothing usable.
|
||||
Err(anyhow::anyhow!(
|
||||
"no operating credential available: no stored cak_, no enrollment_key/site_code, \
|
||||
and no legacy api_key — this managed agent cannot authenticate"
|
||||
))
|
||||
}
|
||||
|
||||
/// Run in viewer mode (connect to remote session)
|
||||
@@ -374,7 +666,22 @@ fn run_install(force_user_install: bool) -> Result<()> {
|
||||
fn run_uninstall() -> Result<()> {
|
||||
info!("Uninstalling GuruConnect...");
|
||||
|
||||
// Remove from startup
|
||||
// SPEC-018: remove the managed LocalSystem service and the legacy HKCU Run
|
||||
// autostart. Idempotent — no error if the service was never installed (an
|
||||
// attended/viewer install has no service), so this is safe for every install
|
||||
// shape. Requires Administrator to delete the service; a non-elevated uninstall
|
||||
// still clears the per-user autostart below.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Err(e) = install::uninstall_managed_service() {
|
||||
warn!(
|
||||
"Failed to remove managed service (may require Administrator): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from startup (covers non-elevated / attended / viewer installs).
|
||||
if let Err(e) = startup::remove_from_startup() {
|
||||
warn!("Failed to remove from startup: {}", e);
|
||||
}
|
||||
@@ -452,7 +759,7 @@ fn show_error_box(_title: &str, message: &str) {
|
||||
fn show_debug_console() {
|
||||
unsafe {
|
||||
let hwnd = GetConsoleWindow();
|
||||
if hwnd.0 == std::ptr::null_mut() {
|
||||
if hwnd.0.is_null() {
|
||||
let _ = AllocConsole();
|
||||
} else {
|
||||
let _ = ShowWindow(hwnd, SW_SHOW);
|
||||
@@ -472,31 +779,62 @@ fn cleanup_on_exit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the agent main loop
|
||||
async fn run_agent(config: config::Config) -> Result<()> {
|
||||
/// Run the agent main loop.
|
||||
///
|
||||
/// `service_shutdown`, when present, is the SCM cooperative-stop flag (SPEC-018):
|
||||
/// the managed-agent service passes it so the loop exits promptly on
|
||||
/// `Stop`/`Shutdown`. It is `None` for the interactive/user-launched paths, which
|
||||
/// stop via the tray exit / server control messages instead.
|
||||
async fn run_agent(
|
||||
config: config::Config,
|
||||
service_shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> Result<()> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let elevated = install::is_elevated();
|
||||
let running_as_service = service_shutdown.is_some();
|
||||
let mut session = session::SessionManager::new(config.clone(), elevated);
|
||||
let is_support_session = config.support_code.is_some();
|
||||
let hostname = config.hostname();
|
||||
|
||||
// Add to startup
|
||||
if let Err(e) = startup::add_to_startup() {
|
||||
// Helper: has the SCM asked us to stop?
|
||||
let stop_requested = |flag: &Option<std::sync::Arc<std::sync::atomic::AtomicBool>>| -> bool {
|
||||
flag.as_ref()
|
||||
.map(|f| f.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
// Autostart persistence:
|
||||
// - As the SYSTEM service (SPEC-018), the SERVICE itself is the managed
|
||||
// autostart — do NOT write the per-user HKCU\…\Run entry (that would be a
|
||||
// second, redundant autostart, and writing it from SYSTEM lands in the
|
||||
// wrong hive). The service install/uninstall owns lifecycle.
|
||||
// - Interactive/user-launched runs keep the existing HKCU Run behavior.
|
||||
if running_as_service {
|
||||
info!("Running as the GuruConnect SYSTEM service; service is the autostart (skipping HKCU Run)");
|
||||
} else if let Err(e) = startup::add_to_startup() {
|
||||
warn!("Failed to add to startup: {}", e);
|
||||
}
|
||||
|
||||
// Create tray icon
|
||||
let tray = match tray::TrayController::new(
|
||||
&hostname,
|
||||
config.support_code.as_deref(),
|
||||
is_support_session,
|
||||
) {
|
||||
Ok(t) => {
|
||||
info!("Tray icon created");
|
||||
Some(t)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create tray icon: {}", e);
|
||||
None
|
||||
// A Session-0 SYSTEM service has no interactive desktop, so a tray icon is
|
||||
// both impossible and meaningless there (SPEC-018 Phase 2 moves the user-facing
|
||||
// surface into the per-session worker). Only create the tray off the service.
|
||||
let tray = if running_as_service {
|
||||
None
|
||||
} else {
|
||||
match tray::TrayController::new(
|
||||
&hostname,
|
||||
config.support_code.as_deref(),
|
||||
is_support_session,
|
||||
) {
|
||||
Ok(t) => {
|
||||
info!("Tray icon created");
|
||||
Some(t)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create tray icon: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -505,6 +843,12 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
|
||||
// Connect to server and run main loop
|
||||
loop {
|
||||
// SPEC-018: honour an SCM stop request before (re)connecting.
|
||||
if stop_requested(&service_shutdown) {
|
||||
info!("Service stop requested; exiting agent loop");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Connecting to server...");
|
||||
|
||||
if is_support_session {
|
||||
@@ -526,11 +870,22 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
}
|
||||
|
||||
if let Err(e) = session
|
||||
.run_with_tray(tray.as_ref(), chat_ctrl.as_ref())
|
||||
.run_with_tray(tray.as_ref(), chat_ctrl.as_ref(), service_shutdown.as_ref())
|
||||
.await
|
||||
{
|
||||
let error_msg = e.to_string();
|
||||
|
||||
// SPEC-018 (finding H): the connected session loop broke
|
||||
// because the SCM asked the service to stop. The loop already
|
||||
// closed the WebSocket cleanly; treat this as a graceful stop
|
||||
// (no reconnect) so the service transitions StopPending ->
|
||||
// Stopped. Only the service path can produce this (it is the
|
||||
// only caller that passes a shutdown flag).
|
||||
if error_msg.contains(session::SERVICE_STOP_SENTINEL) {
|
||||
info!("Service stop requested during session; exiting agent loop");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if error_msg.contains("USER_EXIT") {
|
||||
info!("Session ended by user");
|
||||
cleanup_on_exit();
|
||||
@@ -603,6 +958,47 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
}
|
||||
|
||||
info!("Reconnecting in 5 seconds...");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
// SPEC-018: poll the SCM stop flag during the backoff so a service stop is
|
||||
// honoured within ~250ms instead of waiting the full reconnect delay.
|
||||
if service_shutdown.is_some() {
|
||||
for _ in 0..20 {
|
||||
if stop_requested(&service_shutdown) {
|
||||
info!("Service stop requested during reconnect backoff; exiting agent loop");
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
|
||||
}
|
||||
} else {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
|
||||
/// SPEC-018 finding N1: pin the clap subcommand name to the constant the SCM
|
||||
/// is registered with. The service is installed with `SERVICE_RUN_ARG` as its
|
||||
/// launch argument; when the SCM starts it, clap must route that exact token
|
||||
/// into [`Commands::ServiceRun`]. If the `#[command(name = "service-run")]`
|
||||
/// attribute and the constant ever drift apart, the SCM would start the binary
|
||||
/// but clap would fail to match the subcommand and the process would fall
|
||||
/// through to default (non-service) mode and exit. Asserting against the live
|
||||
/// clap metadata (not a second string literal) makes that drift impossible.
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn service_run_subcommand_matches_scm_launch_arg() {
|
||||
let cmd = Cli::command();
|
||||
let has_matching_subcommand = cmd
|
||||
.get_subcommands()
|
||||
.any(|sc| sc.get_name() == service::SERVICE_RUN_ARG);
|
||||
assert!(
|
||||
has_matching_subcommand,
|
||||
"no clap subcommand named '{}' (the SCM launch arg); the ServiceRun \
|
||||
#[command(name = ...)] attribute drifted from service::SERVICE_RUN_ARG",
|
||||
service::SERVICE_RUN_ARG
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Read, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
|
||||
const TIMEOUT_MS: u64 = 5000;
|
||||
|
||||
/// Request Ctrl+Alt+Del (Secure Attention Sequence) via the SAS service
|
||||
pub fn request_sas() -> Result<()> {
|
||||
@@ -65,6 +63,8 @@ pub fn request_sas() -> Result<()> {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// Try to open the pipe
|
||||
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
|
||||
#[allow(dead_code)]
|
||||
pub fn get_service_status() -> String {
|
||||
if is_service_available() {
|
||||
"SAS service is running and responding".to_string()
|
||||
|
||||
520
agent/src/service/mod.rs
Normal file
520
agent/src/service/mod.rs
Normal file
@@ -0,0 +1,520 @@
|
||||
//! Windows SYSTEM service host for the managed GuruConnect agent (SPEC-018).
|
||||
//!
|
||||
//! # Phase 1 scope (this module)
|
||||
//!
|
||||
//! Phase 1 proves the *managed/persistent* agent can run as **LocalSystem** in
|
||||
//! the isolated Session 0 across reboots and at the login screen:
|
||||
//!
|
||||
//! 1. Register the agent with the Service Control Manager (SCM) and run, when
|
||||
//! started, the **existing persistent-agent logic** (`RunMode::PermanentAgent`
|
||||
//! path) *as SYSTEM* — i.e. resolve/enroll the per-machine `cak_` (SPEC-016,
|
||||
//! now readable because the SYSTEM-ACL'd store is in-context) and hold the
|
||||
//! relay WSS connection.
|
||||
//! 2. Report a correct service lifecycle to the SCM (`StartPending` ->
|
||||
//! `Running` -> `StopPending` -> `Stopped`) and handle `Stop`/`Shutdown`
|
||||
//! gracefully. The control handler sets a shared shutdown flag; the agent
|
||||
//! runtime observes it both between reconnect attempts AND inside the
|
||||
//! connected session loop (SPEC-018 finding H), so a stop received while a
|
||||
//! session is live breaks out promptly, closes the WS connection cleanly,
|
||||
//! and exits — rather than waiting for the SCM to force-kill.
|
||||
//! 3. Provide install/uninstall of the service (LocalSystem, auto-start, crash
|
||||
//! recovery) so managed mode uses the service as its single autostart
|
||||
//! instead of the per-user `HKCU\…\Run` entry.
|
||||
//!
|
||||
//! # Phase 2 (deliberately NOT built here — see SPEC-018 §Scope)
|
||||
//!
|
||||
//! A SYSTEM service lives in Session 0 and **cannot** capture or inject the
|
||||
//! interactive desktop directly. Phase 1 therefore enrolls and connects but does
|
||||
//! **NOT** capture a desktop yet. The following are Phase 2 and are intentionally
|
||||
//! absent; the seams where they attach are called out inline below:
|
||||
//!
|
||||
//! - the **session broker** (`WTSEnumerateSessionsW` /
|
||||
//! `WTSGetActiveConsoleSessionId` / `WTSQueryUserToken`),
|
||||
//! - the **per-session capture/input worker** spawned via `CreateProcessAsUserW`
|
||||
//! into `winsta0\default`,
|
||||
//! - **service <-> worker IPC** (the per-session ACL'd named pipe), and
|
||||
//! - **`SERVICE_CONTROL_SESSIONCHANGE`** reaction (logon/logoff/console-connect
|
||||
//! retarget).
|
||||
//!
|
||||
//! Phase 1 registers the control handler for `Stop`/`Shutdown`/`Interrogate`
|
||||
//! only. When Phase 2 lands, the broker hangs off the same control handler
|
||||
//! (adding `SESSIONCHANGE`) and off the same agent runtime started here.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use windows_service::{
|
||||
define_windows_service,
|
||||
service::{
|
||||
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
|
||||
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
|
||||
},
|
||||
service_control_handler::{self, ServiceControlHandlerResult},
|
||||
service_dispatcher,
|
||||
service_manager::{ServiceManager, ServiceManagerAccess},
|
||||
};
|
||||
|
||||
/// Internal service name registered with the SCM (no spaces; used by `sc`,
|
||||
/// `ServiceManager`, and the control handler).
|
||||
pub const SERVICE_NAME: &str = "GuruConnectAgent";
|
||||
|
||||
/// Human-facing display name shown in `services.msc`.
|
||||
pub const SERVICE_DISPLAY_NAME: &str = "GuruConnect Managed Agent";
|
||||
|
||||
/// Service description shown in `services.msc`.
|
||||
pub const SERVICE_DESCRIPTION: &str =
|
||||
"Runs the managed GuruConnect remote-support agent as LocalSystem so it is \
|
||||
reachable at the login screen and across reboots (SPEC-018).";
|
||||
|
||||
/// Hidden subcommand the SCM invokes to enter the service control loop. The
|
||||
/// service is registered with this as its launch argument (see [`install_service`]),
|
||||
/// and `main.rs` routes it into [`run_dispatcher`].
|
||||
pub const SERVICE_RUN_ARG: &str = "service-run";
|
||||
|
||||
/// Hint we give the SCM for how long start/stop transitions may take before it
|
||||
/// should consider the service hung.
|
||||
const TRANSITION_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
// The `windows-service` dispatcher requires a `extern "system"` entry point with
|
||||
// a fixed ABI; this macro generates `ffi_service_main`, which trampolines into
|
||||
// our safe `service_main`.
|
||||
define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
/// Enter the SCM dispatcher (called from `main.rs` for the `service-run`
|
||||
/// subcommand). Blocks until the service stops. This must be invoked by the SCM,
|
||||
/// not interactively — `service_dispatcher::start` fails with
|
||||
/// `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` (1063) if there is no controlling
|
||||
/// SCM, which is the expected outcome of running `guruconnect service-run` by hand.
|
||||
pub fn run_dispatcher() -> Result<()> {
|
||||
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
|
||||
.context("failed to connect to the service control dispatcher (must be started by the SCM)")
|
||||
}
|
||||
|
||||
/// SCM-invoked service body. Any error is logged; the function cannot return an
|
||||
/// error to the SCM directly, so [`run_service`] reports a failed exit code on the
|
||||
/// status handle before returning.
|
||||
fn service_main(_arguments: Vec<OsString>) {
|
||||
if let Err(e) = run_service() {
|
||||
error!("service exited with error: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the full service lifecycle: register the control handler, report
|
||||
/// `Running`, run the persistent agent until a stop is requested, then report
|
||||
/// `Stopped`.
|
||||
fn run_service() -> Result<()> {
|
||||
info!("GuruConnect managed agent service starting (running as SYSTEM in session 0)");
|
||||
|
||||
// Cooperative shutdown flag flipped by the SCM control handler and observed by
|
||||
// the agent runtime. `AtomicBool` keeps the handler closure trivially `Send`
|
||||
// and avoids holding a lock inside an SCM callback.
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let shutdown_for_handler = shutdown.clone();
|
||||
|
||||
let event_handler = move |control_event| -> ServiceControlHandlerResult {
|
||||
match control_event {
|
||||
// SPEC-018 Phase 1: graceful stop. Phase 2 adds
|
||||
// `ServiceControl::SessionChange(_)` here to drive the session broker
|
||||
// (retarget the capture/input worker on logon/logoff/console-connect);
|
||||
// we intentionally do not accept SESSIONCHANGE yet.
|
||||
ServiceControl::Stop | ServiceControl::Shutdown => {
|
||||
info!("received {control_event:?}; signalling agent to shut down");
|
||||
// Set the cooperative-stop flag. The agent runtime observes it on
|
||||
// every idle tick of the connected session loop and between
|
||||
// reconnect attempts (SPEC-018 finding H), so it breaks out and
|
||||
// closes the WebSocket cleanly within ~100ms even if a session is
|
||||
// currently connected.
|
||||
shutdown_for_handler.store(true, Ordering::SeqCst);
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||
_ => ServiceControlHandlerResult::NotImplemented,
|
||||
}
|
||||
};
|
||||
|
||||
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)
|
||||
.context("failed to register the service control handler")?;
|
||||
|
||||
// Report StartPending while we spin up the runtime and connect.
|
||||
set_status(
|
||||
&status_handle,
|
||||
ServiceState::StartPending,
|
||||
ServiceControlAccept::empty(),
|
||||
TRANSITION_WAIT,
|
||||
);
|
||||
|
||||
// Report Running and accept Stop + Shutdown. We report Running before the
|
||||
// first connect attempt completes because the agent loop reconnects forever;
|
||||
// "the service is up and trying" is the correct steady state, and blocking the
|
||||
// SCM on the first relay handshake would risk a start timeout on a slow boot.
|
||||
set_status(
|
||||
&status_handle,
|
||||
ServiceState::Running,
|
||||
ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
|
||||
Duration::default(),
|
||||
);
|
||||
info!("service reported Running; entering managed-agent control loop");
|
||||
|
||||
// Run the existing persistent-agent logic as SYSTEM. This is the Phase 1
|
||||
// payload: resolve/enroll the cak_ (SPEC-016) and hold the relay connection.
|
||||
let run_result = crate::run_managed_agent_service(shutdown.clone());
|
||||
|
||||
if let Err(e) = &run_result {
|
||||
// The agent loop only returns Err on an unrecoverable LOCAL fault (e.g. no
|
||||
// usable credential and nothing to enroll with). Network errors are
|
||||
// retried inside the loop and never surface here. Report the failure to
|
||||
// the SCM so recovery actions (restart) engage.
|
||||
error!("managed-agent control loop terminated with error: {e:#}");
|
||||
} else {
|
||||
info!("managed-agent control loop exited cleanly on stop request");
|
||||
}
|
||||
|
||||
// Transition StopPending -> Stopped.
|
||||
set_status(
|
||||
&status_handle,
|
||||
ServiceState::StopPending,
|
||||
ServiceControlAccept::empty(),
|
||||
TRANSITION_WAIT,
|
||||
);
|
||||
|
||||
let exit_code = match run_result {
|
||||
Ok(()) => ServiceExitCode::Win32(0),
|
||||
// ERROR_SERVICE_SPECIFIC_ERROR-style: surface a non-zero service-specific
|
||||
// code so the SCM treats the exit as a failure and applies recovery.
|
||||
Err(_) => ServiceExitCode::ServiceSpecific(1),
|
||||
};
|
||||
|
||||
set_status_with_exit(
|
||||
&status_handle,
|
||||
ServiceState::Stopped,
|
||||
ServiceControlAccept::empty(),
|
||||
Duration::default(),
|
||||
exit_code,
|
||||
);
|
||||
info!("service reported Stopped");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report a status with a zero (success) exit code.
|
||||
fn set_status(
|
||||
handle: &service_control_handler::ServiceStatusHandle,
|
||||
state: ServiceState,
|
||||
accepted: ServiceControlAccept,
|
||||
wait_hint: Duration,
|
||||
) {
|
||||
set_status_with_exit(
|
||||
handle,
|
||||
state,
|
||||
accepted,
|
||||
wait_hint,
|
||||
ServiceExitCode::Win32(0),
|
||||
);
|
||||
}
|
||||
|
||||
/// Report a status to the SCM. A failure to report is logged (best-effort) — we
|
||||
/// cannot do anything actionable about it and must not panic inside the service.
|
||||
fn set_status_with_exit(
|
||||
handle: &service_control_handler::ServiceStatusHandle,
|
||||
state: ServiceState,
|
||||
accepted: ServiceControlAccept,
|
||||
wait_hint: Duration,
|
||||
exit_code: ServiceExitCode,
|
||||
) {
|
||||
let status = ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: state,
|
||||
controls_accepted: accepted,
|
||||
exit_code,
|
||||
checkpoint: 0,
|
||||
wait_hint,
|
||||
process_id: None,
|
||||
};
|
||||
if let Err(e) = handle.set_service_status(status) {
|
||||
warn!("failed to report service status {state:?} to the SCM: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install / uninstall (used by install.rs for managed mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Install (or reinstall) the managed agent as a LocalSystem auto-start service
|
||||
/// pointing at `exe_path` with the [`SERVICE_RUN_ARG`] launch argument.
|
||||
///
|
||||
/// Idempotent: if the service already exists it is stopped and deleted first,
|
||||
/// then recreated, so an upgrade picks up a new binary path / config. Configures
|
||||
/// crash recovery (restart on failure) via `sc failure`.
|
||||
///
|
||||
/// Requires Administrator (SCM `CREATE_SERVICE`). Returns an error otherwise.
|
||||
pub fn install_service(exe_path: &std::path::Path) -> Result<()> {
|
||||
let manager = ServiceManager::local_computer(
|
||||
None::<&str>,
|
||||
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
|
||||
)
|
||||
.context("failed to connect to the Service Control Manager (run as Administrator)")?;
|
||||
|
||||
// Remove any prior installation so the binary path / args are refreshed.
|
||||
let mut deleted_existing = false;
|
||||
if let Ok(existing) = manager.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
|
||||
) {
|
||||
info!("existing {SERVICE_NAME} service found; removing before reinstall");
|
||||
stop_if_running(&existing);
|
||||
existing
|
||||
.delete()
|
||||
.context("failed to delete the existing service before reinstall")?;
|
||||
drop(existing);
|
||||
deleted_existing = true;
|
||||
}
|
||||
|
||||
let service_info = ServiceInfo {
|
||||
name: OsString::from(SERVICE_NAME),
|
||||
display_name: OsString::from(SERVICE_DISPLAY_NAME),
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
start_type: ServiceStartType::AutoStart,
|
||||
error_control: ServiceErrorControl::Normal,
|
||||
executable_path: exe_path.to_path_buf(),
|
||||
launch_arguments: vec![OsString::from(SERVICE_RUN_ARG)],
|
||||
dependencies: vec![],
|
||||
// account_name: None => LocalSystem (the SPEC-018 requirement).
|
||||
account_name: None,
|
||||
account_password: None,
|
||||
};
|
||||
|
||||
let service = create_service_with_retry(&manager, &service_info, deleted_existing)
|
||||
.context("failed to create the GuruConnect managed agent service")?;
|
||||
|
||||
service
|
||||
.set_description(SERVICE_DESCRIPTION)
|
||||
.context("failed to set the service description")?;
|
||||
|
||||
configure_recovery();
|
||||
|
||||
info!(
|
||||
"installed {SERVICE_NAME} (LocalSystem, auto-start) -> {} {}",
|
||||
exe_path.display(),
|
||||
SERVICE_RUN_ARG
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the service, retrying briefly if the SCM still has the prior instance
|
||||
/// "marked for deletion" (SPEC-018 finding L1).
|
||||
///
|
||||
/// When a service is deleted, the SCM only removes it from its database once every
|
||||
/// open handle to it closes; until then a fresh `CreateService` fails with
|
||||
/// `ERROR_SERVICE_MARKED_FOR_DELETE` (1072). The previous implementation papered
|
||||
/// over this with a fixed 2s sleep after `delete()`, which is both slower than
|
||||
/// necessary in the common case and still racy on a busy box. Instead we attempt
|
||||
/// the create immediately and, only if we just deleted an existing instance and
|
||||
/// hit 1072, retry a few times with short backoff — succeeding as soon as the SCM
|
||||
/// finishes the removal, and giving up with the real error if it never does.
|
||||
///
|
||||
/// The retry is gated on `deleted_existing`: on a clean first install there was no
|
||||
/// prior instance, so a 1072 there is unexpected and is surfaced immediately
|
||||
/// rather than masked by retries.
|
||||
fn create_service_with_retry(
|
||||
manager: &ServiceManager,
|
||||
service_info: &ServiceInfo,
|
||||
deleted_existing: bool,
|
||||
) -> Result<windows_service::service::Service, windows_service::Error> {
|
||||
// ERROR_SERVICE_MARKED_FOR_DELETE (winerror.h). The service is gone from the
|
||||
// caller's perspective but the SCM has not finished reaping it.
|
||||
const ERROR_SERVICE_MARKED_FOR_DELETE: i32 = 1072;
|
||||
// Bounded: ~5 attempts over ~2s total worst case (matches the old fixed sleep
|
||||
// ceiling) but returns the instant the SCM is ready.
|
||||
const MAX_ATTEMPTS: u32 = 5;
|
||||
const BACKOFF: Duration = Duration::from_millis(400);
|
||||
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
match manager.create_service(service_info, ServiceAccess::CHANGE_CONFIG) {
|
||||
Ok(service) => return Ok(service),
|
||||
Err(windows_service::Error::Winapi(ref io_err))
|
||||
if deleted_existing
|
||||
&& io_err.raw_os_error() == Some(ERROR_SERVICE_MARKED_FOR_DELETE)
|
||||
&& attempt < MAX_ATTEMPTS =>
|
||||
{
|
||||
warn!(
|
||||
"{SERVICE_NAME} still marked for deletion by the SCM \
|
||||
(attempt {attempt}/{MAX_ATTEMPTS}); retrying in {}ms",
|
||||
BACKOFF.as_millis()
|
||||
);
|
||||
std::thread::sleep(BACKOFF);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure SCM crash-recovery so the service restarts on unexpected exit.
|
||||
///
|
||||
/// `windows-service` 0.7 does not expose `ChangeServiceConfig2` recovery actions
|
||||
/// in a stable, ergonomic form, so we mirror the established pattern used by the
|
||||
/// SAS service binary and shell out to `sc failure`. `reset=86400` clears the
|
||||
/// failure count after a day; three `restart/5000` actions retry after 5s each.
|
||||
fn configure_recovery() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
match std::process::Command::new("sc")
|
||||
.args([
|
||||
"failure",
|
||||
SERVICE_NAME,
|
||||
"reset=86400",
|
||||
"actions=restart/5000/restart/5000/restart/5000",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
Ok(out) if out.status.success() => {
|
||||
info!("configured crash-recovery (restart) for {SERVICE_NAME}");
|
||||
}
|
||||
Ok(out) => {
|
||||
warn!(
|
||||
"could not configure crash-recovery for {SERVICE_NAME} (sc failure exit {:?}); \
|
||||
the service will still run but will not auto-restart on crash",
|
||||
out.status.code()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("could not invoke `sc failure` to set crash-recovery for {SERVICE_NAME}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop (if running) and delete the managed agent service. Idempotent: succeeds
|
||||
/// quietly if the service is not installed.
|
||||
pub fn uninstall_service() -> Result<()> {
|
||||
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||
.context("failed to connect to the Service Control Manager (run as Administrator)")?;
|
||||
|
||||
match manager.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
|
||||
) {
|
||||
Ok(service) => {
|
||||
stop_if_running(&service);
|
||||
service
|
||||
.delete()
|
||||
.context("failed to delete the managed agent service")?;
|
||||
info!("uninstalled {SERVICE_NAME} service");
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => {
|
||||
// Not installed — nothing to do (idempotent uninstall).
|
||||
info!("{SERVICE_NAME} service is not installed; nothing to uninstall");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the managed agent service now (used right after a first-run install so
|
||||
/// the agent comes up without waiting for the next boot). Best-effort: logs and
|
||||
/// returns the SCM error if the start fails, but a failure is not fatal to install
|
||||
/// because the service is auto-start and will come up on the next boot regardless.
|
||||
pub fn start_service() -> Result<()> {
|
||||
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
|
||||
.context("failed to connect to the Service Control Manager")?;
|
||||
let service = manager
|
||||
.open_service(
|
||||
SERVICE_NAME,
|
||||
ServiceAccess::START | ServiceAccess::QUERY_STATUS,
|
||||
)
|
||||
.context("failed to open the managed agent service to start it")?;
|
||||
|
||||
// If it is already running (e.g. reinstall-over-running), there is nothing to do.
|
||||
if let Ok(status) = service.query_status() {
|
||||
if status.current_state == ServiceState::Running
|
||||
|| status.current_state == ServiceState::StartPending
|
||||
{
|
||||
info!("{SERVICE_NAME} is already running/starting");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
service
|
||||
.start::<String>(&[])
|
||||
.context("failed to start the managed agent service")?;
|
||||
info!("started {SERVICE_NAME}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report whether the managed agent service is currently installed.
|
||||
pub fn is_service_installed() -> bool {
|
||||
match ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) {
|
||||
Ok(manager) => manager
|
||||
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort stop of a service, waiting briefly for it to leave the running
|
||||
/// state so a subsequent `delete` does not race an in-flight stop.
|
||||
fn stop_if_running(service: &windows_service::service::Service) {
|
||||
if let Ok(status) = service.query_status() {
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
info!("stopping {SERVICE_NAME} before delete");
|
||||
let _ = service.stop();
|
||||
for _ in 0..10 {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
match service.query_status() {
|
||||
Ok(s) if s.current_state == ServiceState::Stopped => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The launch argument the service is registered with MUST equal the hidden
|
||||
/// `service-run` subcommand `main.rs` dispatches into [`run_dispatcher`]; a
|
||||
/// mismatch would register a service the SCM could start but that would fall
|
||||
/// through to normal (non-service) mode and immediately exit.
|
||||
///
|
||||
/// This pins the value of the constant itself. The companion test
|
||||
/// `tests::service_run_subcommand_matches_scm_launch_arg` in `main.rs` pins the
|
||||
/// other half — that the clap `#[command(name = "service-run")]` attribute on
|
||||
/// `Commands::ServiceRun` resolves to this same constant — so the two string
|
||||
/// literals cannot silently drift apart.
|
||||
#[test]
|
||||
fn service_run_arg_matches_subcommand_name() {
|
||||
assert_eq!(SERVICE_RUN_ARG, "service-run");
|
||||
}
|
||||
|
||||
/// Service identifiers are non-empty and the internal name carries no spaces
|
||||
/// (the SCM key / `sc` argument must be a single token).
|
||||
#[test]
|
||||
fn service_identifiers_are_well_formed() {
|
||||
assert!(!SERVICE_NAME.is_empty());
|
||||
assert!(
|
||||
!SERVICE_NAME.contains(char::is_whitespace),
|
||||
"the SCM service name must be a single whitespace-free token"
|
||||
);
|
||||
assert!(!SERVICE_DISPLAY_NAME.is_empty());
|
||||
assert!(!SERVICE_DESCRIPTION.is_empty());
|
||||
}
|
||||
|
||||
/// `is_service_installed` must never panic regardless of elevation/SCM access;
|
||||
/// on a dev workstation without the service installed it returns `false`. (We
|
||||
/// do NOT install the service in tests — that is a VM/admin integration step.)
|
||||
#[test]
|
||||
fn is_service_installed_is_total() {
|
||||
let _ = is_service_installed();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
|
||||
#[cfg(windows)]
|
||||
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::config::Config;
|
||||
use crate::encoder::{self, Encoder};
|
||||
@@ -22,7 +22,7 @@ use crate::input::InputController;
|
||||
fn show_debug_console() {
|
||||
unsafe {
|
||||
let hwnd = GetConsoleWindow();
|
||||
if hwnd.0 == std::ptr::null_mut() {
|
||||
if hwnd.0.is_null() {
|
||||
let _ = AllocConsole();
|
||||
tracing::info!("Debug console window opened");
|
||||
} else {
|
||||
@@ -41,8 +41,18 @@ use crate::proto::{message, AgentStatus, ChatMessage, Heartbeat, HeartbeatAck, M
|
||||
use crate::transport::WebSocketTransport;
|
||||
use crate::tray::{TrayAction, TrayController};
|
||||
use anyhow::Result;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Sentinel error string returned by [`SessionManager::run_with_tray`] when the
|
||||
/// loop breaks because the SCM asked the managed-agent service to stop (SPEC-018,
|
||||
/// finding H). The outer `run_agent` loop matches on this to treat the exit as a
|
||||
/// graceful service stop (clean WS close, no reconnect) rather than a session
|
||||
/// error. Only the service path passes a shutdown flag, so only the service path
|
||||
/// can ever produce this.
|
||||
pub const SERVICE_STOP_SENTINEL: &str = "SERVICE_STOP";
|
||||
|
||||
// Heartbeat interval (30 seconds)
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
// Status report interval (60 seconds)
|
||||
@@ -61,6 +71,10 @@ pub struct SessionManager {
|
||||
input: Option<InputController>,
|
||||
// Streaming state
|
||||
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
|
||||
hostname: String,
|
||||
is_elevated: bool,
|
||||
@@ -87,6 +101,8 @@ impl SessionManager {
|
||||
encoder: None,
|
||||
input: None,
|
||||
current_viewer_id: None,
|
||||
// Default to RAW until the server negotiates otherwise (StartStream).
|
||||
negotiated_codec: crate::proto::VideoCodec::Raw,
|
||||
hostname,
|
||||
is_elevated,
|
||||
start_time: Instant::now(),
|
||||
@@ -97,12 +113,16 @@ impl SessionManager {
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
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(
|
||||
&self.config.server_url,
|
||||
&self.config.agent_id,
|
||||
&self.config.api_key,
|
||||
Some(&self.hostname),
|
||||
self.config.support_code.as_deref(),
|
||||
Some(&machine_uid),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -130,7 +150,7 @@ impl SessionManager {
|
||||
|
||||
// Get primary display with panic protection
|
||||
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?,
|
||||
Err(e) => {
|
||||
tracing::error!("Panic during display enumeration: {:?}", e);
|
||||
@@ -168,14 +188,20 @@ impl SessionManager {
|
||||
self.capturer = Some(capturer);
|
||||
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!(
|
||||
"Creating encoder (codec={}, quality={})...",
|
||||
self.config.encoding.codec,
|
||||
"Creating encoder (negotiated={:?}, chosen={:?}, quality={})...",
|
||||
self.negotiated_codec,
|
||||
chosen,
|
||||
self.config.encoding.quality
|
||||
);
|
||||
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?,
|
||||
Err(e) => {
|
||||
@@ -232,6 +258,13 @@ impl SessionManager {
|
||||
organization: self.config.company.clone().unwrap_or_default(),
|
||||
site: self.config.site.clone().unwrap_or_default(),
|
||||
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 {
|
||||
@@ -262,16 +295,34 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the session main loop with tray and chat event processing
|
||||
/// Run the session main loop with tray and chat event processing.
|
||||
///
|
||||
/// `service_shutdown` (SPEC-018 finding H) is the SCM cooperative-stop flag.
|
||||
/// It is `Some(flag)` ONLY on the managed-agent service path; the
|
||||
/// attended/viewer/interactive callers pass `None` and behave EXACTLY as
|
||||
/// before. When present, the flag is polled on every idle tick (the natural
|
||||
/// ~100ms seam below) so an SCM Stop/Shutdown received while CONNECTED breaks
|
||||
/// this inner loop promptly — instead of only being observed by the outer
|
||||
/// `run_agent` reconnect loop, which never runs while a session is connected.
|
||||
/// On a set flag the loop closes the WebSocket cleanly (via the shared exit
|
||||
/// path at the bottom) and returns the [`SERVICE_STOP_SENTINEL`] error, which
|
||||
/// the outer loop maps to a graceful stop.
|
||||
pub async fn run_with_tray(
|
||||
&mut self,
|
||||
tray: Option<&TrayController>,
|
||||
chat: Option<&ChatController>,
|
||||
service_shutdown: Option<&Arc<AtomicBool>>,
|
||||
) -> Result<()> {
|
||||
if self.transport.is_none() {
|
||||
anyhow::bail!("Not connected");
|
||||
}
|
||||
|
||||
// Helper: has the SCM asked the service to stop? Always false off the
|
||||
// service path (where `service_shutdown` is `None`).
|
||||
let stop_requested = |flag: Option<&Arc<AtomicBool>>| -> bool {
|
||||
flag.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||
};
|
||||
|
||||
// Send initial status
|
||||
self.send_status().await?;
|
||||
|
||||
@@ -284,6 +335,29 @@ impl SessionManager {
|
||||
|
||||
// Main loop
|
||||
loop {
|
||||
// SPEC-018 (finding H): honour an SCM stop request received while the
|
||||
// session is CONNECTED. The outer `run_agent` loop only observes the
|
||||
// flag between connection attempts, but a managed agent spends its
|
||||
// entire connected life inside THIS loop — so without this check an
|
||||
// SCM Stop while connected would not break out until the connection
|
||||
// dropped on its own. Breaking here falls through to the shared exit
|
||||
// path below, which closes the transport cleanly (clean WS close);
|
||||
// the sentinel tells the outer loop this was a graceful stop.
|
||||
if stop_requested(service_shutdown) {
|
||||
tracing::info!("Service stop requested; ending connected session loop");
|
||||
self.release_streaming();
|
||||
self.state = SessionState::Disconnected;
|
||||
if let Some(transport) = self.transport.as_mut() {
|
||||
// Best-effort clean WebSocket close (sends a Close frame). A
|
||||
// failure here just means the peer/socket is already gone; the
|
||||
// service still stops cleanly.
|
||||
if let Err(e) = transport.close().await {
|
||||
tracing::warn!("error during clean WebSocket close on service stop: {}", e);
|
||||
}
|
||||
}
|
||||
return Err(anyhow::anyhow!(SERVICE_STOP_SENTINEL));
|
||||
}
|
||||
|
||||
// Process tray events
|
||||
if let Some(t) = tray {
|
||||
if let Some(action) = t.process_events() {
|
||||
@@ -336,6 +410,15 @@ impl SessionManager {
|
||||
match payload {
|
||||
message::Payload::StartStream(start) => {
|
||||
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() {
|
||||
tracing::error!("Failed to init streaming: {}", e);
|
||||
} else {
|
||||
@@ -369,6 +452,17 @@ impl SessionManager {
|
||||
}
|
||||
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 +592,69 @@ impl SessionManager {
|
||||
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
|
||||
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||
match msg.payload {
|
||||
@@ -548,18 +705,23 @@ impl SessionManager {
|
||||
|
||||
Some(message::Payload::KeyEvent(key)) => {
|
||||
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)) => {
|
||||
if let Some(input) = self.input.as_mut() {
|
||||
use crate::proto::SpecialKey;
|
||||
match SpecialKey::try_from(special.key).ok() {
|
||||
Some(SpecialKey::CtrlAltDel) => {
|
||||
input.send_ctrl_alt_del()?;
|
||||
}
|
||||
_ => {}
|
||||
if let Ok(SpecialKey::CtrlAltDel) = SpecialKey::try_from(special.key) {
|
||||
input.send_ctrl_alt_del()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -634,3 +796,47 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// SPEC-018 finding H: the connected-stop contract. When the SCM sets the
|
||||
/// shutdown flag, `run_with_tray` returns an error whose message contains
|
||||
/// [`SERVICE_STOP_SENTINEL`]; the outer `run_agent` loop recognises a graceful
|
||||
/// stop with `error_msg.contains(SERVICE_STOP_SENTINEL)`. This pins that the
|
||||
/// error the loop constructs on stop actually satisfies that match — so the
|
||||
/// two halves (producer here, consumer in `main.rs`) cannot drift.
|
||||
///
|
||||
/// A full end-to-end test of the in-loop interrupt would need a live connected
|
||||
/// transport (a real or mocked server), which is an integration concern; this
|
||||
/// unit test instead pins the wire contract the interrupt relies on.
|
||||
#[test]
|
||||
fn service_stop_sentinel_is_matched_by_outer_loop_check() {
|
||||
let produced = anyhow::anyhow!(SERVICE_STOP_SENTINEL);
|
||||
assert!(
|
||||
produced.to_string().contains(SERVICE_STOP_SENTINEL),
|
||||
"the stop error must contain the sentinel the outer loop matches on"
|
||||
);
|
||||
assert!(
|
||||
!SERVICE_STOP_SENTINEL.is_empty(),
|
||||
"the sentinel must be a non-empty, distinctive token"
|
||||
);
|
||||
}
|
||||
|
||||
/// The shutdown-flag check is a no-op (always `false`) when no flag is passed,
|
||||
/// i.e. on the attended/viewer/interactive paths — guaranteeing the new
|
||||
/// parameter is a pure addition that cannot alter non-service behaviour
|
||||
/// (SPEC-018 finding H: "no regression").
|
||||
#[test]
|
||||
fn no_shutdown_flag_never_requests_stop() {
|
||||
let none: Option<&Arc<AtomicBool>> = None;
|
||||
let check = |flag: Option<&Arc<AtomicBool>>| flag.is_some_and(|f| f.load(Ordering::SeqCst));
|
||||
assert!(!check(none));
|
||||
|
||||
let set = Arc::new(AtomicBool::new(true));
|
||||
assert!(check(Some(&set)));
|
||||
let unset = Arc::new(AtomicBool::new(false));
|
||||
assert!(!check(Some(&unset)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
//! Handles adding/removing the agent from Windows startup.
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::core::PCWSTR;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Registry::{
|
||||
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, HKEY_CURRENT_USER, KEY_WRITE,
|
||||
RegCloseKey, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE,
|
||||
REG_SZ,
|
||||
};
|
||||
|
||||
@@ -42,37 +42,39 @@ pub fn add_to_startup() -> Result<()> {
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// SAFETY: FFI into the Win32 registry API. `key_path`/`value_name`/`value_data`
|
||||
// are NUL-terminated wide strings that outlive the calls. `RegOpenKeyExW`
|
||||
// writes the opened key into `hkey`; we only use it after confirming success,
|
||||
// and always pair it with `RegCloseKey`.
|
||||
unsafe {
|
||||
let mut hkey = windows::Win32::Foundation::HANDLE::default();
|
||||
let mut hkey = HKEY::default();
|
||||
|
||||
// Open the Run key
|
||||
// Open the Run key. RegOpenKeyExW takes a `*mut HKEY` out-param.
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey as *mut _ as *mut _,
|
||||
&mut hkey,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
anyhow::bail!("Failed to open registry key: {:?}", result);
|
||||
}
|
||||
|
||||
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
|
||||
|
||||
// Set the value
|
||||
let data_bytes =
|
||||
std::slice::from_raw_parts(value_data.as_ptr() as *const u8, value_data.len() * 2);
|
||||
|
||||
let set_result = RegSetValueExW(
|
||||
hkey_raw,
|
||||
hkey,
|
||||
PCWSTR(value_name.as_ptr()),
|
||||
0,
|
||||
REG_SZ,
|
||||
Some(data_bytes),
|
||||
);
|
||||
|
||||
let _ = RegCloseKey(hkey_raw);
|
||||
let _ = RegCloseKey(hkey);
|
||||
|
||||
if set_result.is_err() {
|
||||
anyhow::bail!("Failed to set registry value: {:?}", set_result);
|
||||
@@ -100,15 +102,19 @@ pub fn remove_from_startup() -> Result<()> {
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// SAFETY: FFI into the Win32 registry API. `key_path`/`value_name` are
|
||||
// NUL-terminated wide strings that outlive the calls. `RegOpenKeyExW` writes
|
||||
// the opened key into `hkey`; we only use it after confirming success, and
|
||||
// always pair it with `RegCloseKey`.
|
||||
unsafe {
|
||||
let mut hkey = windows::Win32::Foundation::HANDLE::default();
|
||||
let mut hkey = HKEY::default();
|
||||
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR(key_path.as_ptr()),
|
||||
0,
|
||||
KEY_WRITE,
|
||||
&mut hkey as *mut _ as *mut _,
|
||||
&mut hkey,
|
||||
);
|
||||
|
||||
if result.is_err() {
|
||||
@@ -116,11 +122,9 @@ pub fn remove_from_startup() -> Result<()> {
|
||||
return Ok(()); // Not an error if key doesn't exist
|
||||
}
|
||||
|
||||
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
|
||||
let delete_result = RegDeleteValueW(hkey, PCWSTR(value_name.as_ptr()));
|
||||
|
||||
let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr()));
|
||||
|
||||
let _ = RegCloseKey(hkey_raw);
|
||||
let _ = RegCloseKey(hkey);
|
||||
|
||||
if delete_result.is_err() {
|
||||
warn!("Registry value may not exist: {:?}", delete_result);
|
||||
@@ -180,6 +184,8 @@ pub fn uninstall() -> Result<()> {
|
||||
|
||||
/// Install the SAS service if the binary is available
|
||||
/// 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)]
|
||||
pub fn install_sas_service() -> Result<()> {
|
||||
info!("Attempting to install SAS service...");
|
||||
@@ -230,6 +236,8 @@ pub fn install_sas_service() -> Result<()> {
|
||||
}
|
||||
|
||||
/// Uninstall the SAS service
|
||||
// Not yet wired into the CLI; retained as the SAS service management API.
|
||||
#[allow(dead_code)]
|
||||
#[cfg(windows)]
|
||||
pub fn uninstall_sas_service() -> Result<()> {
|
||||
info!("Attempting to uninstall SAS service...");
|
||||
@@ -244,16 +252,14 @@ pub fn uninstall_sas_service() -> Result<()> {
|
||||
)),
|
||||
];
|
||||
|
||||
for path_opt in paths.iter() {
|
||||
if let Some(ref path) = path_opt {
|
||||
if path.exists() {
|
||||
let output = std::process::Command::new(path).arg("uninstall").output();
|
||||
for path in paths.iter().flatten() {
|
||||
if path.exists() {
|
||||
let output = std::process::Command::new(path).arg("uninstall").output();
|
||||
|
||||
if let Ok(result) = output {
|
||||
if result.status.success() {
|
||||
info!("SAS service uninstalled successfully");
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(result) = output {
|
||||
if result.status.success() {
|
||||
info!("SAS service uninstalled successfully");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +270,8 @@ pub fn uninstall_sas_service() -> Result<()> {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub fn check_sas_service() -> bool {
|
||||
use crate::sas_client;
|
||||
|
||||
@@ -35,14 +35,25 @@ impl WebSocketTransport {
|
||||
api_key: &str,
|
||||
hostname: Option<&str>,
|
||||
support_code: Option<&str>,
|
||||
machine_uid: Option<&str>,
|
||||
) -> 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);
|
||||
|
||||
if let Some(hostname) = 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 {
|
||||
params.push_str(&format!("&support_code={}", code));
|
||||
}
|
||||
@@ -82,7 +93,7 @@ impl WebSocketTransport {
|
||||
|
||||
// Send as binary WebSocket message
|
||||
stream
|
||||
.send(WsMessage::Binary(buf.into()))
|
||||
.send(WsMessage::Binary(buf))
|
||||
.await
|
||||
.context("Failed to send message")?;
|
||||
|
||||
@@ -132,6 +143,7 @@ impl WebSocketTransport {
|
||||
}
|
||||
|
||||
/// Receive a message (blocking)
|
||||
#[allow(dead_code)]
|
||||
pub async fn recv(&mut self) -> Result<Option<Message>> {
|
||||
// Return buffered message if available
|
||||
if let Some(msg) = self.incoming.pop_front() {
|
||||
@@ -164,7 +176,7 @@ impl WebSocketTransport {
|
||||
.context("Failed to decode protobuf message")?;
|
||||
Ok(Some(msg))
|
||||
}
|
||||
WsMessage::Ping(data) => {
|
||||
WsMessage::Ping(_data) => {
|
||||
// Pong is sent automatically by tungstenite
|
||||
tracing::trace!("Received ping");
|
||||
Ok(None)
|
||||
@@ -193,6 +205,7 @@ impl WebSocketTransport {
|
||||
}
|
||||
|
||||
/// Close the connection
|
||||
#[allow(dead_code)]
|
||||
pub async fn close(&mut self) -> Result<()> {
|
||||
let mut stream = self.stream.lock().await;
|
||||
stream.close(None).await?;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
//! - End session
|
||||
|
||||
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::Arc;
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -28,7 +28,8 @@ pub enum TrayAction {
|
||||
/// Tray icon controller
|
||||
pub struct TrayController {
|
||||
_tray_icon: TrayIcon,
|
||||
menu: Menu,
|
||||
// Kept alive for the lifetime of the tray icon; not read directly.
|
||||
_menu: Menu,
|
||||
end_session_item: MenuItem,
|
||||
debug_item: MenuItem,
|
||||
status_item: MenuItem,
|
||||
@@ -86,7 +87,7 @@ impl TrayController {
|
||||
|
||||
Ok(Self {
|
||||
_tray_icon: tray_icon,
|
||||
menu,
|
||||
_menu: menu,
|
||||
end_session_item,
|
||||
debug_item,
|
||||
status_item,
|
||||
@@ -124,14 +125,9 @@ impl TrayController {
|
||||
}
|
||||
|
||||
// Check for tray icon events (like double-click)
|
||||
if let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
||||
match event {
|
||||
TrayIconEvent::DoubleClick { .. } => {
|
||||
info!("Tray icon double-clicked");
|
||||
return Some(TrayAction::ShowDetails);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Ok(TrayIconEvent::DoubleClick { .. }) = TrayIconEvent::receiver().try_recv() {
|
||||
info!("Tray icon double-clicked");
|
||||
return Some(TrayAction::ShowDetails);
|
||||
}
|
||||
|
||||
None
|
||||
|
||||
@@ -10,6 +10,25 @@ use tracing::{error, info, warn};
|
||||
|
||||
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
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct VersionInfo {
|
||||
@@ -17,10 +36,14 @@ pub struct VersionInfo {
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Update state tracking
|
||||
// Future use: drive an update-progress indicator.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum UpdateState {
|
||||
Idle,
|
||||
@@ -38,7 +61,7 @@ pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInf
|
||||
info!("Checking for updates at {}", url);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true) // For self-signed certs in dev
|
||||
.danger_accept_invalid_certs(dev_insecure_tls())
|
||||
.build()?;
|
||||
|
||||
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);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_certs(dev_insecure_tls())
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
@@ -130,6 +153,13 @@ pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
info!("Verifying checksum...");
|
||||
|
||||
@@ -156,6 +186,9 @@ pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<boo
|
||||
/// Perform the actual update installation
|
||||
/// This renames the current executable and copies the new one in place
|
||||
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...");
|
||||
|
||||
// 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.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;
|
||||
#[cfg(windows)]
|
||||
use crate::proto;
|
||||
use anyhow::Result;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
#[cfg(windows)]
|
||||
use tracing::trace;
|
||||
@@ -12,36 +27,83 @@ use tracing::trace;
|
||||
use windows::{
|
||||
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
|
||||
Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, DispatchMessageW, GetMessageW, PeekMessageW, SetWindowsHookExW,
|
||||
TranslateMessage, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE,
|
||||
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT,
|
||||
LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
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)]
|
||||
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
|
||||
|
||||
#[cfg(windows)]
|
||||
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)]
|
||||
mod vk {
|
||||
pub const VK_LWIN: u32 = 0x5B;
|
||||
pub const VK_RWIN: u32 = 0x5C;
|
||||
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_ESCAPE: u32 = 0x1B;
|
||||
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -52,10 +114,10 @@ pub struct KeyboardHook {
|
||||
#[cfg(windows)]
|
||||
impl KeyboardHook {
|
||||
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
||||
// Store the sender globally for the hook callback
|
||||
INPUT_TX
|
||||
.set(input_tx)
|
||||
.map_err(|_| anyhow::anyhow!("Input TX already set"))?;
|
||||
// Store the sender globally for the hook callback. If it was already set (e.g.
|
||||
// a previous viewer instance in the same process), reuse the existing one rather
|
||||
// than failing — the hook handle itself is what we re-install.
|
||||
let _ = INPUT_TX.set(input_tx);
|
||||
|
||||
unsafe {
|
||||
let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?;
|
||||
@@ -78,42 +140,78 @@ 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)]
|
||||
unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code >= 0 {
|
||||
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||
let vk_code = kb_struct.vkCode;
|
||||
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_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
|
||||
|
||||
if is_down || is_up {
|
||||
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
|
||||
let should_intercept = matches!(vk_code, vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS);
|
||||
let forwarding = SEND_SYSTEM_KEYS.load(Ordering::Relaxed);
|
||||
let focused = VIEWER_FOCUSED.load(Ordering::Relaxed);
|
||||
let modifiers = current_modifiers();
|
||||
|
||||
// Send the key event to the remote
|
||||
if let Some(tx) = INPUT_TX.get() {
|
||||
let event = proto::KeyEvent {
|
||||
down: is_down,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code,
|
||||
unicode: String::new(),
|
||||
modifiers: Some(get_current_modifiers()),
|
||||
};
|
||||
// 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);
|
||||
|
||||
let _ = tx.try_send(InputEvent::Key(event));
|
||||
trace!(
|
||||
"Key hook: vk={:#x} scan={} down={}",
|
||||
vk_code,
|
||||
scan_code,
|
||||
is_down
|
||||
);
|
||||
}
|
||||
if divert {
|
||||
if let Some(tx) = INPUT_TX.get() {
|
||||
let event = proto::KeyEvent {
|
||||
down: is_down,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code,
|
||||
unicode: String::new(),
|
||||
is_extended,
|
||||
modifiers: Some(modifiers),
|
||||
};
|
||||
|
||||
// For Win key, consume the event so it doesn't open Start menu locally
|
||||
if should_intercept {
|
||||
let _ = tx.try_send(InputEvent::Key(event));
|
||||
trace!(
|
||||
"System-key hook diverted: vk={:#x} scan={} ext={} down={}",
|
||||
vk_code,
|
||||
scan_code,
|
||||
is_extended,
|
||||
is_down
|
||||
);
|
||||
}
|
||||
|
||||
// Suppress local handling of the diverted system combo.
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +221,7 @@ unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam:
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_current_modifiers() -> proto::Modifiers {
|
||||
fn current_modifiers() -> proto::Modifiers {
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
||||
|
||||
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
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
@@ -163,6 +249,73 @@ impl KeyboardHook {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub fn pump_messages() {}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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
|
||||
//! GuruConnect sessions with low-level keyboard hooks for Win key capture.
|
||||
|
||||
#[cfg(windows)]
|
||||
mod decoder;
|
||||
mod input;
|
||||
mod render;
|
||||
mod transport;
|
||||
@@ -26,9 +28,84 @@ pub enum ViewerEvent {
|
||||
pub enum InputEvent {
|
||||
Mouse(proto::MouseEvent),
|
||||
Key(proto::KeyEvent),
|
||||
// Not yet emitted by the viewer input path (special-key fidelity is pending).
|
||||
#[allow(dead_code)]
|
||||
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
|
||||
pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()> {
|
||||
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
|
||||
let viewer_tx_recv = viewer_tx.clone();
|
||||
let receive_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_receiver.recv().await {
|
||||
match msg.payload {
|
||||
Some(proto::message::Payload::VideoFrame(frame)) => {
|
||||
if let Some(proto::video_frame::Encoding::Raw(raw)) = frame.encoding {
|
||||
Some(proto::message::Payload::VideoFrame(frame)) => match frame.encoding {
|
||||
Some(proto::video_frame::Encoding::Raw(raw)) => {
|
||||
let frame_data = render::FrameData {
|
||||
width: raw.width 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;
|
||||
}
|
||||
}
|
||||
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)) => {
|
||||
let _ = viewer_tx_recv
|
||||
.send(ViewerEvent::CursorPosition(pos.x, pos.y, pos.visible))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Window rendering and frame display
|
||||
|
||||
#[cfg(windows)]
|
||||
use super::input;
|
||||
use super::{InputEvent, ViewerEvent};
|
||||
use crate::proto;
|
||||
@@ -25,9 +24,55 @@ pub struct FrameData {
|
||||
pub height: u32,
|
||||
pub data: Vec<u8>,
|
||||
pub compressed: bool,
|
||||
// Carried through from the wire frame; the renderer does not branch on it yet.
|
||||
#[allow(dead_code)]
|
||||
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 {
|
||||
window: Option<Arc<Window>>,
|
||||
surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>,
|
||||
@@ -38,6 +83,7 @@ struct ViewerApp {
|
||||
input_tx: mpsc::Sender<InputEvent>,
|
||||
mouse_x: i32,
|
||||
mouse_y: i32,
|
||||
modifiers: ViewerModifierState,
|
||||
#[cfg(windows)]
|
||||
keyboard_hook: Option<input::KeyboardHook>,
|
||||
}
|
||||
@@ -54,6 +100,7 @@ impl ViewerApp {
|
||||
input_tx,
|
||||
mouse_x: 0,
|
||||
mouse_y: 0,
|
||||
modifiers: ViewerModifierState::default(),
|
||||
#[cfg(windows)]
|
||||
keyboard_hook: None,
|
||||
}
|
||||
@@ -214,24 +261,56 @@ impl ViewerApp {
|
||||
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 {
|
||||
PhysicalKey::Code(code) => keycode_to_vk(code),
|
||||
_ => 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 {
|
||||
down: state == ElementState::Pressed,
|
||||
down,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code: 0,
|
||||
unicode: String::new(),
|
||||
is_extended: crate::input::vk_is_extended(vk_code as u16),
|
||||
modifiers: Some(proto::Modifiers::default()),
|
||||
};
|
||||
|
||||
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) {
|
||||
let Some(window) = &self.window else {
|
||||
return (x as i32, y as i32);
|
||||
@@ -316,6 +395,8 @@ impl ApplicationHandler for ViewerApp {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
info!("Window close requested");
|
||||
// Release any modifiers still held so the remote isn't left latched.
|
||||
self.release_held_modifiers();
|
||||
event_loop.exit();
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
@@ -343,13 +424,39 @@ impl ApplicationHandler for ViewerApp {
|
||||
};
|
||||
self.send_mouse_wheel(dx, dy);
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
// Note: This handles keys that aren't captured by the low-level hook
|
||||
// The hook handles Win key and other special keys
|
||||
if !event.repeat {
|
||||
self.send_key_event(event.physical_key, event.state);
|
||||
// Focus changes drive the low-level hook's focus gate. The hook is GLOBAL
|
||||
// (fires for all desktop input), so it must only divert system keys while the
|
||||
// viewer is focused; we flip `set_viewer_focused` here. On blur we also release
|
||||
// any held modifiers so they don't stay latched on the remote — winit's hook
|
||||
// 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
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
|
||||
// Process Windows messages for keyboard hook
|
||||
#[cfg(windows)]
|
||||
input::pump_messages();
|
||||
// NOTE: do NOT manually pump the Win32 message queue here. winit's own
|
||||
// run_app loop already pumps this thread's messages (which also services
|
||||
// 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
|
||||
if let Some(window) = &self.window {
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
## [0.2.0] - 2026-05-29
|
||||
## [0.3.0] - 2026-06-01
|
||||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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",
|
||||
"version": "0.2.0",
|
||||
"description": "GuruConnect Remote Desktop Viewer Components",
|
||||
"version": "0.3.0",
|
||||
"description": "GuruConnect v2 operator dashboard",
|
||||
"author": "AZ Computer Guru",
|
||||
"license": "Proprietary",
|
||||
"main": "src/components/index.ts",
|
||||
"types": "src/components/index.ts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user