58 Commits

Author SHA1 Message Date
guruconnect-ci
e967cce1a1 chore: release v0.3.0 [skip ci] 2026-06-01 00:10:58 +00:00
16586c4a1b chore: reconcile manifest versions to v0.2.2 baseline
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m14s
Build and Test / Build Server (Linux) (push) Successful in 11m25s
Build and Test / Security Audit (push) Successful in 7m13s
Build and Test / Build Summary (push) Successful in 1m2s
agent + server Cargo.toml hardcoded 0.2.0 (below the workspace.package
0.2.2 and the last release tag v0.2.2); dashboard was on a divergent
2.0.0 scheme. Align all component manifests + the dashboard lockfile to
the v0.2.2 baseline so the next release bumps them coherently to 0.3.0
rather than decreasing the dashboard. No code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:50:59 -07:00
96f9c0ab45 feat(dashboard): operator removal UI for stale machines/sessions (SPEC-004 Task 5)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m13s
Build and Test / Build Server (Linux) (push) Successful in 11m21s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 11s
Admin-only per-row Remove + multi-select bulk removal on the machines view, plus
per-row purge Remove on the sessions view, wired to the Task-5 admin API
(DELETE /api/machines|sessions/:id?purge=true, POST /api/machines/bulk-remove).
Confirm modals (danger-styled, focus-trapped), TanStack refetch so purged rows
leave the console, structured ApiError surfacing, honest partial-bulk summary,
and admin-gating via useAuth().isAdmin as defense-in-depth over the server 403.
Replaces the legacy all-user delete trigger. typecheck/lint/build clean.

Implements specs/v2-stable-identity/plan.md Task 5 (dashboard portion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:14:49 -07:00
5ee6675337 feat(server): operator removal of stale sessions/machines (SPEC-004 Task 5, server)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m29s
Build and Test / Build Server (Linux) (push) Successful in 10m58s
Build and Test / Security Audit (push) Successful in 4m4s
Build and Test / Build Summary (push) Successful in 8s
Admin-gated soft-delete + purge so operators can clear ghost machines/sessions
(the ~15-rows-for-one-host accumulation) from the console.

- migration 009: deleted_at on connect_sessions + connect_machines, with partial
  indexes WHERE deleted_at IS NULL.
- DELETE /api/machines/:agent_id?purge=true and DELETE /api/sessions/:id?purge=true
  soft-delete the row and purge the in-memory session (remove_session); the
  non-purge path keeps the legacy hard-delete / live-only disconnect. POST
  /api/machines/bulk-remove handles multi-select (batch cap 500). All admin-gated
  (AdminUser -> 403; tightens the prior any-user delete) and audited to
  connect_session_events (actor + target + trusted client IP).
- list/get queries filter deleted_at IS NULL so removed units leave the console;
  upsert revives (deleted_at = NULL) a genuinely-reconnecting machine. The
  keyed-reattach identity resolver (get_machine_by_id) is intentionally unfiltered.

Dashboard removal UI is the A3b follow-up. 86 server tests pass; fmt/clippy/test
clean. Implements specs/v2-stable-identity/plan.md Task 5 (server portion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:52:36 -07:00
cef1928379 style(server): cargo fmt for SPEC-004 Task 2 + Task 4
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m40s
Build and Test / Build Server (Linux) (push) Successful in 10m18s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 12s
Pure rustfmt reflow of the Task 2 (machine_uid dedup) and Task 4 (session
reaping) code; no logic change. The CI Build-Server-Linux job gates on
cargo fmt --check, which the two feature commits failed because local
validation ran check/clippy/test but not fmt --check. fmt --check, check,
and clippy -D warnings all clean now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:27:01 -07:00
4e80573cbd feat(server): reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4)
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m32s
Build and Test / Build Agent (Windows) (push) Has started running
Build and Test / Security Audit (push) Has started running
Build and Test / Build Summary (push) Has been cancelled
A periodic reaper removes persistent, offline, viewerless sessions whose last
heartbeat is older than a 10-minute TTL (60s sweep spawned at startup), and a
same-machine supersede on the new-session path drops a stranded prior session
when a legacy no-uid agent upgrades to a fresh agent_id + machine_uid. Both
removals re-assert the predicate under the write lock (remove_session_if) to
close a snapshot->remove TOCTOU.

Security: keyed (cak_) agents pass machine_uid=None, so they never trigger
supersede and are never reaped as a uid victim; online, viewer-attached, and
support sessions are never reaped. 82 server tests pass; clippy clean.

Implements specs/v2-stable-identity/plan.md Task 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:21:15 -07:00
ffca7f0cee feat(server): dedup machines on machine_uid (SPEC-004 Task 2)
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m48s
Build and Test / Build Agent (Windows) (push) Successful in 7m34s
Build and Test / Security Audit (push) Successful in 4m44s
Build and Test / Build Summary (push) Has been skipped
Persist the agent-reported machine_uid and dedup connect_machines on it so a
single physical machine can't register duplicate rows when its config-file
agent_id regenerates (the ghost-session root cause).

- migration 008: nullable connect_machines.machine_uid + partial unique index
  (WHERE machine_uid IS NOT NULL); idempotent, startup-applied.
- upsert_machine: two-path dedup (ON CONFLICT machine_uid when present, else
  the legacy ON CONFLICT agent_id path, unchanged).
- session reattach: a machine_uid index consulted before agent_id, with all
  removal paths purging it.
- security: keyed (cak_) agents stay authoritative — their claimed machine_uid
  is dropped (effective_machine_uid=None); uid is dedup-only for un-keyed /
  support-code agents. Startup restore skips uid-indexing keyed machines and
  fails closed if the keyed-set query errors.

74 server tests pass; clippy clean. Implements specs/v2-stable-identity/plan.md Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:06:50 -07:00
97780304e7 fix(agent): make native H.264 viewer render live frames
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m2s
Build and Test / Build Server (Linux) (push) Successful in 10m24s
Build and Test / Security Audit (push) Successful in 4m15s
Build and Test / Build Summary (push) Successful in 9s
The native viewer's H.264 path (Task 7 first-cut, compile-verified only)
never rendered a frame. Three stacked bugs, all confirmed via live loopback:

1. decoder: MF_E_NOTACCEPTING (0xC00D36B5) was treated as fatal and only
   one output was drained per call, so once the MFT filled it rejected
   every subsequent frame. decode() now returns Vec<DecodedFrame>, drains
   on back-pressure and retries the unconsumed sample, then drains all
   ready outputs.
2. decoder: the NV12 output type was hand-built and rejected by the MS
   H.264 decoder MFT (MF_E_TRANSFORM_TYPE_NOT_SET, 0xC00D6D60). It is now
   negotiated by enumerating GetOutputAvailableType on STREAM_CHANGE /
   TYPE_NOT_SET.
3. render: a manual pump_messages() in about_to_wait stole winit's own
   thread messages and froze the event loop after one iteration, so frames
   were never drained from the channel. Removed; winit's run_app pump
   already services the WH_KEYBOARD_LL hook.

Validated on a 5070 loopback: 0 decode errors, frames decode/paint/present
(present count 0 -> 1740). Reviewed (APPROVE-WITH-NITS); diagnostics stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:25:05 -07:00
afbf0d81b8 spec: add SPEC-015 Configurable Notification Overlay
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m0s
Build and Test / Build Server (Linux) (push) Successful in 11m26s
Build and Test / Security Audit (push) Successful in 4m37s
Build and Test / Build Summary (push) Successful in 12s
Comprehensive specification for on-screen notification when technician connects.

- Semi-transparent topmost window with configurable message, position, duration
- Dashboard admin settings page (enable/disable, message template, position, duration)
- Template variables: {{technician_name}}, {{company}}, {{time}}
- Agent displays overlay on StartStream, auto-hides after duration or manual dismiss
- Database: notification_config singleton table
- Protobuf: NotificationConfig message in StartStream
- Priority: P2, Effort: Medium (3-4 weeks)
- Added to roadmap under Core Remote Control

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-31 08:40:53 -07:00
b45c683a51 spec: add SPEC-014 Branding and White-Label Configuration
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m16s
Build and Test / Build Server (Linux) (push) Successful in 11m48s
Build and Test / Security Audit (push) Successful in 4m35s
Build and Test / Build Summary (push) Successful in 13s
Comprehensive specification for branding/whitelabel configuration.

- Dashboard admin settings page (logo, brand hue, product name, company name, favicon)
- OKLCH color system with CSS variables for dynamic theming
- Agent tray tooltip customization via registry key
- Singleton database table with public GET endpoint
- Priority: P2, Effort: Medium (4-6 weeks)
- Added to roadmap under Server/API (v2 Phase 2)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-31 08:12:37 -07:00
5637e4c1f9 spec: add SPEC-013 Windows Session Selection and Backstage Mode
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m5s
Build and Test / Build Server (Linux) (push) Successful in 11m24s
Build and Test / Security Audit (push) Successful in 4m30s
Build and Test / Build Summary (push) Successful in 12s
2026-05-31 07:54:25 -07:00
b3e8f32734 feat(agent): derive + report deterministic machine_uid (SPEC-004 Task 1)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m4s
Build and Test / Build Server (Linux) (push) Successful in 9m41s
Build and Test / Security Audit (push) Successful in 4m11s
Build and Test / Build Summary (push) Successful in 10s
Agent now derives a recomputable, opaque machine_uid (Windows: SHA-256 of the OS
MachineGuid at HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid -> muid_<hex>;
non-Windows / registry-failure: persisted random UUID, warn-logged). Raw GUID
never exposed; OnceLock-cached. Reported ALONGSIDE agent_id (unchanged) on
AgentStatus (new additive proto field 12) and in the connect handshake query.
This is the stable identity that fixes config-loss duplicate registrations
(DESKTOP-I66IM5Q x9); server-side dedup keying that consumes it is SPEC-004
Task 2. Non-breaking, isolated. 5 unit tests; cargo fmt/clippy(-D warnings)/test
green on GURU-5070.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:23:11 -07:00
92bc522c3a spec: add v2-stable-identity implementation plan (SPEC-004 breakdown)
Some checks failed
Build and Test / Build Server (Linux) (push) Has started running
Build and Test / Build Agent (Windows) (push) Has started running
Build and Test / Security Audit (push) Has been cancelled
Build and Test / Build Summary (push) Has been cancelled
Ordered, execution-ready plan for SPEC-004 (stable machine identity + session
reaping + operator removal). Works out the core integration: machine_uid =
deterministic MachineGuid-based hardware identity (recomputable, so config loss
can't duplicate); per-agent cak_ key stays the credential/trust boundary; they
compose so one cak_ key per machine_uid = one key per real machine (the
prerequisite the fleet key-migration #7 needs). Root cause grounded in code:
agent_id is a random UUID (config.rs:90), connect_machines dedups on ON CONFLICT
(agent_id), so config loss -> duplicate rows (DESKTOP-I66IM5Q x9 live). 5 ordered
tasks (agent uid -> server dedup -> reconcile/age-out -> reaping -> operator
removal). Unblocks #7 -> #5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:17:49 -07:00
df51d40094 feat(server): per-agent H.264 test override (h264-test tag) [Task 8 prep]
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m32s
Build and Test / Build Server (Linux) (push) Successful in 10m55s
Build and Test / Security Audit (push) Successful in 4m14s
Build and Test / Build Summary (push) Successful in 11s
Lets the HW-H.264 path be live-validated on tagged test agents without affecting
the live client fleet. Adds H264_TEST_TAG="h264-test" + a pure prefer_h264_for(tags)
helper (DEFAULT_PREFER_H264 || tags contains the tag, case-insensitive); StartStream
codec negotiation now computes prefer_h264 from the agent's reported tags instead of
the bare const, and logs the computed value. SAFETY: untagged sessions are byte-for-
byte unchanged (prefer_h264 == DEFAULT_PREFER_H264 == false -> raw); the supports_h264
guard still forces raw for a no-HW agent even when tagged. DEFAULT_PREFER_H264 stays
false (flipping the global default is a separate future step). 3 unit tests added.
cargo fmt/clippy(-D warnings)/test green on GURU-5070 (37 agent + 64 server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:17:38 -07:00
7be8f454e0 Merge remote security fixes with local specs
All checks were successful
Build and Test / Build Server (Linux) (push) Successful in 9m56s
Build and Test / Build Agent (Windows) (push) Successful in 6m21s
Build and Test / Security Audit (push) Successful in 4m21s
Build and Test / Build Summary (push) Successful in 9s
2026-05-30 19:21:42 -07:00
c98692e424 fix(server): revoke viewer tokens on logout + stop logging chat content
Some checks failed
Build and Test / Build Server (Linux) (push) Has started running
Build and Test / Build Agent (Windows) (push) Has started running
Build and Test / Security Audit (push) Has been cancelled
Build and Test / Build Summary (push) Has been cancelled
Security follow-ups (audit 2026-05-30, both reviewed APPROVE):
- MEDIUM: viewer tokens were never blacklisted on logout, so a minted
  session-scoped viewer token stayed valid up to its 5-min TTL after the user
  logged out. Add a per-user ViewerTokenRegistry (Arc<Mutex<HashMap<sub,
  Vec<(token, expires_at)>>>>, prune-on-insert) on AppState; mint_viewer_token
  registers each token under the user sub; logout drains take_for_user(sub) and
  blacklists each via the existing token_blacklist. The viewer WS already calls
  is_revoked, so no WS change. Key chain user.user_id == ViewerClaims.sub ==
  registry key verified consistent. 8 new tests.
- LOW: relay chat logs now emit content length, not the chat body (support-chat
  can carry secrets/PII).
cargo fmt/clippy(-D warnings)/test green on GURU-5070 (37 agent + 61 server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:20:15 -07:00
761bae5d01 spec: update SPEC-012 to include both Serial Console + PTY Shell modes
Major update to SPEC-012 adding dual-mode terminal access:

Mode 1: Serial Console Mode (True Remote Console)
- Direct access to system serial console (/dev/ttyS0 or /dev/console)
- Sees GRUB bootloader, kernel boot messages, login prompts, kernel panics
- Boot-time interaction: select GRUB entries, edit kernel parameters, single-user mode
- Requires root privileges or CAP_SYS_TTY_CONFIG capability
- Setup: GRUB + kernel parameters configured for serial console output
- Like KVM-over-IP or IPMI Serial-over-LAN (text-mode equivalent)

Mode 2: PTY Shell Mode (Interactive Shell)
- Spawn pseudo-TTY with bash/zsh shell session
- Normal server management (package updates, log review, etc.)
- Runs as unprivileged agent service user
- Standard interactive shell with full ANSI/VT100 support

Architecture:
- Agent mode selection based on viewer request (console vs. shell)
- Dashboard shows two buttons: "Console" and "Shell" for headless agents
- Same xterm.js viewer handles both modes transparently
- Protobuf extensions: TerminalModeRequest enum, console_mode flag

Security:
- Console mode requires root (boot-level control risk)
- Recommend RBAC: separate console_access and shell_access permissions
- Console sessions should require MFA (Phase 2)
- Audit logging for both modes

Setup Requirements:
- One-time GRUB configuration for serial console
- systemd service with CAP_SYS_TTY_CONFIG for console mode
- serial-getty@ttyS0.service enabled for login prompt

Updated effort: Medium (5-7 weeks, up from 4-6)
Priority remains P2

Addresses user request for "remote console" (as if at the machine)
not just shell access.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 19:02:27 -07:00
8119292bcd fix(agent): close auto-update TLS bypass (MITM -> RCE) [HIGH]
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m24s
Build and Test / Build Server (Linux) (push) Successful in 10m41s
Build and Test / Security Audit (push) Successful in 4m19s
Build and Test / Build Summary (push) Successful in 9s
The auto-update path built both reqwest clients with an unconditional
danger_accept_invalid_certs(true), so a network MITM could serve an arbitrary
update .exe (checksum is no defense — same unverified channel) and gain RCE on
every managed endpoint. Replace with dev_insecure_tls() = cfg!(debug_assertions)
&& env GURUCONNECT_DEV_INSECURE_TLS: the cfg gate compiles out of release builds,
so a shipped agent ALWAYS verifies certs; dev keeps a self-signed escape hatch.
Loud warn when the insecure path is taken; verify_checksum kept + documented as
transport-integrity (not tamper) defense; TODO + follow-up for embedded-key
update signing (defense-in-depth). Release-invariant unit test added.
cargo fmt/clippy(-D warnings)/test green on GURU-5070 (90 tests). Closes the
2026-05-30 security-audit HIGH (reports/2026-05-30-gc-audit.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:02:23 -07:00
9f44807230 audit: security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED
Some checks failed
Build and Test / Build Agent (Windows) (push) Successful in 7m1s
Build and Test / Build Server (Linux) (push) Successful in 10m17s
Build and Test / Security Audit (push) Has started running
Build and Test / Build Summary (push) Has been cancelled
Independent /gc-audit --pass=security re-derivation of the v2 secure-session-core
rebuild: all three 2026-05-29 relay CRITICALs confirmed closed with no bypass
(any-JWT-joins-session, viewer-WS blacklist, JWT-as-agent-key). Relay plane clean;
consent/code paths fail closed; abuse surface bounded; rate limiting proxy-aware.
Net-new: 1 HIGH (agent auto-update disables TLS cert verification -> MITM-RCE,
agent/src/update.rs:45,111 — outside the relay plane), 1 LOW (chat content logged),
2 INFO. Report: reports/2026-05-30-gc-audit.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:48:48 -07:00
a062a825ea spec: add SPEC-012 Headless Linux Mode (Direct TTY Access)
Comprehensive specification for terminal-based remote access to headless
Linux servers (no X11/Wayland GUI):

Core Capabilities:
- PTY spawn via openpty() + fork/exec shell (/bin/bash or $SHELL)
- Terminal I/O: PTY output → TerminalData protobuf → WebSocket relay
- Input: keyboard → TerminalInput protobuf → PTY master write
- Resize: SIGWINCH on terminal window resize, TIOCSWINSZ ioctl
- Auto-detection: agent detects headless environment (no DISPLAY) at runtime

Viewer:
- xterm.js-based web terminal (80x24 default, resizable)
- Full ANSI/VT100 support (colors, cursor control, vim/nano/htop)
- Same protobuf-over-WSS protocol, support-code/agent-key auth
- Dashboard shows "Terminal" badge, routes to terminal viewer

Use Cases:
- Server management (headless Ubuntu Server, VMs, containers)
- Emergency recovery (systemd rescue mode, single-user mode)
- Container debugging (exec into running containers)
- SSH replacement with centralized audit logging

Protobuf Extensions:
- TerminalData, TerminalInput, TerminalResize messages
- AgentStatus.terminal_mode flag

Security:
- Run agent as unprivileged user + sudo for privileged commands
- Session recording to terminal_recordings table (asciicast format)
- Same auth model as GUI agents (support-code / per-agent key)

Estimated effort: Medium (4-6 weeks)
Priority: P2 (server management is market-critical)

Extends SPEC-010 Linux agent with PTY alternative to screen capture.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 18:28:34 -07:00
b1862800a1 spec: add SPEC-011 Mobile Agent Support (iOS and Android)
Comprehensive specification for iOS/Android devices as remote control targets:

iOS Agent (View-Only):
- ReplayKit 2 screen capture (user consent required)
- VideoToolbox H.264 encoding
- NO input injection (iOS sandboxing limitation)
- APNs push notifications for session requests
- Foreground-only operation (OS requirement)

Android Agent (View + Control):
- MediaProjection API screen capture (user consent)
- MediaCodec H.264 encoding
- Accessibility Service for input injection (tap/swipe/type)
- FCM push notifications
- Foreground service with persistent notification

Architecture:
- Native Swift/SwiftUI (iOS) and Kotlin/Jetpack Compose (Android) apps
- Same protobuf-over-WSS protocol as desktop agents
- Support-code authentication (persistent mode deferred to Phase 2)
- Minor protobuf additions: MobileCapabilities, TouchEvent
- Server push module: APNs (a2 crate) + FCM HTTP v1

Key constraints:
- Attended-only sessions (user must grant permission)
- Foreground-only (cannot capture in background on either platform)
- iOS view-only (platform sandbox prevents input injection)
- Consent-first model (MediaProjection/ReplayKit user prompts)

Estimated effort: X-Large (16-20 weeks, requires mobile expertise)
Priority: P3

Distinct from GuruRMM SPEC-017 (MDM/inventory) — this is remote
control, not device management.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 18:24:16 -07:00
442eecefc0 fix(server,agent): apply Tasks 3-5 review fixes (non-blocking)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m6s
Build and Test / Build Server (Linux) (push) Successful in 10m39s
Build and Test / Security Audit (push) Successful in 4m14s
Build and Test / Build Summary (push) Successful in 8s
From the secure-session-core Tasks 3-5 code review (APPROVE-WITH-FIXES):
- MEDIUM-2: delete the dead `validate_agent_key` "accept-any-key" placeholder +
  its AuthenticatedAgent/AuthState scaffolding (zero callers; the real agent
  auth is validate_agent_api_key + per-agent cak_ keys). Removes an auth landmine.
- LOW-3: stop interpolating support-code values into 3 relay log lines (bearer
  credentials).
- LOW-1: document the X-Real-IP trust requirement in ip_extract.rs (NPM must set
  it from $remote_addr); behavior unchanged.
- LOW-2: correct the consent/heartbeat comment in agent session loop (the loop
  awaits the dialog; safe because CONSENT_TIMEOUT 60s < HEARTBEAT_TIMEOUT 90s).
cargo fmt/clippy(-D warnings)/test all green on GURU-5070 (89 tests, 0 warnings).
MEDIUM-1 (viewer-token logout revocation) remains a tracked follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:23:03 -07:00
5e2325507f spec: add SPEC-010 Cross-Platform Agent Support (macOS and Linux)
Comprehensive specification for expanding agent support beyond Windows:

macOS Agent (Priority 1):
- ScreenCaptureKit API (macOS 13+) with AVFoundation fallback
- CGEvent input injection
- VideoToolbox H.264 encoding
- NSStatusItem menu bar icon
- Universal binary (x86_64 + arm64)
- Code signing and notarization

Linux Agent (Priority 2):
- X11 XShm screen capture with Wayland detection
- XTest input injection
- VA-API hardware H.264 encoding with software fallback
- StatusNotifier system tray
- .deb and .rpm packaging

Architecture:
- Platform abstraction layer (traits for capture/input/encoder/tray)
- Refactor existing Windows code behind PlatformCapture/Input/Encoder
- No protobuf protocol changes
- Same authentication (support codes and agent keys)

Estimated effort: X-Large (12-16 weeks)
Priority: P2 (market-critical for multi-platform MSP adoption)

Updated roadmap: promoted from P3 to P2 with full spec link.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 18:15:16 -07:00
c736a710a1 docs: record Tasks 3-5 code review (APPROVE-WITH-FIXES) in plan status
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m43s
Build and Test / Build Agent (Windows) (push) Successful in 7m43s
Build and Test / Security Audit (push) Successful in 4m57s
Build and Test / Build Summary (push) Has been skipped
Formal review on GURU-5070: cargo fmt/clippy/test green (89 tests, 0 warnings);
the 3 audit CRITICALs verified closed with no bypass; all security paths fail
closed. Non-blocking follow-ups tracked (viewer-token logout revocation, delete
dead validate_agent_key placeholder, X-Real-IP/log hygiene). Remaining for
Phase-1 exit: Task 8 e2e verification + /gc-audit security re-audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:14:02 -07:00
786d3e47af docs: correct roadmap — v2 Phase 1 already landed, not a future sprint
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m12s
Build and Test / Security Audit (push) Successful in 4m53s
Build and Test / Build Agent (Windows) (push) Successful in 7m14s
Build and Test / Build Summary (push) Has been skipped
Re-baseline against actual git/deploy state: secure-session-core Tasks 1-7 are
committed and DEPLOYED; the 3 audit CRITICALs are closed and live in prod
(verified: deployed checkout abc55ab descends from the CRITICAL#1 fix + Task 7;
guruconnect.service running on :3002). The prior "Sprint 0: bypasses are live"
banner was wrong (stale 2026-05-29 audit narrative) and is removed. Remaining
to exit Phase 1 = secure-session-core Task 8 (e2e verification + security
re-audit) + Code-Review sign-off on Tasks 3-5. Schema note corrected
(connect_agent_keys + tenancy already exist via migration 004).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:36:18 -07:00
03f62d413f docs: annotate roadmap with v2-first direction + phase mapping
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m54s
Build and Test / Build Agent (Windows) (push) Has started running
Build and Test / Security Audit (push) Has started running
Build and Test / Build Summary (push) Has been cancelled
Mark SPEC-003..009 as work-items inside the SPEC-002 v2 phases (not standalone
v1 backlog): banner records the v2-reset decision + the Sprint-0 relay-auth
CRITICAL hotfix, a phase-mapping table (004->P1, 008->P0/1, 003/005/006/007->P2,
009->P3), inline [-> v2 Phase N] tags per spec, and a note to bake SPEC-003
inventory cols + SPEC-004 machine_uid + connect_agent_keys into the Phase-0
fresh schema. Sprint planning 2026-05-30 (Mike: v2 reset first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:26:47 -07:00
7ab87384a7 spec: add SPEC-009 feature-rich documented API
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m42s
Build and Test / Build Agent (Windows) (push) Successful in 7m39s
Build and Test / Security Audit (push) Successful in 4m34s
Build and Test / Build Summary (push) Has been skipped
Everything the console does should be callable by API, documented and
discoverable. Adds: OpenAPI 3.x generated from code (utoipa) + Swagger/Redoc at
/api/docs (drift-proof, route<->spec parity test); long-lived revocable scoped
API tokens (connect_api_tokens, hashed like agent keys) distinct from the 24h
dashboard JWT and agent keys; an API-completeness gap audit (folds in SPEC-004/
006/007 endpoints); consistent pagination/filtering + versioning policy. Today
there is zero API doc tooling and no programmatic token. Depends on SPEC-008 for
the documented error envelope; distinct from the ADR-001 integration contract.
Large. Parallel guru-rmm SPEC-019. Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:35:57 -07:00
65eff5cf50 spec: add SPEC-008 valuable error messages
Cross-cutting error-quality initiative: one structured AppError envelope
(stable error_code + message + correlation_id) replacing the current ad-hoc
mix (bare (StatusCode,&str) tuples, per-file ErrorResponse, two JSON envelopes
the dashboard already unions); correlation-id middleware tied to tracing spans
+ response header so a reported id greps the log; contextual error logging with
identifiers + error chain; sweep the 37 server `let _ =` swallows (the pattern
that silently hid migration-005's missing columns); dashboard renders the real
cause + correlation id (drop the hardcoded generic at MachinesPage.tsx:202);
agent logs why/where auth/connection failed (the auth-loop incident gave no
local signal). Phaseable; Large. Parallel RMM request keeps conventions aligned.
Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:30:07 -07:00
008d2bf30b spec: add SPEC-007 managed-agent installer builder
Dashboard "Build Installer" wizard for pre-labeled managed/persistent agents
(Name/Company/Site/Department/Device Type/Tag/Type) with Download / Copy URL /
Send Link, ScreenConnect-style. The embed-config build path already exists
(downloads.rs appends EmbeddedConfig GURUCONFIG blob; AgentDownloadParams takes
company/site/tags/api_key; agent reads it at config.rs:223) - missing is the UI,
department + device_type fields (EmbeddedConfig/AgentStatus/connect_machines),
name strategy, and Copy-URL/Send-Link actions. Labels persist at install time,
feeding SPEC-003/005/006. Embedded key should be revocable per-machine/site
(pairs with SPEC-004). Biggest open question: appending config after Authenticode
signing invalidates the signature. Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:24:56 -07:00
0eb38520ed spec: add SPEC-006 universal machine search
Single search box matching case-insensitive substring across ALL machine
attributes (OS, logged-on user, external/private IP, company, site, tag,
serial, MAC, client version, ...) server-side, ScreenConnect-style. Replaces
the dashboard's hostname/agent_id-only client filter (inadequate at ~900+
machines). pg_trgm GIN index over a concatenated searchable-text expression
(INET cast to text, tags via array_to_string); multi-term AND; optional
field-scoped syntax (os:/user:/ip:). Parameterized + fixed column allowlist
(no injection), admin-guarded, DoS-capped. Depends on SPEC-003 (attrs must be
persisted to be searchable); reuses SPEC-005 enriched payload. Requested by
Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:21:10 -07:00
cdc182f0fb spec: add SPEC-005 machines list view (dual indicators + rich rows)
ScreenConnect "Access"-list parity for the Operator Console machines list:
per-row dual Host/Guest connection indicators (Guest=agent is_online,
Host=viewer_count>0 with viewer names + durations) and rich inline metadata
(company, site, device type, tags, logged-on user + idle, client version in
red when outdated). Live Host/Guest state already exists on SessionInfo
(is_online, viewer_count, viewers); main work is enriching /api/machines with
that + SPEC-003 inventory and redesigning MachinesPage rows. Depends on
SPEC-003 (data), reads cleanest after SPEC-004 (dedup), dovetails SPEC-002
Phase 2. Company-tree nav split out as a P3 follow-up. Requested by Mike
2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:17:48 -07:00
f8bd4d1dab spec: SPEC-004 add stable machine-derived identity as the primary fix
Address duplicate registration at the source, not just via cleanup. Root
cause now grounded: agent_id is a random UUID (config.rs:90 generate_agent_id)
persisted only in the config file, so a portable/misconfigured execution
(the Pavon desktop launcher) regenerates a fresh id each launch, defeating
both the DB upsert (ON CONFLICT agent_id) and session-reuse dedupe. Add a
deterministic machine_uid (Windows MachineGuid-based, recomputable) keyed by
registration; reaping/supersede become defense-in-depth. Security: machine_uid
is identity not authorization and must be bound to the per-machine agent key
to prevent session/record hijack. Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:11:38 -07:00
ee900c6395 spec: add SPEC-004 session lifecycle reaping + operator removal
Stop orphaned managed sessions accumulating in the Operator Console and let
admins remove stale sessions/units individually and in bulk. Root cause
confirmed in code: the Sessions list is the in-memory SessionManager;
register_agent reconnect-reuse keys on a stable agent_id (session/mod.rs:169)
and persistent sessions are never reaped on disconnect (session/mod.rs:519-542),
so an agent reconnecting with a fresh agent_id leaves a new retained ghost
session each time (observed: 15 sessions/0 live, ~10 orphans for one machine
after a GuruConnect-client reconnect storm). Adds TTL sweep + same-machine
supersede, admin-gated audited purge + bulk endpoints, and dashboard
multi-select removal. Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:05:32 -07:00
abf499cb23 spec: add SPEC-003 full machine inventory in connection DB
Persist a complete per-machine device inventory on connect_machines
(OS+locale+install, CPU/RAM, mfr/model/serial, external WAN IP captured
server-side via trusted-proxy client_ip + private LAN IP + MAC, logged-on
user, idle, time zone, uptime, local-admin-present), refreshed each
AgentStatus and surfaced in the dashboard machine detail — ScreenConnect
"Guest Info" parity. Data layer for SPEC-002 Phase 2; closes the GC side
of the agent-IP gap (coord todo 7459428e). Requested by Mike 2026-05-30.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:48:09 -07:00
abc55abb0b fix(server): tolerate NULL connect_machines columns (tags decode bug)
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m37s
Build and Test / Build Agent (Windows) (push) Successful in 7m30s
Build and Test / Security Audit (push) Successful in 4m49s
Build and Test / Build Summary (push) Has been skipped
connect_machines.tags is text[] nullable with no default; the derived
FromRow decoded it as non-Option Vec<String>, so rows with NULL tags
threw "unexpected null" - breaking managed-session reconcile on startup
and the authed Machines list. Hit in production on the v2 cutover.

- Replace the derived FromRow on Machine with a manual impl that decodes
  every nullable-non-Option column as Option<T> with unwrap_or_default
  (tags, is_elevated, is_persistent, status, timestamps), fixing all six
  read sites at once. Public field types unchanged.
- migrations/007: backfill NULL tags to empty array, set DEFAULT '{}',
  set NOT NULL (no writer inserts NULL: upsert omits tags, metadata
  update binds a non-null array). Idempotent with the prod hot-patch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:17:12 -07:00
96b4fd7721 feat(dashboard): GuruConnect v2 Users admin view
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m43s
Build and Test / Build Agent (Windows) (push) Successful in 8m48s
Build and Test / Security Audit (push) Successful in 4m38s
Build and Test / Build Summary (push) Has been skipped
Admin-only user management: list, create, edit role/permissions/status,
reset password, and disable/delete, against the v2 users API.

- Admin-gated three ways: AdminRoute on /users (calm access-denied panel
  for non-admins, no redirect loop or data fetch), Sidebar hides the nav
  item, and every mutation relies on the server AdminUser 403 as the real
  authority. isAdmin is derived from the server-validated user, not the
  client token.
- Users table: role badge (admin/operator/viewer), permissions summary,
  enabled/disabled status, created, last-login. Sticky header, skeleton,
  empty/error states. Self row tagged "You".
- Create/edit use the real roles and permission strings
  (view/control/transfer/manage_users/manage_clients); admin permissions
  are server-implicit and shown locked. Passwords: typed or Web Crypto
  generated (rejection-sampled, copy-once reveal), type=password +
  autoComplete=new-password, cleared from state on open/close/success,
  never logged/persisted/in-URL; blank on edit means unchanged.
- Self-lockout guards: cannot disable, delete, or demote your own admin
  account (controls disabled + submit-handler checks, matched on the
  authoritative user id). Server mirrors self-disable/self-delete; the
  self-demotion guard is client-side (server todo filed).
- useUpdateUser sequences user-update then permissions-set; invalidates
  ["users"] on settled so the table reconciles after a partial failure,
  with an actionable message if only permissions failed.

Passed Code Review (no blockers after fixes) and local gates
(tsc/lint/build green). Completes the v2 dashboard view set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:18:40 -07:00
664f33d5ab feat(dashboard): GuruConnect v2 Support Codes view
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m27s
Build and Test / Build Agent (Windows) (push) Successful in 7m11s
Build and Test / Security Audit (push) Successful in 4m32s
Build and Test / Build Summary (push) Has been skipped
Generate, list, and cancel attended-support codes (XXX-XXX-XXX), built
on the v2 codes API and existing UI primitives.

- Codes table: code in mono, status badge (pending+pulse/connected/
  completed/cancelled), bound client/machine, created-by, created
  (relative + absolute tooltip). Sticky header, skeleton load,
  actionable empty/error states.
- Generate opens a focused reveal modal showing the code large in
  JetBrains Mono with copy and a read-aloud instruction; the code is
  announced character-by-character for screen readers. Mint is ref-
  guarded so it creates exactly one code per open (no StrictMode dupe).
- Cancel via confirm dialog (POST /api/codes/:code/cancel), disabled for
  non-cancellable statuses; invalidates the codes query. List polls 7s.
- Shared API client now tolerates non-JSON 200 bodies, so the cancel
  endpoint's plain-text "Code cancelled" success no longer surfaces as a
  failure. Error-envelope handling unchanged.

Passed Code Review (no blockers after fixes) and local gates
(tsc/lint/build green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:59:18 -07:00
67f3722b3c feat(server): serve dashboard SPA with deep-link fallback; remove v1 portal
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m26s
Build and Test / Build Agent (Windows) (push) Successful in 7m17s
Build and Test / Security Audit (push) Successful in 4m29s
Build and Test / Build Summary (push) Has been skipped
Axum now serves the v2 React/Vite dashboard SPA at / with a client-side
routing fallback, and the dead v1 HTML portal is removed (nothing was
live on the server to preserve).

- SPA served from server/static/app via ServeDir with a fallback to
  index.html, so deep links (/machines, /sessions) resolve to the SPA.
- /api/*rest and /ws/*rest return JSON 404 so unrouted API/WS paths never
  leak index.html to clients; real /api, /ws, /health, /metrics, and the
  /downloads nest keep precedence (matchit static-over-wildcard).
- Path-aware Cache-Control: hashed /assets immutable, index.html no-cache.
- Vite builds to server/static/app (base /); the artifact is gitignored
  and rebuilt at deploy time (npm ci && npm run build).
- Removed v1 portal files (login/dashboard/users/index/viewer .html) and
  their dead serve_* handlers; the SPA owns /, /login, /dashboard, /users.

Verified locally: server boots, / and deep links serve the SPA, unknown
/api path returns JSON 404 (not HTML), /health and /downloads intact.
cargo build + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:44:13 -07:00
6ecb937eb6 feat(dashboard): GuruConnect v2 Sessions view (pass 2)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m9s
Build and Test / Build Server (Linux) (push) Successful in 10m41s
Build and Test / Security Audit (push) Successful in 4m25s
Build and Test / Build Summary (push) Successful in 10s
Active-sessions table with consent-state badges, viewer-token Join,
and disconnect, built on the v2 session API and existing UI primitives.

- Sessions table: machine, mode (managed/attended), consent badge
  (granted/pending+pulse/denied/not_required), viewers, started,
  duration, status. Sticky header, skeleton load, empty/error states.
- Join action mints a session-scoped viewer token
  (POST /api/sessions/:id/viewer-token) and reveals it with the
  /ws/viewer relay URL and copy buttons. The static viewer.html is
  intentionally not targeted: it sends the raw login JWT, which the v2
  viewer plane rejects. In-dashboard web viewer ships in a later pass.
- Authz split mirrors the server mint gate: admin or control permission
  gets Control; view permission gets View only; neither hides the action.
  Server remains authoritative; the minted token carries the signed
  access claim.
- Disconnect via confirm dialog (DELETE /api/sessions/:id), invalidates
  the sessions query. List polls every 8s so consent transitions surface.

Passed Code Review (no blockers) and local gates (tsc/lint/build green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:12:04 -07:00
43a9432b81 feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m56s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s
React + Vite + TypeScript SPA: scaffold, operations-terminal design
system, Bearer-token auth, and the Machines view.

- Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan),
  Hanken Grotesk + JetBrains Mono, status-color language
  (online/offline/granted/pending/denied/not_required), motion with
  prefers-reduced-motion honored.
- Auth: token in sessionStorage via ref (never React state), protected
  routes, 401 session teardown, admin-gated per-agent-key UI.
- Machines view: data table (sticky header, keyboard-activated rows,
  skeleton loading, actionable empty/error states), non-blocking detail
  drawer, delete confirm, admin key management with copy-once reveal.
- UI primitives: Modal (focus trap + inert + portal + dialogStack),
  Drawer, Table, Badge/StatusDot, toast, states.
- Typed API client normalizing the two error-envelope shapes.

Passed Code Review (no blockers), impeccable critique-and-polish, and
local gates (tsc/lint/build green). Dev-only Vite proxy to :3002.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:51:11 -07:00
f9bdecbfdb feat(agent,server): v2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m57s
Build and Test / Build Server (Linux) (push) Successful in 10m23s
Build and Test / Security Audit (push) Successful in 4m15s
Build and Test / Build Summary (push) Successful in 9s
SPEC-002 Phase 1 Task 7 (the last), code-reviewed APPROVED, locally verified
(cargo fmt + clippy -D warnings exit 0 + cargo test --workspace 89 pass + build).

- Encoder trait + factory: RawEncoder (salvaged, UNCHANGED) and H264Encoder,
  selected by negotiation; factory falls back to raw on H.264 init failure.
- Negotiation: agent advertises supports_h264 (MFTEnumEx HW probe, cached) in
  AgentStatus; server picks the codec via select_video_codec(supports, prefer)
  and stamps StartStream.video_codec; agent re-guards on local HW. Policy
  constant DEFAULT_PREFER_H264 = false, so RAW is negotiated for every session
  today - H.264 stays dormant until live hardware validation (Task 8).
- MF H.264 encoder (h264.rs, FIRST-CUT / compile-verified-only): HW encoder MFT,
  BGRA->NV12 (color.rs, unit-tested), sync drain, fall-back-to-raw on any failure.
- Viewer H.264 decoder (decoder.rs, FIRST-CUT): MF decoder on a dedicated COM
  thread; drops+logs on failure, raw render path untouched.
- proto additive: VideoCodec enum, StartStream.video_codec=3,
  SessionResponse.video_codec=5, AgentStatus.supports_h264=11.
- Raw+Zstd path byte-for-byte unchanged; remains the guaranteed default/fallback.

Review confirmed unsafe impl Send for H264Encoder is sound (single-owned &mut on
the block_on thread; session future never spawned) and every MF failure degrades
to raw. H.264 is NOT claimed functional - compile/clippy/build-verified only;
live validation + force-IDR + the no-spawn-invariant doc are Task 8 go-live gates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:35:04 -07:00
bb73ba667f feat(agent): v2 secure-session-core Task 6 - full key fidelity
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m1s
Build and Test / Build Server (Linux) (push) Successful in 11m32s
Build and Test / Security Audit (push) Successful in 4m31s
Build and Test / Build Summary (push) Successful in 11s
SPEC-002 Phase 1 Task 6, code-reviewed APPROVED (2 rounds), locally verified
(cargo fmt + clippy -D warnings exit 0 + cargo test --workspace 70 pass + build).

- Viewer WH_KEYBOARD_LL hook diverts system combos (Win/Win+R, Alt+Tab, Alt+Esc,
  Ctrl+Esc) to the remote as a full KeyEvent (vk + scan + is_extended + modifiers)
  and suppresses local handling - GATED on the viewer window having focus AND a
  "send system keys" toggle (default on; Pause/Break host-key), so it never bricks
  the technician's local keyboard when unfocused.
- Agent injection via SendInput KEYEVENTF_SCANCODE + correct KEYEVENTF_EXTENDEDKEY
  (right Ctrl/Alt, arrows, nav, Win, NumLock, numpad Divide) - layout-independent,
  extended-key-correct.
- Ctrl+Alt+Del completes through the SAS helper (SYSTEM SendSAS); installer sets
  the SoftwareSASGeneration policy; 3-tier fail-loud (no false success). SAS named
  pipe DACL tightened from NULL/Everyone to Authenticated Users.
- Modifier hygiene: viewer emits key-ups for held Ctrl/Alt/Shift/Win on focus loss
  / close so modifiers never stick on the remote.
- proto: KeyEvent.is_extended = 7 (additive; older agents derive the flag).

Closes Win+R / Ctrl+C-V / Ctrl+Alt+Del / arrows-vs-numpad fidelity. Live on-device
testing is plan Task 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:16:26 -07:00
d0de888dd1 style(agent): clear 77 pre-existing clippy -D warnings
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m53s
Build and Test / Build Server (Linux) (push) Successful in 10m59s
Build and Test / Security Audit (push) Successful in 4m31s
Build and Test / Build Summary (push) Successful in 10s
CI never ran clippy on the agent crate (the build-server clippy job is
Linux-only and can't compile the Windows agent; build-agent only runs cargo
build), so 77 clippy -D-warnings errors had accumulated. Behavior-preserving
cleanup, code-reviewed APPROVED, locally verified (cargo clippy --workspace
--all-targets --all-features -- -D warnings exits 0; cargo test --workspace =
57 passed).

- let _ = on Win32 resource-teardown BOOL returns (gdi.rs); fallible
  BitBlt/GetDIBits stay error-handled
- removed unused imports/vars; idiom fixes (div_ceil, is_null, transmute
  annotations, match collapsing, useless_conversion)
- #[allow(dead_code)] + comment on genuine Task-6/7 scaffolding (vk consts,
  SpecialKey emission, SAS mgmt API, modifier tracking, GDI frame-diff fields)
- Cargo.lock: cargo pruned ~147 stale transitive entries (no version changes)

Follow-up: add cargo clippy -D warnings to the build-agent CI job so the agent
crate stays clippy-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:51:45 -07:00
fbf9e26f5a style(server,agent): fmt + clippy fixes for Task 5 (CI green)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m29s
Build and Test / Build Server (Linux) (push) Successful in 12m9s
Build and Test / Security Audit (push) Successful in 5m23s
Build and Test / Build Summary (push) Successful in 11s
9082e11 compiles + passes all 50 server tests on the build host; only blocked
CI on cargo fmt (4 files) and one clippy -D dead-code denial:
- cargo fmt --all (relay/mod.rs, session/mod.rs, agent consent/mod.rs + session/mod.rs)
- #[cfg_attr(not(test), allow(dead_code))] on session::get_consent_state (a
  read accessor currently exercised only by tests)
No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:59:29 -07:00
9082e11490 feat(server,agent): v2 secure-session-core Task 5 - attended consent
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 5m42s
Build and Test / Build Agent (Windows) (push) Successful in 8m22s
Build and Test / Security Audit (push) Successful in 5m12s
Build and Test / Build Summary (push) Has been skipped
SPEC-002 Phase 1 Task 5, code-reviewed APPROVED. An attended (support-code)
session is invisible and inert to the technician until the end user accepts a
consent prompt on their own machine.

- proto: ConsentRequest / ConsentResponse + ConsentAccessMode enum (oneof
  fields 80/81; no existing field renumbered).
- server: ConsentState on Session; attended -> Pending, managed -> NotRequired;
  join_session refuses viewers unless Granted/NotRequired (single chokepoint -
  StartStream only fires from join_session, so no frames or input flow pre-
  consent); run_consent_handshake sends ConsentRequest, 60s timeout, granted ->
  proceed, denied/timeout/disconnect -> teardown (end_session denied, machine
  offline, support code released). consent_state persisted; consent_requested/
  granted/denied audited.
- agent: Windows MessageBox (topmost/system-modal) on spawn_blocking; anything
  but an explicit Yes = deny; non-Windows build is a fail-closed stub.

Not cargo-check-verified locally (no toolchain). Server verified on the build
host; the Windows agent half is verified by CI build-agent (Pluto).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:44:09 -07:00
8cb0b5b16b style(server): cargo fmt for trusted-proxy IP extractor (CI green)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m53s
Build and Test / Build Server (Linux) (push) Successful in 10m54s
Build and Test / Security Audit (push) Successful in 4m21s
Build and Test / Build Summary (push) Successful in 11s
5d5cd26 compiles + passes clippy -D warnings + all 45 tests on the build host;
only cargo fmt --check failed on one reflowed method chain in ip_extract.rs.
No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:26:15 -07:00
5d5cd26572 fix(server): trusted-proxy client-IP extraction for rate-limit/audit keying
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 5m9s
Build and Test / Build Agent (Windows) (push) Successful in 7m38s
Build and Test / Security Audit (push) Successful in 4m59s
Build and Test / Build Summary (push) Has been skipped
Resolves coord todo 3c1f372a (Task-4 review SHOULD-FIX). Behind NPM-on-loopback,
ConnectInfo was 127.0.0.1 so the rate limiter + lockout bucketed every client
under one IP. New shared utils::ip_extract::client_ip() honors X-Real-IP /
X-Forwarded-For (rightmost-untrusted hop) ONLY when the TCP peer is a configured
trusted proxy (CONNECT_TRUSTED_PROXIES env, default loopback, fail-closed);
untrusted peers are keyed by their true peer IP (forged headers ignored). Wired
into the 3 rate-limit middleware, the validate_code lockout feed, and the agent/
viewer WS handlers so the limiter, lockout, and audit ip_address all key on the
real client consistently. 13 unit tests (spoof rejection, XFF walk, fail-safe
defaults). Code-reviewed APPROVED. Not cargo-check-verified locally (no toolchain);
build-host/CI verification follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:15:45 -07:00
21189423f2 fix(server): clippy fixes for Task 4 (CI green)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m3s
Build and Test / Build Server (Linux) (push) Successful in 10m19s
Build and Test / Security Audit (push) Successful in 4m10s
Build and Test / Build Summary (push) Successful in 9s
Task 4 (bfcdbb5) compiles and passes all 32 tests on the build host; only
clippy -D warnings blocked CI. Fixed the two denials:
- rate_limit.rs: converted a dangling /// doc block (no documented item) to //
  to clear clippy::empty_line_after_doc_comments
- db/events.rs: #[allow(dead_code)] on CONNECTION_REJECTED_EXPIRED_CODE and
  _CANCELLED_CODE (not-yet-wired audit-event constants), matching the file's
  existing STREAMING_STOPPED pattern; TODO comments note the rejection-event wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:17:23 -07:00
bfcdbb5379 feat(server): v2 secure-session-core Task 4 - rate limit + single-use codes
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 6m12s
Build and Test / Build Agent (Windows) (push) Successful in 6m43s
Build and Test / Security Audit (push) Successful in 4m23s
Build and Test / Build Summary (push) Has been skipped
SPEC-002 Phase 1 Task 4 (the final keystone task), code-reviewed APPROVED.
Closes the audit's reusable-code HIGH and rate-limiting-disabled HIGH.

- Rebuilt rate limiting as a self-contained in-memory per-IP limiter (replaces
  the non-compiling tower_governor; removed that dep). Fixed-window caps wired
  to login (8/min), change-password (5/min), code-validate (15/min) -> 429;
  per-IP lockout after 10 consecutive failed code validations (15-min cooldown).
- Single-use support codes: atomic consume on first agent bind (in-memory
  Pending->Connected under write lock + DB conditional UPDATE), rejecting a
  second presenter; validate/preview does not consume.
- Widened code format: XXX-XXX-XXX, 31-char unambiguous alphabet (no 0/O/1/I/L),
  CSPRNG + rejection sampling, ~44.6 bits (replaces 6-digit numeric); migration
  006 widens the code columns to TEXT.

Completes the keystone (Tasks 1-4): every audit CRITICAL + HIGH in the secure
auth/session core is now addressed. Known follow-up todos (not blocking): (1)
trusted-proxy client-IP extraction (NPM-on-loopback collapses clients to
127.0.0.1); (2) multi-instance fail-closed DB single-use gate. Not
cargo-check-verified locally - build-host/CI verification follows this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:04:54 -07:00
8a0193577b style(server): cargo fmt + clippy fixes for v2 keystone (CI green)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m29s
Build and Test / Build Server (Linux) (push) Successful in 10m23s
Build and Test / Security Audit (push) Successful in 4m17s
Build and Test / Build Summary (push) Successful in 11s
The Task 2/3/authz commits failed CI at the first gate (cargo fmt --all
--check), which short-circuited before clippy/build/test ran. Verified on the
build host (172.16.3.30): the v2 server compiles and all 18 tests pass; only
3 cosmetic issues blocked CI, all fixed here:
- cargo fmt --all (whitespace, 3 files)
- clippy unused_imports: drop ViewerClaims from auth/mod.rs re-export
- clippy doc_overindented_list_items: de-indent one doc line in sessions.rs
Testing Agent confirmed fmt + clippy -D warnings + build --release + test are
all green with these applied. No logic changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:19:26 -07:00
a453e7984e feat(server): viewer-token view-only/control split - closes CRITICAL #1
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m20s
Build and Test / Build Agent (Windows) (push) Successful in 6m9s
Build and Test / Security Audit (push) Successful in 4m21s
Build and Test / Build Summary (push) Has been skipped
Authz-strength fix (coord todo c8916c89), code-reviewed APPROVED. Replaces the
weak "view" gate (held by every role) with a permission-tiered access mode
stamped inside the signed viewer token:
- mint: is_admin() || has_permission("control") -> CONTROL token; else
  has_permission("view") -> VIEW_ONLY token; else 403.
- enforce: the relay drops MouseEvent/KeyEvent/SpecialKey for a VIEW_ONLY token
  before forwarding (video still streams); CONTROL tokens forward under the
  Task-3 throttle. Mode is unforgeable (in the signature) and unbypassable
  (all other viewer->agent payloads hit the catch-all and are never forwarded).
A low-privilege viewer-role user can now at most watch, never control. New
ViewerAccess enum (view_only|control) on ViewerClaims; 3 unit tests.

Audit CRITICAL #1 now fully closed (mechanism in Task 3 + this authz strength).
Not cargo-check-verified locally (no toolchain) - the push triggers CI
(clippy -D warnings + build + test) which is the verification gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:24:32 -07:00
0f258788f9 feat(server): v2 secure-session-core Task 3 - secure relay WS
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m3s
Build and Test / Build Agent (Windows) (push) Successful in 7m48s
Build and Test / Security Audit (push) Successful in 4m20s
Build and Test / Build Summary (push) Has been skipped
SPEC-002 Phase 1 Task 3 (specs/v2-secure-session-core), code-reviewed APPROVED.

- viewer_ws_handler: verify the session-scoped VIEWER token (validate_viewer_token
  sig+exp+purpose) + token_blacklist.is_revoked + session_id claim == requested
  session, before upgrade. Raw login JWTs no longer accepted on the viewer plane
  (closes audit CRITICAL #2; closes the *mechanism* of CRITICAL #1).
- mint_viewer_token: authz gate is_admin() || has_permission("view") -> 403.
- Agent identity binding: validate_agent_api_key returns AgentKeyAuth; a cak_-
  verified agent rebinds to the key's machine identity (fails closed if
  unresolvable), so a key for machine X cannot seize machine Y's session slot.
- Frame caps on both WS upgrades (agent 4 MiB, viewer 64 KiB) - closes WS-OOM HIGH.
- Viewer->agent input throttle (200 ev/s token bucket, bounded try_send) - closes
  input-injection MEDIUM.
- Startup managed-session reconcile clarified.

KNOWN FOLLOW-UPS (tracked todos): (1) authz STRENGTH - the "view" permission is
held by every default role incl. viewer, and a viewer token grants input control,
so the gate should be "control" or a VIEW_ONLY/CONTROL token split; CRITICAL #1 is
mechanism-closed, strength pending decision. (2) revoke minted viewer tokens on
logout (currently bounded only by 5-min TTL). Not cargo-check-verified (no toolchain
on the authoring host).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:13:03 -07:00
41691bfb2c feat(server): v2 secure-session-core Task 2 - auth rebuild
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m37s
Build and Test / Build Agent (Windows) (push) Successful in 6m37s
Build and Test / Security Audit (push) Successful in 4m10s
Build and Test / Build Summary (push) Has been skipped
SPEC-002 Phase 1 Task 2 (specs/v2-secure-session-core), code-reviewed APPROVED.

- DELETE the JWT-as-agent-key branch in relay validate_agent_api_key (audit
  CRITICAL): agent auth now = per-agent cak_ key (SHA-256 -> connect_agent_keys,
  revoked filtered) OR support code OR deprecated shared AGENT_API_KEY (warned).
  A user JWT can no longer authenticate an agent.
- auth/agent_keys.rs: cak_ gen (OsRng 256-bit) + SHA-256 hash + verify.
- auth/jwt.rs: ViewerClaims + create/validate_viewer_token (5-min TTL,
  purpose=viewer, session_id+tenant_id claims; non-interchangeable with login).
- Admin key issuance: POST/GET/DELETE /api/machines/:agent_id/keys.
- POST /api/sessions/:id/viewer-token mints a session-bound short-lived token.
- Migration 005: organization/site/tags on connect_machines (fixes the silent
  update_machine_metadata write, coord todo faf39fe0).

NOTE: viewer-token minting is gated by AuthenticatedUser only; the AUTHORIZATION
check (admin/permission gate) that closes audit CRITICAL #1 lands in Task 3 (the
viewer WS verification). The viewer WS path (relay/mod.rs:285) is untouched here.
Not cargo-check-verified (no toolchain on the authoring host) - self-reviewed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:57:12 -07:00
fef8111ff3 feat(server): v2 secure-session-core Task 1 - schema + per-agent keys
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m7s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m24s
Build and Test / Build Summary (push) Successful in 12s
SPEC-002 Phase 1 Task 1 (specs/v2-secure-session-core), code-reviewed APPROVED.

Migration 004 (idempotent, server-applied): tenants + seeded default tenant,
connect_agent_keys (hash-only, revocable, FK->connect_machines), nullable
tenant_id on all scoped tables (tenancy-ready, not tenant-yet), connect_sessions
is_managed/source/consent_state, connect_support_codes consumed_at. New db
modules agent_keys.rs (stores only key_hash) + tenancy.rs (DEFAULT_TENANT_ID,
Phase-4 switch point). Struct/query updates across machines/sessions/
support_codes/events/users. Runtime sqlx throughout (GC db layer already uses
it - no compile-time macros).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:33:26 -07:00
81e4b99a34 spec: add v2-secure-session-core shape spec
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 7m2s
Build and Test / Build Server (Linux) (push) Successful in 10m41s
Build and Test / Security Audit (push) Successful in 4m17s
Build and Test / Build Summary (push) Successful in 8s
Phase 1 of SPEC-002 (GuruConnect v2). Keystone-first plan: Tasks 1-4
rebuild the auth/session core that closes the 3 audit CRITICALs by design
(per-agent cak_ keys, plane separation, session-scoped viewer tokens,
blacklist+frame-caps+throttle on the relay WS, single-use rate-limited
support codes, tenancy-ready schema); Tasks 5-7 deliver attended consent,
native full key fidelity (WH_KEYBOARD_LL hook, scan-code injection, SAS
Ctrl+Alt+Del), and HW H.264 with raw+Zstd fallback. plan/shape/references/
standards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:15:37 -07:00
5c60a105c0 docs(spec): add SPEC-002 GuruConnect v2 modernization architecture
Some checks failed
Build and Test / Build Agent (Windows) (push) Successful in 6m34s
Build and Test / Build Server (Linux) (push) Has started running
Build and Test / Security Audit (push) Has been cancelled
Build and Test / Build Summary (push) Has been cancelled
Ground-up v2 re-architecture decided 2026-05-29 (Mike), grounded in the
2026-05-29 audit + adopted GuruRMM design principles. Greenfield salvaging
proven Rust cores (DXGI/GDI capture, input injection, SAS helper, prost codec,
CI). Native-first full key fidelity (Win+R/Ctrl+Alt+Del) + bidirectional file
transfer (clipboard cut/paste + drag-and-drop) as headline differentiators;
WebRTC fallback only. Hardened single-tenant, tenancy-ready schema. Standalone-
first + /api/integration/v1 RMM contract. Closes all audit CRITICALs by design.
Open decisions resolved: in-place repo reset, H.264 default, WSS-first web
transport, widened support codes, clean v1 cutover (no client migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:08:23 -07:00
486debfc52 docs(audit): add inaugural gc-audit report 2026-05-29
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m14s
Build and Test / Build Server (Linux) (push) Successful in 10m29s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s
First /gc-audit run (also a dry run validating the skill). 7 passes.
4 CRITICAL (3 relay-plane auth failures: any-JWT session hijack,
viewer-WS blacklist bypass, JWT-accepted-as-agent-key; 1 functional:
dashboard protobuf.ts wire-incompatible). Plus deploy.yml stub leaving
prod 57 commits stale. Proposed roadmap/tech-debt deltas listed (not
yet applied, pending review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:46:26 -07:00
ccc6ba9c02 ci: enforce clippy -D warnings and cargo audit as hard gates
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 12m18s
Build and Test / Build Server (Linux) (push) Successful in 14m11s
Build and Test / Security Audit (push) Successful in 5m32s
Build and Test / Build Summary (push) Successful in 9s
Flip both CI gates from informational to hard-fail (SPEC-001 quality gates):
- clippy: `-- -D warnings` on the server crate. Cleared the debt via clippy --fix
  (unused imports/style), targeted #[allow(dead_code)] on native-remote-control
  future API, and #[allow(clippy::too_many_arguments)] on 3 protocol-mirroring fns.
- cargo audit: hard-fail with documented per-ID --ignore flags (rsa RUSTSEC-2023-0071
  unfixable/unreachable in active tree; gtk-rs + glib Linux-only tray backend not
  compiled into the Windows agent; proc-macro-error build-time). New advisories fail.
- Move [profile.release] to the workspace root (it was silently ignored in the server
  member), activating lto/codegen-units/strip.

No behavioral changes. Reviewed and gates verified passing on the build host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:18:50 +00:00
196 changed files with 29262 additions and 5297 deletions

View File

@@ -57,11 +57,10 @@ jobs:
- name: Check formatting - name: Check formatting
run: cd server && cargo fmt --all -- --check run: cd server && cargo fmt --all -- --check
# Informational (warn-only) for now. The pre-spec codebase has ~65 lint warnings, # Hard gate: clippy must pass with zero warnings (-D warnings). Dead-code that is
# mostly dead-code for API the integration spec (native-remote-control) will wire. # future API surface for native-remote-control carries targeted #[allow(dead_code)].
# Re-tighten to `-- -D warnings` during the GC re-spec once that API is in use. - name: Run Clippy
- name: Run Clippy (informational) run: cd server && cargo clippy --all-targets --all-features -- -D warnings
run: cd server && cargo clippy --all-targets --all-features
- name: Build server - name: Build server
run: | run: |
@@ -143,12 +142,18 @@ jobs:
- name: Install cargo-audit - name: Install cargo-audit
run: cargo install cargo-audit run: cargo install cargo-audit
# Informational (warn-only) for now, like clippy. GuruConnect is a single Cargo workspace, # Hard gate: cargo audit must pass. GuruConnect is a single Cargo workspace, so one
# so one `cargo audit` at the root covers all members (agent + server) via the shared # `cargo audit` at the root covers all members (agent + server) via the shared Cargo.lock.
# Cargo.lock. The pre-spec dependency tree has known advisories; re-tighten to a hard gate # The advisories below are explicitly ignored with documented justifications; any NEW
# during the GC re-spec after a dependency refresh. # advisory fails the build.
- name: Run security audit (informational) # RUSTSEC-2023-0071 (rsa) ............. no fixed upgrade; optional/unreachable in active tree
run: cargo audit || echo "[WARNING] cargo audit reported advisories (informational; address in GC re-spec)" # RUSTSEC-2024-0413/-0416/-0412/-0418/
# -0415/-0420/-0419 (gtk-rs GTK3) ..... Linux-only tray-icon backend, not compiled into shipping Windows agent
# RUSTSEC-2024-0429 (glib) ............ Linux-only tray-icon backend, not compiled into shipping Windows agent
# RUSTSEC-2024-0370 (proc-macro-error) build-time proc-macro dependency, no runtime impact
- name: Run security audit
run: |
cargo audit --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2024-0413 --ignore RUSTSEC-2024-0416 --ignore RUSTSEC-2024-0412 --ignore RUSTSEC-2024-0418 --ignore RUSTSEC-2024-0415 --ignore RUSTSEC-2024-0420 --ignore RUSTSEC-2024-0419 --ignore RUSTSEC-2024-0429 --ignore RUSTSEC-2024-0370
build-summary: build-summary:
name: Build Summary name: Build Summary

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ vendor/
# Generated files # Generated files
*.generated.* *.generated.*
# Built SPA (Vite build output served by the server; rebuilt from dashboard/)
/server/static/app/

View File

@@ -6,20 +6,66 @@ All notable changes to GuruConnect are documented here. Format follows
Per-version entries below are generated from conventional commits (`feat:`, `fix:`, `perf:`) Per-version entries below are generated from conventional commits (`feat:`, `fix:`, `perf:`)
by the release workflow; per-component changelogs are also written to by the release workflow; per-component changelogs are also written to
`changelogs/<component>/v<version>.md` and served at `/api/changelog/...`. `changelogs/<component>/v<version>.md` and served at `/api/changelog/...`.
## [0.3.0] - 2026-06-01
### Added
- Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fixed
- Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security
- Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)
## [0.2.2] - 2026-05-29 ## [0.2.2] - 2026-05-29
### Fixed
- Drop broken jsign --info verify step in release (5727ccf3)
## [0.2.1] - 2026-05-29
### Fixed
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
## [0.2.0] - 2026-05-29
### Added ### Added
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2) - Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2)
@@ -28,6 +74,11 @@ by the release workflow; per-component changelogs are also written to
- Use Self:: for static method calls (cc35d111) - Use Self:: for static method calls (cc35d111)
### Fixed
- Drop broken jsign --info verify step in release (5727ccf3)
- Use jsign 7.1 for Azure Trusted Signing (e7f38ce2)
### Security ### Security
- Require authentication for all WebSocket and API endpoints (4614df04) - Require authentication for all WebSocket and API endpoints (4614df04)

150
Cargo.lock generated
View File

@@ -735,19 +735,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
@@ -1075,31 +1062,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "forwarded-header-value"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [
"nonempty",
"thiserror 1.0.69",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@@ -1167,19 +1129,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro", "futures-macro",
@@ -1398,26 +1353,6 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "governor"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
dependencies = [
"cfg-if",
"dashmap",
"futures",
"futures-timer",
"no-std-compat",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.8.6",
"smallvec",
"spinning_top",
]
[[package]] [[package]]
name = "gtk" name = "gtk"
version = "0.18.2" version = "0.18.2"
@@ -1472,13 +1407,14 @@ dependencies = [
[[package]] [[package]]
name = "guruconnect" name = "guruconnect"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"chrono", "chrono",
"clap", "clap",
"futures-util", "futures-util",
"hex",
"hostname", "hostname",
"image", "image",
"muda", "muda",
@@ -1511,7 +1447,7 @@ dependencies = [
[[package]] [[package]]
name = "guruconnect-server" name = "guruconnect-server"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -1534,18 +1470,11 @@ dependencies = [
"toml 0.8.2", "toml 0.8.2",
"tower", "tower",
"tower-http", "tower-http",
"tower_governor",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -2362,24 +2291,6 @@ dependencies = [
"jni-sys 0.3.1", "jni-sys 0.3.1",
] ]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "nonempty"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -3035,12 +2946,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -3218,21 +3123,6 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.39.4"
@@ -3377,15 +3267,6 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.1",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3960,15 +3841,6 @@ dependencies = [
"lock_api", "lock_api",
] ]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "spki" name = "spki"
version = "0.7.3" version = "0.7.3"
@@ -4653,22 +4525,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower_governor"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aea939ea6cfa7c4880f3e7422616624f97a567c16df67b53b11f0d03917a8e46"
dependencies = [
"axum",
"forwarded-header-value",
"governor",
"http",
"pin-project",
"thiserror 1.0.69",
"tower",
"tracing",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"

View File

@@ -6,7 +6,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.2.2" version = "0.3.0"
edition = "2021" edition = "2021"
authors = ["AZ Computer Guru"] authors = ["AZ Computer Guru"]
license = "Proprietary" license = "Proprietary"
@@ -25,3 +25,8 @@ anyhow = "1"
thiserror = "1" thiserror = "1"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
[profile.release]
lto = true
codegen-units = 1
strip = true

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "guruconnect" name = "guruconnect"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
authors = ["AZ Computer Guru"] authors = ["AZ Computer Guru"]
description = "GuruConnect Remote Desktop - Agent and Viewer" description = "GuruConnect Remote Desktop - Agent and Viewer"
@@ -47,6 +47,7 @@ toml = "0.8"
# Crypto # Crypto
ring = "0.17" ring = "0.17"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4"
# HTTP client for updates # HTTP client for updates
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
@@ -95,6 +96,13 @@ windows = { version = "0.58", features = [
"Win32_System_Pipes", "Win32_System_Pipes",
"Win32_System_SystemServices", "Win32_System_SystemServices",
"Win32_System_IO", "Win32_System_IO",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Ole",
"Win32_System_Variant",
"Win32_Media_MediaFoundation",
"Win32_Media_KernelStreaming",
"Win32_Media_DirectShow",
]} ]}
# Windows service support # Windows service support

View File

@@ -4,7 +4,6 @@
//! The agent communicates with this service via named pipe IPC. //! The agent communicates with this service via named pipe IPC.
use std::ffi::OsString; use std::ffi::OsString;
use std::io::{Read, Write as IoWrite};
use std::sync::mpsc; use std::sync::mpsc;
use std::time::Duration; use std::time::Duration;
@@ -37,7 +36,19 @@ const PIPE_READMODE_MESSAGE: u32 = 0x00000002;
const PIPE_WAIT: u32 = 0x00000000; const PIPE_WAIT: u32 = 0x00000000;
const PIPE_UNLIMITED_INSTANCES: u32 = 255; const PIPE_UNLIMITED_INSTANCES: u32 = 255;
const INVALID_HANDLE_VALUE: isize = -1; const INVALID_HANDLE_VALUE: isize = -1;
const SECURITY_DESCRIPTOR_REVISION: u32 = 1; /// SDDL revision passed to `ConvertStringSecurityDescriptorToSecurityDescriptorW`
/// (`SDDL_REVISION_1`).
const SDDL_REVISION_1: u32 = 1;
/// Restrictive DACL for the SAS named pipe, in SDDL form.
///
/// `D:` introduces the DACL; `(A;;GA;;;AU)` is an ACE granting GENERIC_ALL (`GA`) to
/// Authenticated Users (`AU`). Anonymous / null-session callers are NOT authenticated and
/// are therefore denied — closing the original NULL-DACL hole where any local process
/// (Everyone) could connect and make this SYSTEM service raise the secure-attention
/// screen. The agent runs in the interactive logon session and IS an authenticated user,
/// so it can still connect and request a SAS.
const PIPE_SDDL: &str = "D:(A;;GA;;;AU)";
// FFI declarations for named pipe operations // FFI declarations for named pipe operations
#[link(name = "kernel32")] #[link(name = "kernel32")]
@@ -71,19 +82,23 @@ extern "system" {
lpOverlapped: *mut std::ffi::c_void, lpOverlapped: *mut std::ffi::c_void,
) -> i32; ) -> i32;
fn FlushFileBuffers(hFile: isize) -> i32; fn FlushFileBuffers(hFile: isize) -> i32;
fn LocalFree(hMem: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
} }
#[link(name = "advapi32")] #[link(name = "advapi32")]
extern "system" { extern "system" {
fn InitializeSecurityDescriptor(pSecurityDescriptor: *mut u8, dwRevision: u32) -> i32; /// Build a self-relative security descriptor from an SDDL string. The descriptor is
fn SetSecurityDescriptorDacl( /// allocated with `LocalAlloc` and must be released with `LocalFree`.
pSecurityDescriptor: *mut u8, fn ConvertStringSecurityDescriptorToSecurityDescriptorW(
bDaclPresent: i32, StringSecurityDescriptor: *const u16,
pDacl: *mut std::ffi::c_void, StringSDRevision: u32,
bDaclDefaulted: i32, SecurityDescriptor: *mut *mut std::ffi::c_void,
SecurityDescriptorSize: *mut u32,
) -> i32; ) -> i32;
} }
// Field names mirror the Win32 SECURITY_ATTRIBUTES ABI struct.
#[allow(non_snake_case)]
#[repr(C)] #[repr(C)]
struct SECURITY_ATTRIBUTES { struct SECURITY_ATTRIBUTES {
nLength: u32, nLength: u32,
@@ -280,26 +295,31 @@ fn run_pipe_server() -> Result<()> {
tracing::info!("Starting pipe server on {}", PIPE_NAME); tracing::info!("Starting pipe server on {}", PIPE_NAME);
loop { loop {
// Create security descriptor that allows everyone // Build a restrictive security descriptor from SDDL: grant access only to
let mut sd = [0u8; 256]; // Authenticated Users (excludes anonymous / null-session callers). See PIPE_SDDL.
unsafe { let sddl: Vec<u16> = PIPE_SDDL.encode_utf16().chain(std::iter::once(0)).collect();
if InitializeSecurityDescriptor(sd.as_mut_ptr(), SECURITY_DESCRIPTOR_REVISION) == 0 { let mut sd_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
tracing::error!("Failed to initialize security descriptor"); let converted = unsafe {
std::thread::sleep(Duration::from_secs(1)); ConvertStringSecurityDescriptorToSecurityDescriptorW(
continue; sddl.as_ptr(),
} SDDL_REVISION_1,
&mut sd_ptr,
// Set NULL DACL = allow everyone std::ptr::null_mut(),
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)); if converted == 0 || sd_ptr.is_null() {
continue; 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 { let mut sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32, nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.as_mut_ptr(), lpSecurityDescriptor: sd_ptr as *mut u8,
bInheritHandle: 0, bInheritHandle: 0,
}; };
@@ -320,6 +340,12 @@ fn run_pipe_server() -> Result<()> {
) )
}; };
// CreateNamedPipeW copies the descriptor into the kernel object, so the SDDL-built
// copy can be freed now regardless of success.
unsafe {
LocalFree(sd_ptr);
}
if pipe == INVALID_HANDLE_VALUE { if pipe == INVALID_HANDLE_VALUE {
tracing::error!("Failed to create named pipe"); tracing::error!("Failed to create named pipe");
std::thread::sleep(Duration::from_secs(1)); std::thread::sleep(Duration::from_secs(1));
@@ -403,6 +429,69 @@ fn run_pipe_server() -> Result<()> {
} }
} }
/// Enable the `SoftwareSASGeneration` Winlogon policy so `SendSAS` is permitted.
///
/// Without this policy, `sas.dll!SendSAS` is a silent no-op even when called from
/// SYSTEM. The value lives at
/// `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\SoftwareSASGeneration`
/// and is a DWORD bitmask:
/// 0 = none, 1 = services, 2 = ease-of-access apps, 3 = both.
///
/// We set `1` (services) because the GuruConnect SAS helper runs as a SYSTEM service.
/// This is invoked from the SAS service installer; the broader agent installer should
/// ensure this runs (see `// TODO(installer)` below).
fn set_software_sas_policy() -> Result<()> {
use windows::core::PCWSTR;
use windows::Win32::System::Registry::{
RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_LOCAL_MACHINE, KEY_SET_VALUE,
REG_DWORD, REG_OPTION_NON_VOLATILE,
};
let subkey: Vec<u16> = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = "SoftwareSASGeneration"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// DWORD 1 = allow services to generate a software SAS.
let data: u32 = 1;
unsafe {
let mut hkey = HKEY::default();
let status = RegCreateKeyExW(
HKEY_LOCAL_MACHINE,
PCWSTR(subkey.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE,
None,
&mut hkey,
None,
);
if status.is_err() {
anyhow::bail!("RegCreateKeyExW(Policies\\System) failed: {:?}", status);
}
let set = RegSetValueExW(
hkey,
PCWSTR(value_name.as_ptr()),
0,
REG_DWORD,
Some(&data.to_ne_bytes()),
);
let _ = RegCloseKey(hkey);
if set.is_err() {
anyhow::bail!("RegSetValueExW(SoftwareSASGeneration) failed: {:?}", set);
}
}
Ok(())
}
/// Call SendSAS via sas.dll /// Call SendSAS via sas.dll
fn send_sas() -> Result<()> { fn send_sas() -> Result<()> {
unsafe { unsafe {
@@ -505,6 +594,19 @@ fn install_service() -> Result<()> {
]) ])
.output(); .output();
// Enable the SoftwareSASGeneration policy so SendSAS actually works from the
// SYSTEM service. TODO(installer): the top-level managed agent installer should
// also ensure this policy is set (and that this SAS service is installed) as part
// of unattended deployment, rather than relying on a manual SAS-service install.
match set_software_sas_policy() {
Ok(()) => println!("Enabled SoftwareSASGeneration policy (services)"),
Err(e) => println!(
"Warning: failed to set SoftwareSASGeneration policy: {}. \
Ctrl+Alt+Del may not reach the secure desktop until this is set.",
e
),
}
println!("\n** GuruConnect SAS Service installed successfully!"); println!("\n** GuruConnect SAS Service installed successfully!");
println!("\nBinary: {:?}", binary_dest); println!("\nBinary: {:?}", binary_dest);
println!("\nStarting service..."); println!("\nStarting service...");

View File

@@ -32,6 +32,8 @@ pub struct Display {
} }
/// Display info for protocol messages /// Display info for protocol messages
// Future use: multi-display protocol negotiation.
#[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DisplayInfo { pub struct DisplayInfo {
pub displays: Vec<Display>, pub displays: Vec<Display>,
@@ -40,11 +42,13 @@ pub struct DisplayInfo {
impl Display { impl Display {
/// Total pixels in the display /// Total pixels in the display
#[allow(dead_code)]
pub fn pixel_count(&self) -> u32 { pub fn pixel_count(&self) -> u32 {
self.width * self.height self.width * self.height
} }
/// Bytes needed for BGRA frame buffer /// Bytes needed for BGRA frame buffer
#[allow(dead_code)]
pub fn buffer_size(&self) -> usize { pub fn buffer_size(&self) -> usize {
(self.width * self.height * 4) as usize (self.width * self.height * 4) as usize
} }
@@ -60,7 +64,6 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
}; };
let mut displays = Vec::new(); let mut displays = Vec::new();
let mut display_id = 0u32;
// Callback for EnumDisplayMonitors // Callback for EnumDisplayMonitors
unsafe extern "system" fn enum_callback( unsafe extern "system" fn enum_callback(
@@ -148,6 +151,8 @@ pub fn enumerate_displays() -> Result<Vec<Display>> {
} }
/// Get display info for protocol /// Get display info for protocol
// Future use: multi-display protocol negotiation.
#[allow(dead_code)]
pub fn get_display_info() -> Result<DisplayInfo> { pub fn get_display_info() -> Result<DisplayInfo> {
let displays = enumerate_displays()?; let displays = enumerate_displays()?;
let primary_id = displays let primary_id = displays

View File

@@ -32,6 +32,8 @@ pub struct DxgiCapturer {
staging_texture: Option<ID3D11Texture2D>, staging_texture: Option<ID3D11Texture2D>,
width: u32, width: u32,
height: u32, height: u32,
// Future use: frame diffing against the previously captured frame.
#[allow(dead_code)]
last_frame: Option<Vec<u8>>, last_frame: Option<Vec<u8>>,
} }

View File

@@ -48,7 +48,7 @@ impl GdiCapturer {
let bitmap = CreateCompatibleBitmap(screen_dc, self.width as i32, self.height as i32); let bitmap = CreateCompatibleBitmap(screen_dc, self.width as i32, self.height as i32);
if bitmap.is_invalid() { if bitmap.is_invalid() {
DeleteDC(mem_dc); let _ = DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc); ReleaseDC(HWND::default(), screen_dc);
anyhow::bail!("Failed to create compatible bitmap"); anyhow::bail!("Failed to create compatible bitmap");
} }
@@ -69,8 +69,8 @@ impl GdiCapturer {
SRCCOPY, SRCCOPY,
) { ) {
SelectObject(mem_dc, old_bitmap); SelectObject(mem_dc, old_bitmap);
DeleteObject(bitmap); let _ = DeleteObject(bitmap);
DeleteDC(mem_dc); let _ = DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc); ReleaseDC(HWND::default(), screen_dc);
anyhow::bail!("BitBlt failed: {}", e); anyhow::bail!("BitBlt failed: {}", e);
} }
@@ -110,8 +110,8 @@ impl GdiCapturer {
// Cleanup // Cleanup
SelectObject(mem_dc, old_bitmap); SelectObject(mem_dc, old_bitmap);
DeleteObject(bitmap); let _ = DeleteObject(bitmap);
DeleteDC(mem_dc); let _ = DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc); ReleaseDC(HWND::default(), screen_dc);
if lines == 0 { if lines == 0 {

View File

@@ -9,7 +9,7 @@ mod dxgi;
#[cfg(windows)] #[cfg(windows)]
mod gdi; mod gdi;
pub use display::{Display, DisplayInfo}; pub use display::Display;
use anyhow::Result; use anyhow::Result;
use std::time::Instant; use std::time::Instant;
@@ -33,6 +33,8 @@ pub struct CapturedFrame {
pub display_id: u32, pub display_id: u32,
/// Regions that changed since last frame (if available) /// Regions that changed since last frame (if available)
// Populated by capturers; not yet consumed by the encoder pipeline.
#[allow(dead_code)]
pub dirty_rects: Option<Vec<DirtyRect>>, pub dirty_rects: Option<Vec<DirtyRect>>,
} }
@@ -53,9 +55,11 @@ pub trait Capturer: Send {
fn capture(&mut self) -> Result<Option<CapturedFrame>>; fn capture(&mut self) -> Result<Option<CapturedFrame>>;
/// Get the current display info /// Get the current display info
#[allow(dead_code)]
fn display(&self) -> &Display; fn display(&self) -> &Display;
/// Check if capturer is still valid (display may have changed) /// Check if capturer is still valid (display may have changed)
#[allow(dead_code)]
fn is_valid(&self) -> bool; fn is_valid(&self) -> bool;
} }

View File

@@ -6,17 +6,13 @@
use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use tracing::{error, info, warn}; use tracing::info;
#[cfg(not(windows))]
use tracing::warn;
#[cfg(windows)] #[cfg(windows)]
use windows::core::PCWSTR; use windows::core::PCWSTR;
#[cfg(windows)] #[cfg(windows)]
use windows::Win32::Foundation::*;
#[cfg(windows)]
use windows::Win32::Graphics::Gdi::*;
#[cfg(windows)]
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::*; use windows::Win32::UI::WindowsAndMessaging::*;
/// A chat message /// A chat message
@@ -29,11 +25,15 @@ pub struct ChatMessage {
} }
/// Commands that can be sent to the chat window /// Commands that can be sent to the chat window
// Show/Hide/Close are part of the chat control API but not yet driven by the session loop.
#[derive(Debug)] #[derive(Debug)]
pub enum ChatCommand { pub enum ChatCommand {
#[allow(dead_code)]
Show, Show,
#[allow(dead_code)]
Hide, Hide,
AddMessage(ChatMessage), AddMessage(ChatMessage),
#[allow(dead_code)]
Close, Close,
} }
@@ -69,11 +69,13 @@ impl ChatController {
} }
/// Show the chat window /// Show the chat window
#[allow(dead_code)]
pub fn show(&self) { pub fn show(&self) {
let _ = self.command_tx.send(ChatCommand::Show); let _ = self.command_tx.send(ChatCommand::Show);
} }
/// Hide the chat window /// Hide the chat window
#[allow(dead_code)]
pub fn hide(&self) { pub fn hide(&self) {
let _ = self.command_tx.send(ChatCommand::Hide); let _ = self.command_tx.send(ChatCommand::Hide);
} }
@@ -93,16 +95,14 @@ impl ChatController {
} }
/// Close the chat window /// Close the chat window
#[allow(dead_code)]
pub fn close(&self) { pub fn close(&self) {
let _ = self.command_tx.send(ChatCommand::Close); let _ = self.command_tx.send(ChatCommand::Close);
} }
} }
#[cfg(windows)] #[cfg(windows)]
fn run_chat_window(command_rx: Receiver<ChatCommand>, message_tx: Sender<ChatMessage>) { fn run_chat_window(command_rx: Receiver<ChatCommand>, _message_tx: Sender<ChatMessage>) {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
info!("Starting chat window thread"); info!("Starting chat window thread");
// For now, we'll use a simple message box approach // For now, we'll use a simple message box approach

View File

@@ -9,7 +9,7 @@ use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf; use std::path::PathBuf;
use tracing::{info, warn}; use tracing::info;
use uuid::Uuid; use uuid::Uuid;
/// Magic marker for embedded configuration (10 bytes) /// Magic marker for embedded configuration (10 bytes)
@@ -196,7 +196,7 @@ impl Config {
/// Extract 6-digit support code from filename /// Extract 6-digit support code from filename
fn extract_support_code(filename: &str) -> Option<String> { fn extract_support_code(filename: &str) -> Option<String> {
// Look for patterns like "GuruConnect-123456" or "GuruConnect_123456" // Look for patterns like "GuruConnect-123456" or "GuruConnect_123456"
for part in filename.split(|c| c == '-' || c == '_' || c == '.') { for part in filename.split(['-', '_', '.']) {
let trimmed = part.trim(); let trimmed = part.trim();
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
return Some(trimmed.to_string()); return Some(trimmed.to_string());
@@ -435,6 +435,8 @@ impl Config {
} }
/// Example configuration file content /// Example configuration file content
// Retained for documentation / config-template generation.
#[allow(dead_code)]
pub fn example_config() -> &'static str { pub fn example_config() -> &'static str {
r#"# GuruConnect Agent Configuration r#"# GuruConnect Agent Configuration

157
agent/src/consent/mod.rs Normal file
View 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
);
}
}

View 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
View 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
View 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)
}

View File

@@ -1,16 +1,27 @@
//! Frame encoding module //! Frame encoding module
//! //!
//! Encodes captured frames for transmission. Supports: //! Encodes captured frames for transmission. Supports:
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode) //! - Raw BGRA + Zstd compression (lowest latency, LAN mode; the guaranteed
//! - VP9 software encoding (universal fallback) //! fallback and the current default).
//! - H264 hardware encoding (when GPU available) //! - H.264 hardware encoding via Windows Media Foundation (Task 7) — the
//! negotiated upgrade. Compile-verified; validated on real hardware in plan
//! Task 8. On any init/feed failure the factory or encoder falls back to raw.
//!
//! Codec selection is driven by the negotiated `VideoCodec` the server sends on
//! `StartStream` (see `select_codec` / `create_encoder_for`). The capability the
//! agent advertises to the server is detected by `capability::supports_hardware_h264`.
mod capability;
pub(crate) mod color;
#[cfg(windows)]
mod h264;
mod raw; mod raw;
pub use capability::supports_hardware_h264;
pub use raw::RawEncoder; pub use raw::RawEncoder;
use crate::capture::CapturedFrame; use crate::capture::CapturedFrame;
use crate::proto::{DirtyRect as ProtoDirtyRect, RawFrame, VideoFrame}; use crate::proto::{video_frame, VideoCodec, VideoFrame};
use anyhow::Result; use anyhow::Result;
/// Encoded frame ready for transmission /// Encoded frame ready for transmission
@@ -23,30 +34,191 @@ pub struct EncodedFrame {
pub size: usize, pub size: usize,
/// Whether this is a keyframe (full frame) /// Whether this is a keyframe (full frame)
// Set by encoders; not yet read by the transmit path.
#[allow(dead_code)]
pub is_keyframe: bool, pub is_keyframe: bool,
} }
/// Frame encoder trait /// Frame encoder trait.
///
/// Every implementor turns a `CapturedFrame` (BGRA) into a wire `VideoFrame`
/// using one `video_frame::Encoding` variant. `RawEncoder` emits the `Raw`
/// variant; the H.264 encoder emits the `H264` variant. The factory
/// (`create_encoder_for`) selects the implementor from the negotiated codec.
pub trait Encoder: Send { pub trait Encoder: Send {
/// Encode a captured frame /// Encode a captured frame
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame>; fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame>;
/// Request a keyframe on next encode /// Request a keyframe on next encode
#[allow(dead_code)]
fn request_keyframe(&mut self); fn request_keyframe(&mut self);
/// Get encoder name/type /// Get encoder name/type
#[allow(dead_code)]
fn name(&self) -> &str; fn name(&self) -> &str;
} }
/// Create an encoder based on configuration /// Map a configured/negotiated codec string to a `VideoCodec`.
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> { ///
/// Used when constructing an encoder from the agent's own `EncodingConfig`
/// (before any server negotiation). Unknown / "auto" / "raw" all resolve to raw
/// — the safe default. "h264" resolves to H.264 (which itself falls back to raw
/// if MF init fails).
///
/// Retained as the config-string entry point (used by `create_encoder` and the
/// unit tests); the live session negotiates via `select_codec` on a `VideoCodec`.
#[allow(dead_code)]
pub fn codec_from_str(codec: &str) -> VideoCodec {
match codec.to_lowercase().as_str() { match codec.to_lowercase().as_str() {
"raw" | "zstd" => Ok(Box::new(RawEncoder::new(quality)?)), "h264" => VideoCodec::H264,
// "vp9" => Ok(Box::new(Vp9Encoder::new(quality)?)), // "h265"/"hevc" are future opt-in (TODO) — treat as raw for now so we
// "h264" => Ok(Box::new(H264Encoder::new(quality)?)), // never select an unimplemented codec.
"auto" | _ => { _ => VideoCodec::Raw,
// Default to raw for now (best for LAN) }
Ok(Box::new(RawEncoder::new(quality)?)) }
}
/// 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");
} }
} }

View File

@@ -77,8 +77,8 @@ impl RawEncoder {
let mut dirty_rects = Vec::new(); let mut dirty_rects = Vec::new();
let stride = (width * 4) as usize; let stride = (width * 4) as usize;
let blocks_x = (width + BLOCK_SIZE - 1) / BLOCK_SIZE; let blocks_x = width.div_ceil(BLOCK_SIZE);
let blocks_y = (height + BLOCK_SIZE - 1) / BLOCK_SIZE; let blocks_y = height.div_ceil(BLOCK_SIZE);
for by in 0..blocks_y { for by in 0..blocks_y {
for bx in 0..blocks_x { for bx in 0..blocks_x {

300
agent/src/identity.rs Normal file
View File

@@ -0,0 +1,300 @@
//! Deterministic, recomputable machine identity (`machine_uid`).
//!
//! SPEC-004 / v2-stable-identity Task 1.
//!
//! `machine_uid()` returns a stable, opaque identifier for *this physical
//! machine*. Unlike `agent_id` (a random UUID persisted in the config file,
//! which mints a fresh value — and thus a duplicate server row — whenever the
//! config is lost), `machine_uid` is **derived from the hardware/OS** and is
//! **recomputable**: the same machine yields the same id on every call with no
//! persistence required.
//!
//! - **Windows:** SHA-256 hash of the OS machine GUID read from
//! `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (a `REG_SZ`). The raw
//! GUID is never returned — only the opaque `muid_<hex>` derived from it.
//! - **Non-Windows (and Windows registry failure):** a random UUID persisted in
//! the agent's data directory, read back on subsequent runs so it is stable
//! across calls and process restarts.
//!
//! This module deliberately does NOT change `agent_id`/`generate_agent_id`.
//! `machine_uid` is reported *alongside* `agent_id`; the server-side dedup that
//! consumes it is a separate task.
use std::sync::OnceLock;
/// Prefix marking the value as an opaque machine-uid (vs. a raw GUID/UUID).
const MUID_PREFIX: &str = "muid_";
/// Cached value — `machine_uid()` reads the registry / a file, so compute once
/// and reuse for the lifetime of the process.
static MACHINE_UID: OnceLock<String> = OnceLock::new();
/// Return a deterministic, recomputable opaque machine identifier.
///
/// The result is non-empty and prefixed with [`MUID_PREFIX`]. It is cached after
/// the first call. On Windows it is derived purely from the OS machine GUID (no
/// persistence). If the Windows registry read fails — or on any non-Windows
/// platform — it degrades to a persisted random UUID (today's-behavior-equivalent
/// stability) rather than panicking.
pub fn machine_uid() -> String {
MACHINE_UID.get_or_init(compute_machine_uid).clone()
}
/// Derive the opaque id from a raw machine-identity string via SHA-256.
///
/// Returns `muid_<first-16-bytes-of-sha256, hex>`. Hashing makes the value
/// opaque (the raw `MachineGuid` is never exposed) while staying fully
/// deterministic for a given input.
fn derive_uid(raw: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(raw.as_bytes());
let hash = hasher.finalize();
format!("{}{}", MUID_PREFIX, hex::encode(&hash[..16]))
}
#[cfg(windows)]
fn compute_machine_uid() -> String {
match read_machine_guid() {
Ok(guid) if !guid.trim().is_empty() => derive_uid(guid.trim()),
Ok(_) => {
tracing::warn!(
"MachineGuid registry value was empty; falling back to persisted machine_uid"
);
persisted_uid()
}
Err(e) => {
tracing::warn!(
"Failed to read MachineGuid from registry ({e}); falling back to persisted machine_uid"
);
persisted_uid()
}
}
}
#[cfg(not(windows))]
fn compute_machine_uid() -> String {
// No OS machine GUID available — use the persisted random UUID, hashed for a
// uniform opaque shape with the Windows path.
persisted_uid()
}
/// Read `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (REG_SZ).
///
/// Uses `RegGetValueW`, which opens, queries, null-terminates, and (with
/// `RRF_RT_REG_SZ`) type-checks the value in one call.
#[cfg(windows)]
fn read_machine_guid() -> anyhow::Result<String> {
use anyhow::{anyhow, Context};
use windows::core::PCWSTR;
use windows::Win32::Foundation::ERROR_SUCCESS;
use windows::Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ};
fn to_wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
let subkey = to_wide(r"SOFTWARE\Microsoft\Cryptography");
let value = to_wide("MachineGuid");
unsafe {
// First query the required buffer size (in bytes).
let mut size: u32 = 0;
let status = RegGetValueW(
HKEY_LOCAL_MACHINE,
PCWSTR(subkey.as_ptr()),
PCWSTR(value.as_ptr()),
RRF_RT_REG_SZ,
None,
None,
Some(&mut size),
);
if status != ERROR_SUCCESS {
return Err(anyhow!("RegGetValueW(size) failed: {:?}", status));
}
if size == 0 {
return Err(anyhow!("MachineGuid reported zero length"));
}
// `size` is bytes; allocate a u16 buffer large enough to hold it.
let len_u16 = size.div_ceil(2) as usize;
let mut buffer = vec![0u16; len_u16];
let mut size_out = size;
let status = RegGetValueW(
HKEY_LOCAL_MACHINE,
PCWSTR(subkey.as_ptr()),
PCWSTR(value.as_ptr()),
RRF_RT_REG_SZ,
None,
Some(buffer.as_mut_ptr() as *mut _),
Some(&mut size_out),
);
if status != ERROR_SUCCESS {
return Err(anyhow!("RegGetValueW(read) failed: {:?}", status));
}
// Trim the trailing NUL(s) that RegGetValueW guarantees.
let chars = size_out as usize / 2;
let slice = &buffer[..chars.min(buffer.len())];
let end = slice.iter().position(|&c| c == 0).unwrap_or(slice.len());
String::from_utf16(&slice[..end]).context("MachineGuid was not valid UTF-16")
}
}
/// Read (or, on first use, generate and persist) a random UUID, then derive the
/// opaque id from it. This is the fallback identity: stable across calls and
/// process restarts because it is persisted to disk.
fn persisted_uid() -> String {
let path = fallback_uid_path();
// Try to read an existing value.
if let Some(ref p) = path {
if let Ok(contents) = std::fs::read_to_string(p) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return derive_uid(trimmed);
}
}
}
// Generate a new random seed and persist it (best-effort).
let seed = uuid::Uuid::new_v4().to_string();
if let Some(ref p) = path {
if let Some(parent) = p.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(p, &seed) {
tracing::warn!(
"Could not persist fallback machine_uid seed to {:?} ({e}); \
id will be stable for this process only",
p
);
}
} else {
tracing::warn!(
"No writable data directory for fallback machine_uid seed; \
id will be stable for this process only"
);
}
derive_uid(&seed)
}
/// Location of the persisted fallback seed file.
///
/// - **Windows:** `%ProgramData%\GuruConnect\machine_uid` (mirrors the agent
/// config location), used only when the registry read fails.
/// - **Non-Windows:** `$XDG_DATA_HOME/guruconnect/machine_uid`, falling back to
/// `$HOME/.local/share/guruconnect/machine_uid`, then a temp-dir path.
fn fallback_uid_path() -> Option<std::path::PathBuf> {
#[cfg(windows)]
{
if let Ok(program_data) = std::env::var("ProgramData") {
return Some(
std::path::PathBuf::from(program_data)
.join("GuruConnect")
.join("machine_uid"),
);
}
}
#[cfg(not(windows))]
{
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return Some(
std::path::PathBuf::from(xdg)
.join("guruconnect")
.join("machine_uid"),
);
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return Some(
std::path::PathBuf::from(home)
.join(".local")
.join("share")
.join("guruconnect")
.join("machine_uid"),
);
}
}
}
// Last resort: a stable name in the system temp dir.
Some(std::env::temp_dir().join("guruconnect_machine_uid"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn machine_uid_is_non_empty_and_prefixed() {
let uid = machine_uid();
assert!(!uid.is_empty(), "machine_uid must not be empty");
assert!(
uid.starts_with(MUID_PREFIX),
"machine_uid must start with {MUID_PREFIX}: got {uid}"
);
// muid_ + 16 bytes hex (32 chars).
assert_eq!(
uid.len(),
MUID_PREFIX.len() + 32,
"unexpected machine_uid length: {uid}"
);
assert!(
uid[MUID_PREFIX.len()..]
.chars()
.all(|c| c.is_ascii_hexdigit()),
"machine_uid suffix must be lowercase hex: {uid}"
);
}
#[test]
fn machine_uid_is_deterministic_across_calls() {
// The cached public API must be stable.
assert_eq!(machine_uid(), machine_uid());
}
#[test]
fn derive_uid_is_deterministic() {
// Same input -> same output; different input -> different output.
let a = derive_uid("the-same-input");
let b = derive_uid("the-same-input");
let c = derive_uid("a-different-input");
assert_eq!(a, b);
assert_ne!(a, c);
assert!(a.starts_with(MUID_PREFIX));
}
/// The non-Windows fallback must be stable across calls because it persists
/// its seed. We exercise `persisted_uid()` directly (the public `machine_uid`
/// is cached, so it cannot demonstrate persistence on its own).
#[test]
fn persisted_uid_is_stable_across_calls() {
let first = persisted_uid();
let second = persisted_uid();
assert_eq!(
first, second,
"persisted fallback uid must be stable across calls"
);
assert!(first.starts_with(MUID_PREFIX));
}
/// On Windows specifically, the registry-derived path must be deterministic:
/// reading the same `MachineGuid` twice yields the same uid.
#[cfg(windows)]
#[test]
fn windows_machine_guid_path_is_deterministic() {
// If the registry read succeeds, two reads must agree and the derived
// uid must match. If it fails (unusual), the test still validates the
// fallback determinism via compute_machine_uid().
let a = compute_machine_uid();
let b = compute_machine_uid();
assert_eq!(a, b, "compute_machine_uid must be deterministic on Windows");
assert!(a.starts_with(MUID_PREFIX));
}
}

View File

@@ -1,4 +1,11 @@
//! Keyboard input simulation using Windows SendInput API //! Keyboard input simulation using Windows SendInput API
//!
//! Injection is **scan-code based** (`KEYEVENTF_SCANCODE`) rather than virtual-key
//! based. Scan codes are layout-independent: the same physical key produces the same
//! scan code regardless of the remote keyboard layout, so the remote machine's active
//! layout (not the technician's) decides what character a key produces. The viewer
//! still carries the virtual-key code for logic that needs it, and we fall back to
//! deriving a scan code from the VK when the wire frame did not supply one.
use anyhow::Result; use anyhow::Result;
@@ -11,11 +18,13 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
/// Keyboard input controller /// Keyboard input controller
pub struct KeyboardController { pub struct KeyboardController {
// Track modifier states for proper handling /// Tracks which modifier keys this controller currently holds DOWN on the remote.
#[allow(dead_code)] /// Used so a focus-loss / session-end re-sync can release any still-held modifier
/// and avoid "stuck" Ctrl/Alt/Shift/Win on the remote desktop.
modifiers: ModifierState, modifiers: ModifierState,
} }
/// Tracks the down/up state of each modifier the agent has injected.
#[derive(Default)] #[derive(Default)]
struct ModifierState { struct ModifierState {
ctrl: bool, ctrl: bool,
@@ -24,6 +33,55 @@ struct ModifierState {
meta: bool, meta: bool,
} }
impl ModifierState {
/// Record a modifier transition for `vk_code`. Returns `true` if `vk_code` is a
/// modifier key (and the state was updated), `false` otherwise.
fn record(&mut self, vk_code: u16, down: bool) -> bool {
match vk_code {
// VK_CONTROL / VK_LCONTROL / VK_RCONTROL
0x11 | 0xA2 | 0xA3 => {
self.ctrl = down;
true
}
// VK_MENU / VK_LMENU / VK_RMENU (Alt)
0x12 | 0xA4 | 0xA5 => {
self.alt = down;
true
}
// VK_SHIFT / VK_LSHIFT / VK_RSHIFT
0x10 | 0xA0 | 0xA1 => {
self.shift = down;
true
}
// VK_LWIN / VK_RWIN
0x5B | 0x5C => {
self.meta = down;
true
}
_ => false,
}
}
/// Return the VK codes of every modifier currently held down, then clear the state.
fn drain_held(&mut self) -> Vec<u16> {
let mut held = Vec::new();
if self.ctrl {
held.push(0x11);
}
if self.alt {
held.push(0x12);
}
if self.shift {
held.push(0x10);
}
if self.meta {
held.push(0x5B);
}
*self = ModifierState::default();
held
}
}
impl KeyboardController { impl KeyboardController {
/// Create a new keyboard controller /// Create a new keyboard controller
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
@@ -32,28 +90,75 @@ impl KeyboardController {
}) })
} }
/// Press a key down by virtual key code /// Press a key down by virtual key code (scan code derived from the VK).
#[cfg(windows)] #[cfg(windows)]
pub fn key_down(&mut self, vk_code: u16) -> Result<()> { pub fn key_down(&mut self, vk_code: u16) -> Result<()> {
self.send_key(vk_code, true) self.send_key(vk_code, 0, false, true)
} }
/// Release a key by virtual key code /// Release a key by virtual key code (scan code derived from the VK).
#[cfg(windows)] #[cfg(windows)]
pub fn key_up(&mut self, vk_code: u16) -> Result<()> { pub fn key_up(&mut self, vk_code: u16) -> Result<()> {
self.send_key(vk_code, false) self.send_key(vk_code, 0, false, false)
} }
/// Send a key event /// Inject a full-fidelity key event.
///
/// `scan_code` is the hardware scan code captured by the viewer's low-level hook
/// (0 ⇒ derive it from `vk_code`). `is_extended` is the viewer-captured extended-key
/// flag (`LLKHF_EXTENDED`); when `false` the agent still derives the flag from the
/// VK / scan code so older viewers that don't set it stay correct.
#[cfg(windows)] #[cfg(windows)]
fn send_key(&mut self, vk_code: u16, down: bool) -> Result<()> { pub fn key_event_full(
// Get scan code from virtual key &mut self,
let scan_code = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 }; vk_code: u16,
scan_code: u16,
is_extended: bool,
down: bool,
) -> Result<()> {
self.send_key(vk_code, scan_code, is_extended, down)
}
let mut flags = KEYBD_EVENT_FLAGS::default(); /// Release every modifier this controller currently holds down on the remote.
///
/// Called on viewer focus loss and at session end so a Ctrl/Alt/Shift/Win that was
/// pressed but whose key-up never arrived (e.g. the technician alt-tabbed away) does
/// not stay latched on the remote desktop.
#[cfg(windows)]
pub fn release_all_modifiers(&mut self) -> Result<()> {
for vk in self.modifiers.drain_held() {
// Emit the key-up directly; drain_held already cleared the tracked state.
if let Err(e) = self.send_key(vk, 0, false, false) {
tracing::warn!("Failed to release held modifier vk={:#x}: {}", vk, e);
} else {
tracing::debug!("Released stuck modifier vk={:#x} on focus loss", vk);
}
}
Ok(())
}
// Add extended key flag for certain keys /// Send a key event using scan-code injection.
if Self::is_extended_key(vk_code) || (scan_code >> 8) == 0xE0 { #[cfg(windows)]
fn send_key(
&mut self,
vk_code: u16,
scan_code: u16,
is_extended: bool,
down: bool,
) -> Result<()> {
// Track modifier state so we can release stuck modifiers later.
self.modifiers.record(vk_code, down);
// Prefer the viewer-supplied scan code; fall back to deriving one from the VK.
// MAPVK_VK_TO_VSC_EX yields a 0xE0-prefixed value for extended keys.
let mapped = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 };
let effective_scan = if scan_code != 0 { scan_code } else { mapped };
let mut flags = KEYBD_EVENT_FLAGS::default() | KEYEVENTF_SCANCODE;
// Add the extended flag if the viewer flagged it, the VK is inherently
// extended, or the mapped scan code carries the 0xE0 extended prefix.
if is_extended || Self::is_extended_key(vk_code) || (mapped >> 8) == 0xE0 {
flags |= KEYEVENTF_EXTENDEDKEY; flags |= KEYEVENTF_EXTENDEDKEY;
} }
@@ -61,12 +166,16 @@ impl KeyboardController {
flags |= KEYEVENTF_KEYUP; flags |= KEYEVENTF_KEYUP;
} }
// For scan-code injection the low byte of the scan code is what Windows uses;
// the 0xE0 prefix is conveyed via KEYEVENTF_EXTENDEDKEY, not the wScan value.
let w_scan = (effective_scan & 0x00FF) as u16;
let input = INPUT { let input = INPUT {
r#type: INPUT_KEYBOARD, r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 { Anonymous: INPUT_0 {
ki: KEYBDINPUT { ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(vk_code), wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
wScan: scan_code, wScan: w_scan,
dwFlags: flags, dwFlags: flags,
time: 0, time: 0,
dwExtraInfo: 0, dwExtraInfo: 0,
@@ -78,6 +187,7 @@ impl KeyboardController {
} }
/// Type a unicode character /// Type a unicode character
#[allow(dead_code)]
#[cfg(windows)] #[cfg(windows)]
pub fn type_char(&mut self, ch: char) -> Result<()> { pub fn type_char(&mut self, ch: char) -> Result<()> {
let mut inputs = Vec::new(); let mut inputs = Vec::new();
@@ -119,6 +229,7 @@ impl KeyboardController {
} }
/// Type a string of text /// Type a string of text
#[allow(dead_code)]
#[cfg(windows)] #[cfg(windows)]
pub fn type_string(&mut self, text: &str) -> Result<()> { pub fn type_string(&mut self, text: &str) -> Result<()> {
for ch in text.chars() { for ch in text.chars() {
@@ -129,21 +240,35 @@ impl KeyboardController {
/// Send Secure Attention Sequence (Ctrl+Alt+Delete) /// Send Secure Attention Sequence (Ctrl+Alt+Delete)
/// ///
/// This uses a multi-tier approach: /// Ctrl+Alt+Del is the Secure Attention Sequence and **cannot** be injected via
/// 1. Try the GuruConnect SAS Service (runs as SYSTEM, handles via named pipe) /// `SendInput` — Windows reserves it. It must be raised by `SendSAS`, which only
/// 2. Try the sas.dll directly (requires SYSTEM privileges) /// works when the caller runs as SYSTEM (or has SeTcbPrivilege) AND the
/// 3. Fallback to key simulation (won't work on secure desktop) /// `SoftwareSASGeneration` Winlogon policy permits software-generated SAS. The
/// managed installer is responsible for installing the SAS helper service (running
/// as SYSTEM) and setting that policy. See `set_software_sas_policy` in
/// `bin/sas_service.rs` and the `// TODO(installer)` note there.
///
/// Tiers, in order:
/// 1. The GuruConnect SAS helper service (SYSTEM) via named-pipe IPC — the supported path.
/// 2. Direct `sas.dll!SendSAS` — only succeeds if THIS process is already SYSTEM with the policy.
/// 3. Fallback key simulation — will NOT reach the secure desktop; logged as a clear failure.
#[cfg(windows)] #[cfg(windows)]
pub fn send_sas(&mut self) -> Result<()> { pub fn send_sas(&mut self) -> Result<()> {
// Tier 1: Try the SAS service (named pipe IPC to SYSTEM service) // Tier 1: Try the SAS service (named pipe IPC to SYSTEM service)
if let Ok(()) = crate::sas_client::request_sas() { match crate::sas_client::request_sas() {
tracing::info!("SAS sent via GuruConnect SAS Service"); Ok(()) => {
return 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 + SoftwareSASGeneration)
// Tier 2: Try using the sas.dll directly (requires SYSTEM privileges)
use windows::core::PCWSTR; use windows::core::PCWSTR;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
@@ -154,49 +279,33 @@ impl KeyboardController {
if let Ok(lib) = lib { if let Ok(lib) = lib {
let proc_name = b"SendSAS\0"; let proc_name = b"SendSAS\0";
if let Some(proc) = GetProcAddress(lib, windows::core::PCSTR(proc_name.as_ptr())) { if let Some(proc) = GetProcAddress(lib, windows::core::PCSTR(proc_name.as_ptr())) {
// SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del // SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del.
// It silently no-ops if the caller lacks privilege / the policy is
// unset, so we cannot detect success here — but it is the best
// effort short of the SYSTEM helper.
let send_sas: extern "system" fn(i32) = std::mem::transmute(proc); let send_sas: extern "system" fn(i32) = std::mem::transmute(proc);
send_sas(0); // FALSE = Ctrl+Alt+Del send_sas(0); // FALSE = Ctrl+Alt+Del
tracing::info!("SAS sent via direct sas.dll call"); tracing::info!("SAS attempted via direct sas.dll call (effective only if SYSTEM + SoftwareSASGeneration policy set)");
return Ok(()); return Ok(());
} }
} }
} }
// Tier 3: Fallback - try sending the keys (won't work on secure desktop) // Tier 3: SAS could not be delivered through any privileged path. A plain
tracing::warn!("SAS service and sas.dll not available, Ctrl+Alt+Del may not work"); // SendInput of Ctrl+Alt+Del never reaches the secure desktop, so report a
// clear, actionable error instead of pretending it worked.
// VK codes let msg = "Ctrl+Alt+Del could not be delivered: the GuruConnect SAS helper \
const VK_CONTROL: u16 = 0x11; service is not running and sas.dll!SendSAS is unavailable. Ensure the \
const VK_MENU: u16 = 0x12; // Alt SAS service is installed (runs as SYSTEM) and the SoftwareSASGeneration \
const VK_DELETE: u16 = 0x2E; policy is enabled by the installer.";
tracing::error!("{}", msg);
// Press keys anyhow::bail!("{}", msg)
self.key_down(VK_CONTROL)?;
self.key_down(VK_MENU)?;
self.key_down(VK_DELETE)?;
// Release keys
self.key_up(VK_DELETE)?;
self.key_up(VK_MENU)?;
self.key_up(VK_CONTROL)?;
Ok(())
} }
/// Check if a virtual key code is an extended key /// Check if a virtual key code is an extended key
#[cfg(windows)] #[cfg(windows)]
fn is_extended_key(vk: u16) -> bool { fn is_extended_key(vk: u16) -> bool {
matches!( vk_is_extended(vk)
vk,
0x21..=0x28 | // Page Up, Page Down, End, Home, Arrow keys
0x2D | 0x2E | // Insert, Delete
0x5B | 0x5C | // Left/Right Windows keys
0x5D | // Applications key
0x6F | // Numpad Divide
0x90 | // Num Lock
0x91 // Scroll Lock
)
} }
/// Send input events /// Send input events
@@ -221,6 +330,22 @@ impl KeyboardController {
anyhow::bail!("Keyboard input only supported on Windows") anyhow::bail!("Keyboard input only supported on Windows")
} }
#[cfg(not(windows))]
pub fn key_event_full(
&mut self,
_vk_code: u16,
_scan_code: u16,
_is_extended: bool,
_down: bool,
) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows")
}
#[cfg(not(windows))]
pub fn release_all_modifiers(&mut self) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows")
}
#[cfg(not(windows))] #[cfg(not(windows))]
pub fn type_char(&mut self, _ch: char) -> Result<()> { pub fn type_char(&mut self, _ch: char) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows") anyhow::bail!("Keyboard input only supported on Windows")
@@ -290,3 +415,121 @@ pub mod vk {
pub const LMENU: u16 = 0xA4; // Left Alt pub const LMENU: u16 = 0xA4; // Left Alt
pub const RMENU: u16 = 0xA5; // Right Alt pub const RMENU: u16 = 0xA5; // Right Alt
} }
/// Whether a Windows virtual-key code is an "extended" key.
///
/// Extended keys must be injected with `KEYEVENTF_EXTENDEDKEY`. This is the
/// platform-independent classifier so the determination can be unit-tested off-Windows;
/// the `#[cfg(windows)]` injection path delegates here. The viewer-captured
/// `LLKHF_EXTENDED` flag is authoritative when present; this is the fallback used when
/// the wire frame did not carry it (older viewers / VK-only synthesis).
pub fn vk_is_extended(vk: u16) -> bool {
matches!(
vk,
0x21..=0x28 | // Page Up, Page Down, End, Home, Arrow keys
0x2D | 0x2E | // Insert, Delete
0x5B | 0x5C | // Left/Right Windows keys
0x5D | // Applications key
0x6F | // Numpad Divide
0x90 | // Num Lock
0x91 | // Scroll Lock
0xA3 | // Right Control
0xA5 // Right Alt (AltGr)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extended_keys_are_flagged() {
// Arrows / navigation block.
for vk in [0x21u16, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28] {
assert!(vk_is_extended(vk), "vk={:#x} should be extended", vk);
}
// Insert / Delete.
assert!(vk_is_extended(0x2D));
assert!(vk_is_extended(0x2E));
// Win keys, Apps, NumLock, numpad Divide.
assert!(vk_is_extended(0x5B));
assert!(vk_is_extended(0x5C));
assert!(vk_is_extended(0x5D));
assert!(vk_is_extended(0x6F));
assert!(vk_is_extended(0x90));
// Right Ctrl / Right Alt.
assert!(vk_is_extended(0xA3));
assert!(vk_is_extended(0xA5));
}
#[test]
fn non_extended_keys_are_not_flagged() {
// Letters, digits, space, enter, left modifiers, numpad digits.
for vk in [
0x41u16, // A
0x5A, // Z
0x30, // 0
0x20, // Space
0x0D, // Enter
0xA0, // Left Shift
0xA2, // Left Control
0xA4, // Left Alt
0x60, // Numpad 0
0x6A, // Numpad Multiply (NOT extended; only Divide is)
] {
assert!(!vk_is_extended(vk), "vk={:#x} should NOT be extended", vk);
}
}
#[test]
fn modifier_state_records_ctrl_alt_shift_win() {
let mut m = ModifierState::default();
// Each of the VK aliases maps to its modifier flag.
assert!(m.record(0x11, true)); // VK_CONTROL
assert!(m.ctrl);
assert!(m.record(0xA4, true)); // VK_LMENU (Alt)
assert!(m.alt);
assert!(m.record(0xA0, true)); // VK_LSHIFT
assert!(m.shift);
assert!(m.record(0x5C, true)); // VK_RWIN
assert!(m.meta);
}
#[test]
fn modifier_state_ignores_non_modifiers() {
let mut m = ModifierState::default();
assert!(!m.record(0x41, true)); // 'A' is not a modifier
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
}
#[test]
fn modifier_state_tracks_down_then_up() {
let mut m = ModifierState::default();
m.record(0x11, true); // Ctrl down
assert!(m.ctrl);
m.record(0x11, false); // Ctrl up
assert!(!m.ctrl);
}
#[test]
fn drain_held_returns_and_clears_held_modifiers() {
let mut m = ModifierState::default();
m.record(0xA2, true); // Left Ctrl -> ctrl
m.record(0x12, true); // Alt
// Shift and Win were never pressed.
let mut held = m.drain_held();
held.sort_unstable();
// Canonical VKs returned: Ctrl(0x11), Alt(0x12).
assert_eq!(held, vec![0x11u16, 0x12]);
// State is cleared after draining.
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
// A second drain yields nothing.
assert!(m.drain_held().is_empty());
}
#[test]
fn drain_held_empty_when_nothing_pressed() {
let mut m = ModifierState::default();
assert!(m.drain_held().is_empty());
}
}

View File

@@ -5,6 +5,7 @@
mod keyboard; mod keyboard;
mod mouse; mod mouse;
pub use keyboard::vk_is_extended;
pub use keyboard::KeyboardController; pub use keyboard::KeyboardController;
pub use mouse::MouseController; pub use mouse::MouseController;
@@ -26,11 +27,13 @@ impl InputController {
} }
/// Get mouse controller /// Get mouse controller
#[allow(dead_code)]
pub fn mouse(&mut self) -> &mut MouseController { pub fn mouse(&mut self) -> &mut MouseController {
&mut self.mouse &mut self.mouse
} }
/// Get keyboard controller /// Get keyboard controller
#[allow(dead_code)]
pub fn keyboard(&mut self) -> &mut KeyboardController { pub fn keyboard(&mut self) -> &mut KeyboardController {
&mut self.keyboard &mut self.keyboard
} }
@@ -54,7 +57,8 @@ impl InputController {
self.mouse.scroll(delta_x, delta_y) self.mouse.scroll(delta_x, delta_y)
} }
/// Press or release a key /// Press or release a key by virtual-key code only (scan code derived from the VK).
#[allow(dead_code)]
pub fn key_event(&mut self, vk_code: u16, down: bool) -> Result<()> { pub fn key_event(&mut self, vk_code: u16, down: bool) -> Result<()> {
if down { if down {
self.keyboard.key_down(vk_code) self.keyboard.key_down(vk_code)
@@ -63,7 +67,32 @@ impl InputController {
} }
} }
/// Inject a full-fidelity key event (VK + hardware scan code + extended-key flag).
///
/// This is the path used for relayed viewer keystrokes so that scan-code injection
/// (layout-independent) and the correct `KEYEVENTF_EXTENDEDKEY` flag are applied.
pub fn key_event_full(
&mut self,
vk_code: u16,
scan_code: u16,
is_extended: bool,
down: bool,
) -> Result<()> {
self.keyboard
.key_event_full(vk_code, scan_code, is_extended, down)
}
/// Release any modifier keys currently held down on the remote.
///
/// Invoked when the viewer loses focus or the session ends so a Ctrl/Alt/Shift/Win
/// whose key-up never arrived does not stay latched on the remote desktop.
#[allow(dead_code)]
pub fn release_all_modifiers(&mut self) -> Result<()> {
self.keyboard.release_all_modifiers()
}
/// Type a unicode character /// Type a unicode character
#[allow(dead_code)]
pub fn type_unicode(&mut self, ch: char) -> Result<()> { pub fn type_unicode(&mut self, ch: char) -> Result<()> {
self.keyboard.type_char(ch) self.keyboard.type_char(ch)
} }
@@ -80,7 +109,10 @@ pub enum MouseButton {
Left, Left,
Right, Right,
Middle, Middle,
// Extra mouse buttons; not yet produced by the viewer input mapping.
#[allow(dead_code)]
X1, X1,
#[allow(dead_code)]
X2, X2,
} }

View File

@@ -6,7 +6,7 @@
//! - UAC elevation with graceful fallback //! - UAC elevation with graceful fallback
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use tracing::{error, info, warn}; use tracing::{info, warn};
#[cfg(windows)] #[cfg(windows)]
use windows::{ use windows::{

View File

@@ -15,7 +15,9 @@
mod capture; mod capture;
mod chat; mod chat;
mod config; mod config;
mod consent;
mod encoder; mod encoder;
mod identity;
mod input; mod input;
mod install; mod install;
mod sas_client; mod sas_client;
@@ -452,7 +454,7 @@ fn show_error_box(_title: &str, message: &str) {
fn show_debug_console() { fn show_debug_console() {
unsafe { unsafe {
let hwnd = GetConsoleWindow(); let hwnd = GetConsoleWindow();
if hwnd.0 == std::ptr::null_mut() { if hwnd.0.is_null() {
let _ = AllocConsole(); let _ = AllocConsole();
} else { } else {
let _ = ShowWindow(hwnd, SW_SHOW); let _ = ShowWindow(hwnd, SW_SHOW);

View File

@@ -5,13 +5,11 @@
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas"; const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
const TIMEOUT_MS: u64 = 5000;
/// Request Ctrl+Alt+Del (Secure Attention Sequence) via the SAS service /// Request Ctrl+Alt+Del (Secure Attention Sequence) via the SAS service
pub fn request_sas() -> Result<()> { pub fn request_sas() -> Result<()> {
@@ -65,6 +63,8 @@ pub fn request_sas() -> Result<()> {
} }
/// Check if the SAS service is available /// Check if the SAS service is available
// Used by the test module and the (not-yet-wired) SAS status API.
#[allow(dead_code)]
pub fn is_service_available() -> bool { pub fn is_service_available() -> bool {
// Try to open the pipe // Try to open the pipe
if let Ok(mut pipe) = OpenOptions::new().read(true).write(true).open(PIPE_NAME) { if let Ok(mut pipe) = OpenOptions::new().read(true).write(true).open(PIPE_NAME) {
@@ -81,6 +81,7 @@ pub fn is_service_available() -> bool {
} }
/// Get information about SAS service status /// Get information about SAS service status
#[allow(dead_code)]
pub fn get_service_status() -> String { pub fn get_service_status() -> String {
if is_service_available() { if is_service_available() {
"SAS service is running and responding".to_string() "SAS service is running and responding".to_string()

View File

@@ -11,7 +11,7 @@ use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
#[cfg(windows)] #[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW}; use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
use crate::capture::{self, Capturer, Display}; use crate::capture::{self, Capturer};
use crate::chat::{ChatController, ChatMessage as ChatMsg}; use crate::chat::{ChatController, ChatMessage as ChatMsg};
use crate::config::Config; use crate::config::Config;
use crate::encoder::{self, Encoder}; use crate::encoder::{self, Encoder};
@@ -22,7 +22,7 @@ use crate::input::InputController;
fn show_debug_console() { fn show_debug_console() {
unsafe { unsafe {
let hwnd = GetConsoleWindow(); let hwnd = GetConsoleWindow();
if hwnd.0 == std::ptr::null_mut() { if hwnd.0.is_null() {
let _ = AllocConsole(); let _ = AllocConsole();
tracing::info!("Debug console window opened"); tracing::info!("Debug console window opened");
} else { } else {
@@ -61,6 +61,10 @@ pub struct SessionManager {
input: Option<InputController>, input: Option<InputController>,
// Streaming state // Streaming state
current_viewer_id: Option<String>, current_viewer_id: Option<String>,
// Codec negotiated by the server for the current stream (Task 7). Set from
// StartStream.video_codec; the encoder is built from it (guarded by the
// agent's own hardware capability, with raw as the safe fallback).
negotiated_codec: crate::proto::VideoCodec,
// System info for status reports // System info for status reports
hostname: String, hostname: String,
is_elevated: bool, is_elevated: bool,
@@ -87,6 +91,8 @@ impl SessionManager {
encoder: None, encoder: None,
input: None, input: None,
current_viewer_id: None, current_viewer_id: None,
// Default to RAW until the server negotiates otherwise (StartStream).
negotiated_codec: crate::proto::VideoCodec::Raw,
hostname, hostname,
is_elevated, is_elevated,
start_time: Instant::now(), start_time: Instant::now(),
@@ -97,12 +103,16 @@ impl SessionManager {
pub async fn connect(&mut self) -> Result<()> { pub async fn connect(&mut self) -> Result<()> {
self.state = SessionState::Connecting; self.state = SessionState::Connecting;
// Deterministic, recomputable identity reported alongside agent_id
// (v2 stable-identity Task 1). Cached after the first call.
let machine_uid = crate::identity::machine_uid();
let transport = WebSocketTransport::connect( let transport = WebSocketTransport::connect(
&self.config.server_url, &self.config.server_url,
&self.config.agent_id, &self.config.agent_id,
&self.config.api_key, &self.config.api_key,
Some(&self.hostname), Some(&self.hostname),
self.config.support_code.as_deref(), self.config.support_code.as_deref(),
Some(&machine_uid),
) )
.await?; .await?;
@@ -130,7 +140,7 @@ impl SessionManager {
// Get primary display with panic protection // Get primary display with panic protection
tracing::debug!("Enumerating displays..."); tracing::debug!("Enumerating displays...");
let primary_display = match std::panic::catch_unwind(|| capture::primary_display()) { let primary_display = match std::panic::catch_unwind(capture::primary_display) {
Ok(result) => result?, Ok(result) => result?,
Err(e) => { Err(e) => {
tracing::error!("Panic during display enumeration: {:?}", e); tracing::error!("Panic during display enumeration: {:?}", e);
@@ -168,14 +178,20 @@ impl SessionManager {
self.capturer = Some(capturer); self.capturer = Some(capturer);
tracing::info!("Capturer created successfully"); tracing::info!("Capturer created successfully");
// Create encoder with panic protection // Create encoder from the NEGOTIATED codec (Task 7), guarded by the
// agent's own hardware capability. `create_encoder_for` selects the H.264
// encoder only if it can actually be constructed, otherwise it returns a
// working raw encoder — so this never breaks the session.
let chosen =
encoder::select_codec(self.negotiated_codec, encoder::supports_hardware_h264());
tracing::debug!( tracing::debug!(
"Creating encoder (codec={}, quality={})...", "Creating encoder (negotiated={:?}, chosen={:?}, quality={})...",
self.config.encoding.codec, self.negotiated_codec,
chosen,
self.config.encoding.quality self.config.encoding.quality
); );
let encoder = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let encoder = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
encoder::create_encoder(&self.config.encoding.codec, self.config.encoding.quality) encoder::create_encoder_for(chosen, self.config.encoding.quality)
})) { })) {
Ok(result) => result?, Ok(result) => result?,
Err(e) => { Err(e) => {
@@ -232,6 +248,13 @@ impl SessionManager {
organization: self.config.company.clone().unwrap_or_default(), organization: self.config.company.clone().unwrap_or_default(),
site: self.config.site.clone().unwrap_or_default(), site: self.config.site.clone().unwrap_or_default(),
tags: self.config.tags.clone(), tags: self.config.tags.clone(),
// Advertise hardware H.264 capability so the server can negotiate the
// codec (Task 7). Detected once and cached by the encoder module.
supports_h264: encoder::supports_hardware_h264(),
// Deterministic, recomputable hardware identity (v2 stable-identity
// Task 1). Reported alongside the unchanged random agent_id; cached
// after the first (registry) read.
machine_uid: crate::identity::machine_uid(),
}; };
let msg = Message { let msg = Message {
@@ -336,6 +359,15 @@ impl SessionManager {
match payload { match payload {
message::Payload::StartStream(start) => { message::Payload::StartStream(start) => {
tracing::info!("StartStream received from viewer: {}", start.viewer_id); tracing::info!("StartStream received from viewer: {}", start.viewer_id);
// Apply the server-negotiated codec (Task 7) BEFORE
// building the encoder. An older server that omits the
// field sends 0 = VIDEO_CODEC_RAW, preserving the raw
// default. `select_codec` (in init_streaming) re-guards
// against missing hardware.
self.negotiated_codec =
crate::proto::VideoCodec::try_from(start.video_codec)
.unwrap_or(crate::proto::VideoCodec::Raw);
tracing::info!("Server negotiated codec: {:?}", self.negotiated_codec);
if let Err(e) = self.init_streaming() { if let Err(e) = self.init_streaming() {
tracing::error!("Failed to init streaming: {}", e); tracing::error!("Failed to init streaming: {}", e);
} else { } else {
@@ -369,6 +401,17 @@ impl SessionManager {
} }
continue; continue;
} }
message::Payload::ConsentRequest(req) => {
// ATTENDED-MODE CONSENT (Task 5). The server is holding
// this session in `consent_state = pending` and will not
// surface it to the technician until we reply. Show the
// end user a native dialog and return their decision; the
// dialog blocks, so run it off the async runtime. If the
// user closes it / no choice is made, `prompt_consent`
// returns false (deny).
self.handle_consent_request(req.clone()).await;
continue;
}
_ => {} _ => {}
} }
} }
@@ -498,6 +541,69 @@ impl SessionManager {
Ok(()) Ok(())
} }
/// Handle an attended-mode `ConsentRequest` from the server (Task 5).
///
/// Shows the end user a native consent dialog (off the async runtime, since
/// it blocks) and sends a `ConsentResponse` carrying their decision. A
/// closed dialog / unavailable surface is treated as a DENY. The server
/// gates the whole session on this reply, so we always send a response (even
/// on send failure the server's consent timeout will deny).
async fn handle_consent_request(&mut self, req: crate::proto::ConsentRequest) {
use crate::consent::{prompt_consent, ConsentAccessMode};
use crate::proto::ConsentResponse;
let session_id = req.session_id.clone();
let technician_name = req.technician_name.clone();
let access = ConsentAccessMode::from_proto(req.access_mode);
tracing::info!(
"Consent requested for session {} by '{}' ({:?}); prompting end user",
session_id,
technician_name,
access
);
// The MessageBox blocks the calling thread, so it runs on the blocking
// pool to avoid stalling the tokio runtime. Note, however, that the main
// session loop `.await`s this method (see the ConsentRequest arm), so
// the loop is SUSPENDED for the user's entire think-time and does NOT
// process or respond to server heartbeats while the dialog is open.
// This is safe because CONSENT_TIMEOUT_SECS (60s, server-side) is within
// the server's 90s HEARTBEAT_TIMEOUT_SECS: the prompt resolves before the
// server would consider the agent dead, so the session is not torn down.
let granted = tokio::task::spawn_blocking(move || prompt_consent(&technician_name, access))
.await
.unwrap_or_else(|e| {
// The blocking task panicked — fail closed (deny).
tracing::error!("Consent dialog task failed: {}; denying", e);
false
});
tracing::info!(
"End user {} consent for session {}",
if granted { "GRANTED" } else { "DENIED" },
session_id
);
let response = Message {
payload: Some(message::Payload::ConsentResponse(ConsentResponse {
session_id,
granted,
reason: if granted {
String::new()
} else {
"user_declined".to_string()
},
})),
};
if let Some(transport) = self.transport.as_mut() {
if let Err(e) = transport.send(response).await {
tracing::error!("Failed to send ConsentResponse: {}", e);
}
}
}
/// Handle incoming message from server /// Handle incoming message from server
async fn handle_message(&mut self, msg: Message) -> Result<()> { async fn handle_message(&mut self, msg: Message) -> Result<()> {
match msg.payload { match msg.payload {
@@ -548,18 +654,23 @@ impl SessionManager {
Some(message::Payload::KeyEvent(key)) => { Some(message::Payload::KeyEvent(key)) => {
if let Some(input) = self.input.as_mut() { if let Some(input) = self.input.as_mut() {
input.key_event(key.vk_code as u16, key.down)?; // Full-fidelity scan-code injection: pass the viewer-captured
// scan code and extended-key flag through. A scan_code of 0 (older
// viewers / synthesized events) makes the agent derive it from the VK.
input.key_event_full(
key.vk_code as u16,
key.scan_code as u16,
key.is_extended,
key.down,
)?;
} }
} }
Some(message::Payload::SpecialKey(special)) => { Some(message::Payload::SpecialKey(special)) => {
if let Some(input) = self.input.as_mut() { if let Some(input) = self.input.as_mut() {
use crate::proto::SpecialKey; use crate::proto::SpecialKey;
match SpecialKey::try_from(special.key).ok() { if let Ok(SpecialKey::CtrlAltDel) = SpecialKey::try_from(special.key) {
Some(SpecialKey::CtrlAltDel) => { input.send_ctrl_alt_del()?;
input.send_ctrl_alt_del()?;
}
_ => {}
} }
} }
} }

View File

@@ -3,7 +3,7 @@
//! Handles adding/removing the agent from Windows startup. //! Handles adding/removing the agent from Windows startup.
use anyhow::Result; use anyhow::Result;
use tracing::{error, info, warn}; use tracing::{info, warn};
#[cfg(windows)] #[cfg(windows)]
use windows::core::PCWSTR; use windows::core::PCWSTR;
@@ -58,7 +58,10 @@ pub fn add_to_startup() -> Result<()> {
anyhow::bail!("Failed to open registry key: {:?}", result); anyhow::bail!("Failed to open registry key: {:?}", result);
} }
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey); let hkey_raw = std::mem::transmute::<
windows::Win32::Foundation::HANDLE,
windows::Win32::System::Registry::HKEY,
>(hkey);
// Set the value // Set the value
let data_bytes = let data_bytes =
@@ -116,7 +119,10 @@ pub fn remove_from_startup() -> Result<()> {
return Ok(()); // Not an error if key doesn't exist return Ok(()); // Not an error if key doesn't exist
} }
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey); let hkey_raw = std::mem::transmute::<
windows::Win32::Foundation::HANDLE,
windows::Win32::System::Registry::HKEY,
>(hkey);
let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr())); let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr()));
@@ -180,6 +186,8 @@ pub fn uninstall() -> Result<()> {
/// Install the SAS service if the binary is available /// Install the SAS service if the binary is available
/// This allows the agent to send Ctrl+Alt+Del even without SYSTEM privileges /// This allows the agent to send Ctrl+Alt+Del even without SYSTEM privileges
// Not yet wired into the CLI; retained as the SAS service management API.
#[allow(dead_code)]
#[cfg(windows)] #[cfg(windows)]
pub fn install_sas_service() -> Result<()> { pub fn install_sas_service() -> Result<()> {
info!("Attempting to install SAS service..."); info!("Attempting to install SAS service...");
@@ -230,6 +238,8 @@ pub fn install_sas_service() -> Result<()> {
} }
/// Uninstall the SAS service /// Uninstall the SAS service
// Not yet wired into the CLI; retained as the SAS service management API.
#[allow(dead_code)]
#[cfg(windows)] #[cfg(windows)]
pub fn uninstall_sas_service() -> Result<()> { pub fn uninstall_sas_service() -> Result<()> {
info!("Attempting to uninstall SAS service..."); info!("Attempting to uninstall SAS service...");
@@ -244,16 +254,14 @@ pub fn uninstall_sas_service() -> Result<()> {
)), )),
]; ];
for path_opt in paths.iter() { for path in paths.iter().flatten() {
if let Some(ref path) = path_opt { if path.exists() {
if path.exists() { let output = std::process::Command::new(path).arg("uninstall").output();
let output = std::process::Command::new(path).arg("uninstall").output();
if let Ok(result) = output { if let Ok(result) = output {
if result.status.success() { if result.status.success() {
info!("SAS service uninstalled successfully"); info!("SAS service uninstalled successfully");
return Ok(()); return Ok(());
}
} }
} }
} }
@@ -264,6 +272,8 @@ pub fn uninstall_sas_service() -> Result<()> {
} }
/// Check if the SAS service is installed and running /// Check if the SAS service is installed and running
// Not yet wired into the CLI; retained as the SAS service management API.
#[allow(dead_code)]
#[cfg(windows)] #[cfg(windows)]
pub fn check_sas_service() -> bool { pub fn check_sas_service() -> bool {
use crate::sas_client; use crate::sas_client;

View File

@@ -35,14 +35,25 @@ impl WebSocketTransport {
api_key: &str, api_key: &str,
hostname: Option<&str>, hostname: Option<&str>,
support_code: Option<&str>, support_code: Option<&str>,
machine_uid: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
// Build query parameters // Build query parameters. agent_id + api_key are kept exactly as-is;
// machine_uid is appended ALONGSIDE them (v2 stable-identity Task 1) so
// the server sees the deterministic identity at connect time. It does not
// change registration keying (a separate server-side task).
let mut params = format!("agent_id={}&api_key={}", agent_id, api_key); let mut params = format!("agent_id={}&api_key={}", agent_id, api_key);
if let Some(hostname) = hostname { if let Some(hostname) = hostname {
params.push_str(&format!("&hostname={}", urlencoding::encode(hostname))); params.push_str(&format!("&hostname={}", urlencoding::encode(hostname)));
} }
if let Some(machine_uid) = machine_uid {
params.push_str(&format!(
"&machine_uid={}",
urlencoding::encode(machine_uid)
));
}
if let Some(code) = support_code { if let Some(code) = support_code {
params.push_str(&format!("&support_code={}", code)); params.push_str(&format!("&support_code={}", code));
} }
@@ -82,7 +93,7 @@ impl WebSocketTransport {
// Send as binary WebSocket message // Send as binary WebSocket message
stream stream
.send(WsMessage::Binary(buf.into())) .send(WsMessage::Binary(buf))
.await .await
.context("Failed to send message")?; .context("Failed to send message")?;
@@ -132,6 +143,7 @@ impl WebSocketTransport {
} }
/// Receive a message (blocking) /// Receive a message (blocking)
#[allow(dead_code)]
pub async fn recv(&mut self) -> Result<Option<Message>> { pub async fn recv(&mut self) -> Result<Option<Message>> {
// Return buffered message if available // Return buffered message if available
if let Some(msg) = self.incoming.pop_front() { if let Some(msg) = self.incoming.pop_front() {
@@ -164,7 +176,7 @@ impl WebSocketTransport {
.context("Failed to decode protobuf message")?; .context("Failed to decode protobuf message")?;
Ok(Some(msg)) Ok(Some(msg))
} }
WsMessage::Ping(data) => { WsMessage::Ping(_data) => {
// Pong is sent automatically by tungstenite // Pong is sent automatically by tungstenite
tracing::trace!("Received ping"); tracing::trace!("Received ping");
Ok(None) Ok(None)
@@ -193,6 +205,7 @@ impl WebSocketTransport {
} }
/// Close the connection /// Close the connection
#[allow(dead_code)]
pub async fn close(&mut self) -> Result<()> { pub async fn close(&mut self) -> Result<()> {
let mut stream = self.stream.lock().await; let mut stream = self.stream.lock().await;
stream.close(None).await?; stream.close(None).await?;

View File

@@ -6,10 +6,10 @@
//! - End session //! - End session
use anyhow::Result; use anyhow::Result;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tracing::{info, warn}; use tracing::info;
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent}; use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
#[cfg(windows)] #[cfg(windows)]
@@ -28,7 +28,8 @@ pub enum TrayAction {
/// Tray icon controller /// Tray icon controller
pub struct TrayController { pub struct TrayController {
_tray_icon: TrayIcon, _tray_icon: TrayIcon,
menu: Menu, // Kept alive for the lifetime of the tray icon; not read directly.
_menu: Menu,
end_session_item: MenuItem, end_session_item: MenuItem,
debug_item: MenuItem, debug_item: MenuItem,
status_item: MenuItem, status_item: MenuItem,
@@ -86,7 +87,7 @@ impl TrayController {
Ok(Self { Ok(Self {
_tray_icon: tray_icon, _tray_icon: tray_icon,
menu, _menu: menu,
end_session_item, end_session_item,
debug_item, debug_item,
status_item, status_item,
@@ -124,14 +125,9 @@ impl TrayController {
} }
// Check for tray icon events (like double-click) // Check for tray icon events (like double-click)
if let Ok(event) = TrayIconEvent::receiver().try_recv() { if let Ok(TrayIconEvent::DoubleClick { .. }) = TrayIconEvent::receiver().try_recv() {
match event { info!("Tray icon double-clicked");
TrayIconEvent::DoubleClick { .. } => { return Some(TrayAction::ShowDetails);
info!("Tray icon double-clicked");
return Some(TrayAction::ShowDetails);
}
_ => {}
}
} }
None None

View File

@@ -10,6 +10,25 @@ use tracing::{error, info, warn};
use crate::build_info; use crate::build_info;
/// Whether to disable TLS certificate verification for update traffic.
///
/// Returns `true` ONLY in a debug build (`cfg!(debug_assertions)`) when the
/// `GURUCONNECT_DEV_INSECURE_TLS` environment variable is set. The `cfg!` gate
/// is compiled out of release builds, so a shipped agent ALWAYS verifies certs
/// regardless of environment — a MITM cannot serve a forged update binary over
/// an unverified channel. The env var lets a developer test against a
/// self-signed server without weakening production.
fn dev_insecure_tls() -> bool {
if cfg!(debug_assertions) && std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_ok() {
warn!(
"TLS certificate verification DISABLED (dev-insecure mode) — DO NOT use in production"
);
true
} else {
false
}
}
/// Version information from the server /// Version information from the server
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
pub struct VersionInfo { pub struct VersionInfo {
@@ -17,10 +36,14 @@ pub struct VersionInfo {
pub download_url: String, pub download_url: String,
pub checksum_sha256: String, pub checksum_sha256: String,
pub is_mandatory: bool, pub is_mandatory: bool,
// Part of the server JSON contract; deserialized but not yet surfaced in the UI.
#[allow(dead_code)]
pub release_notes: Option<String>, pub release_notes: Option<String>,
} }
/// Update state tracking /// Update state tracking
// Future use: drive an update-progress indicator.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum UpdateState { pub enum UpdateState {
Idle, Idle,
@@ -38,7 +61,7 @@ pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInf
info!("Checking for updates at {}", url); info!("Checking for updates at {}", url);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // For self-signed certs in dev .danger_accept_invalid_certs(dev_insecure_tls())
.build()?; .build()?;
let response = client let response = client
@@ -104,7 +127,7 @@ pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
info!("Downloading update from {}", version_info.download_url); info!("Downloading update from {}", version_info.download_url);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(dev_insecure_tls())
.build()?; .build()?;
let response = client let response = client
@@ -130,6 +153,13 @@ pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
} }
/// Verify downloaded file checksum /// Verify downloaded file checksum
///
/// NOTE: This is a transport-integrity check (catches truncated/corrupted
/// downloads), NOT a tamper defense. The expected checksum arrives over the
/// same channel as the binary, so an attacker who can serve a forged binary
/// can also serve a matching checksum. Tamper resistance comes from verifying
/// the TLS certificate of the update server (see `dev_insecure_tls`) and, as a
/// future hardening step, an embedded-public-key signature over the artifact.
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> { pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
info!("Verifying checksum..."); info!("Verifying checksum...");
@@ -156,6 +186,9 @@ pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<boo
/// Perform the actual update installation /// Perform the actual update installation
/// This renames the current executable and copies the new one in place /// This renames the current executable and copies the new one in place
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> { pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
// TODO(security): defense-in-depth — verify an embedded-public-key signature
// over the update binary/manifest before install_update; see
// reports/2026-05-30-gc-audit.md
info!("Installing update..."); info!("Installing update...");
// Get current executable path // Get current executable path
@@ -317,4 +350,31 @@ mod tests {
assert!(!is_newer_version("0.1.0", "0.2.0")); assert!(!is_newer_version("0.1.0", "0.2.0"));
assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456")); assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
} }
/// In a release build (`debug_assertions` off), `dev_insecure_tls()` MUST
/// return false regardless of the env var — the shipped agent can never
/// accept invalid certs. In a debug build, it returns true only when
/// `GURUCONNECT_DEV_INSECURE_TLS` is set; we cannot assert the env-var path
/// here without mutating process-global state (which would race other
/// tests), so we only assert the invariant that holds in the current
/// build profile.
#[test]
fn test_dev_insecure_tls_release_is_always_false() {
if !cfg!(debug_assertions) {
// Release/test-release profile: must be false no matter the env.
assert!(
!dev_insecure_tls(),
"release build must never disable TLS verification"
);
} else {
// Debug profile: with the env var unset, must still be false.
// (We avoid setting it to prevent cross-test interference.)
if std::env::var("GURUCONNECT_DEV_INSECURE_TLS").is_err() {
assert!(
!dev_insecure_tls(),
"debug build without the env var must verify TLS"
);
}
}
}
} }

522
agent/src/viewer/decoder.rs Normal file
View 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");
}
}
}

View File

@@ -1,9 +1,24 @@
//! Low-level keyboard hook for capturing all keys including Win key //! Low-level keyboard hook for capturing system key combinations.
//!
//! `WH_KEYBOARD_LL` is a GLOBAL hook: the OS invokes it for ALL desktop input regardless
//! of which window is focused. We therefore gate diversion on the viewer's focus state.
//! ONLY when the viewer window actually has focus AND "send system keys to remote" is
//! enabled does the hook DIVERT the system combinations the local shell would otherwise
//! consume — the Windows key, Win+R, Win+E, Alt+Tab, Ctrl+Esc, Alt+Esc — and forward them
//! to the remote as full-fidelity `KeyEvent`s (virtual key + hardware scan code +
//! extended-key flag + modifier snapshot), returning 1 from the hook proc to suppress the
//! local handling. All other keys flow through the normal viewer input path.
//!
//! When the toggle is OFF, the viewer is not focused, or the key is not a system combo,
//! the hook diverts NOTHING — it falls through to `CallNextHookEx` and every key reaches
//! the local OS unchanged. This keeps the technician's own Start menu / Alt+Tab working
//! while the viewer sits unfocused in the background.
use super::InputEvent; use super::InputEvent;
#[cfg(windows)] #[cfg(windows)]
use crate::proto; use crate::proto;
use anyhow::Result; use anyhow::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::mpsc; use tokio::sync::mpsc;
#[cfg(windows)] #[cfg(windows)]
use tracing::trace; use tracing::trace;
@@ -12,36 +27,83 @@ use tracing::trace;
use windows::{ use windows::{
Win32::Foundation::{LPARAM, LRESULT, WPARAM}, Win32::Foundation::{LPARAM, LRESULT, WPARAM},
Win32::UI::WindowsAndMessaging::{ Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, GetMessageW, PeekMessageW, SetWindowsHookExW, CallNextHookEx, SetWindowsHookExW, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT,
TranslateMessage, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE, LLKHF_EXTENDED, WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
}, },
}; };
#[cfg(windows)] #[cfg(windows)]
use std::sync::OnceLock; use std::sync::OnceLock;
/// Global toggle: when `true`, system key combinations are diverted to the remote;
/// when `false`, the hook is transparent and the local OS handles them. Default ON.
///
/// Lives at module scope because the `WH_KEYBOARD_LL` callback is a bare `extern "system"`
/// function with no user context pointer, so its state must be reachable statically.
static SEND_SYSTEM_KEYS: AtomicBool = AtomicBool::new(true);
/// Set whether system key combinations are forwarded to the remote (vs. handled locally).
///
/// Part of the programmatic toggle API (alongside `toggle_send_system_keys`, which the
/// Pause/Break host key drives). Retained for a future viewer menu / tray item and used
/// by the unit tests; not yet called from non-test code, hence the allow.
#[allow(dead_code)]
pub fn set_send_system_keys(enabled: bool) {
SEND_SYSTEM_KEYS.store(enabled, Ordering::Relaxed);
}
/// Flip the "send system keys to remote" toggle and return the new value.
pub fn toggle_send_system_keys() -> bool {
// fetch_xor(true) flips the bit and returns the PREVIOUS value; invert for the new one.
!SEND_SYSTEM_KEYS.fetch_xor(true, Ordering::Relaxed)
}
/// Current state of the "send system keys to remote" toggle.
///
/// Part of the programmatic toggle API; used by the unit tests and available for a
/// viewer menu / status indicator. Not yet read from non-test code, hence the allow.
#[allow(dead_code)]
pub fn send_system_keys_enabled() -> bool {
SEND_SYSTEM_KEYS.load(Ordering::Relaxed)
}
/// Whether the viewer window currently has input focus. Default `false`.
///
/// `WH_KEYBOARD_LL` is a GLOBAL hook fired for all desktop input, so it must NOT divert
/// system combos while the viewer is unfocused — otherwise the technician's own local
/// Win key / Alt+Tab / Ctrl+Esc would be suppressed and pushed to the remote. The render
/// loop updates this on `WindowEvent::Focused`. Lives at module scope for the same reason
/// as `SEND_SYSTEM_KEYS`: the bare `extern "system"` callback has no user-context pointer.
static VIEWER_FOCUSED: AtomicBool = AtomicBool::new(false);
/// Record whether the viewer window has input focus (drives the hook's focus gate).
pub fn set_viewer_focused(focused: bool) {
VIEWER_FOCUSED.store(focused, Ordering::Relaxed);
}
/// Current focus state as seen by the keyboard hook.
///
/// Used by the unit tests and available for diagnostics; not yet read from non-test code
/// beyond the hook callback itself, hence the allow.
#[allow(dead_code)]
pub fn viewer_focused() -> bool {
VIEWER_FOCUSED.load(Ordering::Relaxed)
}
#[cfg(windows)] #[cfg(windows)]
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new(); static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
#[cfg(windows)] #[cfg(windows)]
static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut()); static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut());
/// Virtual key codes for special keys /// Virtual key codes for keys the hook reasons about.
#[cfg(windows)] #[cfg(windows)]
mod vk { mod vk {
pub const VK_LWIN: u32 = 0x5B; pub const VK_LWIN: u32 = 0x5B;
pub const VK_RWIN: u32 = 0x5C; pub const VK_RWIN: u32 = 0x5C;
pub const VK_APPS: u32 = 0x5D; pub const VK_APPS: u32 = 0x5D;
pub const VK_LSHIFT: u32 = 0xA0;
pub const VK_RSHIFT: u32 = 0xA1;
pub const VK_LCONTROL: u32 = 0xA2;
pub const VK_RCONTROL: u32 = 0xA3;
pub const VK_LMENU: u32 = 0xA4; // Left Alt
pub const VK_RMENU: u32 = 0xA5; // Right Alt
pub const VK_TAB: u32 = 0x09; pub const VK_TAB: u32 = 0x09;
pub const VK_ESCAPE: u32 = 0x1B; pub const VK_ESCAPE: u32 = 0x1B;
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
} }
#[cfg(windows)] #[cfg(windows)]
@@ -52,10 +114,10 @@ pub struct KeyboardHook {
#[cfg(windows)] #[cfg(windows)]
impl KeyboardHook { impl KeyboardHook {
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> { pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
// Store the sender globally for the hook callback // Store the sender globally for the hook callback. If it was already set (e.g.
INPUT_TX // a previous viewer instance in the same process), reuse the existing one rather
.set(input_tx) // than failing — the hook handle itself is what we re-install.
.map_err(|_| anyhow::anyhow!("Input TX already set"))?; let _ = INPUT_TX.set(input_tx);
unsafe { unsafe {
let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?; let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_hook_proc), None, 0)?;
@@ -78,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)] #[cfg(windows)]
unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
if code >= 0 { if code >= 0 {
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT); let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
let vk_code = kb_struct.vkCode; let vk_code = kb_struct.vkCode;
let scan_code = kb_struct.scanCode; let scan_code = kb_struct.scanCode;
// LLKHF_EXTENDED (bit 0) marks extended keys (right Ctrl/Alt, arrows, etc.).
let is_extended = (kb_struct.flags.0 & LLKHF_EXTENDED.0) != 0;
let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN; let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN;
let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP; let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
if is_down || is_up { if is_down || is_up {
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.) let forwarding = SEND_SYSTEM_KEYS.load(Ordering::Relaxed);
let should_intercept = matches!(vk_code, vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS); let focused = VIEWER_FOCUSED.load(Ordering::Relaxed);
let modifiers = current_modifiers();
// Send the key event to the remote // Divert ONLY a SYSTEM combo, ONLY while forwarding is enabled, and ONLY while
if let Some(tx) = INPUT_TX.get() { // the viewer window has focus. This is a global hook, so without the focus gate
let event = proto::KeyEvent { // we would swallow the technician's own Win/Alt+Tab/Ctrl+Esc while the viewer
down: is_down, // sits unfocused in the background. When any condition is false we fall through
key_type: proto::KeyEventType::KeyVk as i32, // to CallNextHookEx and suppress nothing — the local OS handles the key. Ordinary
vk_code, // keys are left to the normal winit viewer input path (they are NOT forwarded
scan_code, // here to avoid double-injection).
unicode: String::new(), let divert =
modifiers: Some(get_current_modifiers()), forwarding && focused && is_system_combo(vk_code, modifiers.alt, modifiers.ctrl);
};
let _ = tx.try_send(InputEvent::Key(event)); if divert {
trace!( if let Some(tx) = INPUT_TX.get() {
"Key hook: vk={:#x} scan={} down={}", let event = proto::KeyEvent {
vk_code, down: is_down,
scan_code, key_type: proto::KeyEventType::KeyVk as i32,
is_down 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 let _ = tx.try_send(InputEvent::Key(event));
if should_intercept { 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); return LRESULT(1);
} }
} }
@@ -123,7 +221,7 @@ unsafe extern "system" fn keyboard_hook_proc(code: i32, wparam: WPARAM, lparam:
} }
#[cfg(windows)] #[cfg(windows)]
fn get_current_modifiers() -> proto::Modifiers { fn current_modifiers() -> proto::Modifiers {
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState; use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
unsafe { unsafe {
@@ -138,18 +236,6 @@ fn get_current_modifiers() -> proto::Modifiers {
} }
} }
/// Pump Windows message queue (required for hooks to work)
#[cfg(windows)]
pub fn pump_messages() {
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
// Non-Windows stubs // Non-Windows stubs
#[cfg(not(windows))] #[cfg(not(windows))]
#[allow(dead_code)] #[allow(dead_code)]
@@ -163,6 +249,73 @@ impl KeyboardHook {
} }
} }
#[cfg(not(windows))] #[cfg(test)]
#[allow(dead_code)] mod tests {
pub fn pump_messages() {} use super::*;
#[test]
fn toggle_defaults_on_and_flips() {
// Default is ON.
set_send_system_keys(true);
assert!(send_system_keys_enabled());
// Toggling flips and returns the NEW value.
assert!(!toggle_send_system_keys());
assert!(!send_system_keys_enabled());
assert!(toggle_send_system_keys());
assert!(send_system_keys_enabled());
// Explicit set wins.
set_send_system_keys(false);
assert!(!send_system_keys_enabled());
set_send_system_keys(true);
}
#[test]
fn viewer_focus_flag_defaults_off_and_tracks() {
// The hook starts gated CLOSED (unfocused) so a background viewer never swallows
// the technician's local system keys until it actually gains focus.
set_viewer_focused(false);
assert!(!viewer_focused());
set_viewer_focused(true);
assert!(viewer_focused());
set_viewer_focused(false);
assert!(!viewer_focused());
}
#[cfg(windows)]
#[test]
fn win_keys_always_divert() {
// Win / Apps keys divert regardless of modifier state.
assert!(is_system_combo(vk::VK_LWIN, false, false));
assert!(is_system_combo(vk::VK_RWIN, false, false));
assert!(is_system_combo(vk::VK_APPS, false, false));
}
#[cfg(windows)]
#[test]
fn alt_tab_and_alt_esc_divert_only_with_alt() {
assert!(is_system_combo(vk::VK_TAB, true, false)); // Alt+Tab
assert!(!is_system_combo(vk::VK_TAB, false, false)); // plain Tab -> local path
assert!(is_system_combo(vk::VK_ESCAPE, true, false)); // Alt+Esc
}
#[cfg(windows)]
#[test]
fn ctrl_esc_diverts_only_with_ctrl() {
assert!(is_system_combo(vk::VK_ESCAPE, false, true)); // Ctrl+Esc
assert!(!is_system_combo(vk::VK_ESCAPE, false, false)); // plain Esc -> local path
}
#[cfg(windows)]
#[test]
fn ordinary_keys_never_divert() {
// 'R' is NOT itself a "system combo" — Win was already diverted (and forwarded
// down), so R flows through the normal viewer path and composes Win+R on the remote.
assert!(!is_system_combo(0x52, false, false)); // 'R'
assert!(!is_system_combo(0x41, false, false)); // 'A'
assert!(!is_system_combo(vk::VK_TAB, false, true)); // Ctrl+Tab is app-level, not a shell combo
}
}

View File

@@ -3,6 +3,8 @@
//! This module provides the viewer functionality for connecting to remote //! This module provides the viewer functionality for connecting to remote
//! GuruConnect sessions with low-level keyboard hooks for Win key capture. //! GuruConnect sessions with low-level keyboard hooks for Win key capture.
#[cfg(windows)]
mod decoder;
mod input; mod input;
mod render; mod render;
mod transport; mod transport;
@@ -26,9 +28,84 @@ pub enum ViewerEvent {
pub enum InputEvent { pub enum InputEvent {
Mouse(proto::MouseEvent), Mouse(proto::MouseEvent),
Key(proto::KeyEvent), Key(proto::KeyEvent),
// Not yet emitted by the viewer input path (special-key fidelity is pending).
#[allow(dead_code)]
SpecialKey(proto::SpecialKeyEvent), SpecialKey(proto::SpecialKeyEvent),
} }
/// Spawn the dedicated H.264 decode worker thread (Task 7, Windows only).
///
/// Returns a sender for `(h264_access_unit, pts_100ns)`. The worker lazily
/// creates the Media Foundation decoder on the first frame; if creation fails it
/// logs once and then silently drops subsequent frames (the raw render path is
/// never affected). Each decoded frame is converted to BGRA and delivered to the
/// viewer as an uncompressed `FrameData`, reusing the existing render path.
#[cfg(windows)]
fn spawn_h264_decode_worker(
viewer_tx: mpsc::Sender<ViewerEvent>,
) -> std::sync::mpsc::Sender<(Vec<u8>, i64)> {
let (tx, rx) = std::sync::mpsc::channel::<(Vec<u8>, i64)>();
std::thread::Builder::new()
.name("gc-h264-decode".to_string())
.spawn(move || {
let mut decoder: Option<decoder::H264Decoder> = None;
let mut init_failed = false;
while let Ok((data, pts)) = rx.recv() {
if init_failed {
continue;
}
if decoder.is_none() {
match decoder::H264Decoder::new() {
Ok(d) => {
info!("H.264 decoder initialized (Media Foundation)");
decoder = Some(d);
}
Err(e) => {
error!(
"H.264 decoder init failed: {e:#}; H.264 frames will be dropped"
);
init_failed = true;
continue;
}
}
}
let dec = decoder.as_mut().expect("decoder present after init");
match dec.decode(&data, pts) {
// One input access unit may yield zero, one, or more frames.
Ok(frames) => {
let mut viewer_closed = false;
for decoded in frames {
let frame = render::FrameData {
width: decoded.width,
height: decoded.height,
data: decoded.bgra,
compressed: false, // already BGRA
is_keyframe: false,
};
if viewer_tx.blocking_send(ViewerEvent::Frame(frame)).is_err() {
// Viewer closed; stop the worker.
viewer_closed = true;
break;
}
}
if viewer_closed {
break;
}
}
Err(e) => {
warn!("H.264 decode error: {e:#}");
}
}
}
})
.expect("failed to spawn H.264 decode worker thread");
tx
}
/// Run the viewer to connect to a remote session /// Run the viewer to connect to a remote session
pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()> { pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()> {
info!("GuruConnect Viewer starting"); info!("GuruConnect Viewer starting");
@@ -75,13 +152,23 @@ pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()
} }
}); });
// H.264 decode worker (Task 7, Windows only). The Media Foundation decoder
// wraps COM interfaces with thread affinity, so it runs on a DEDICATED OS
// thread (not a tokio task, which can migrate across workers at await
// points). The receive task forwards H.264 access units to it over a std
// channel; the worker decodes to BGRA and pushes a FrameData back through
// the viewer channel via `blocking_send`. On decoder-init failure the worker
// logs and drops H.264 frames (the raw path is unaffected).
#[cfg(windows)]
let h264_tx = spawn_h264_decode_worker(viewer_tx.clone());
// Spawn task to receive messages from server // Spawn task to receive messages from server
let viewer_tx_recv = viewer_tx.clone(); let viewer_tx_recv = viewer_tx.clone();
let receive_task = tokio::spawn(async move { let receive_task = tokio::spawn(async move {
while let Some(msg) = ws_receiver.recv().await { while let Some(msg) = ws_receiver.recv().await {
match msg.payload { match msg.payload {
Some(proto::message::Payload::VideoFrame(frame)) => { Some(proto::message::Payload::VideoFrame(frame)) => match frame.encoding {
if let Some(proto::video_frame::Encoding::Raw(raw)) = frame.encoding { Some(proto::video_frame::Encoding::Raw(raw)) => {
let frame_data = render::FrameData { let frame_data = render::FrameData {
width: raw.width as u32, width: raw.width as u32,
height: raw.height as u32, height: raw.height as u32,
@@ -91,7 +178,23 @@ pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()
}; };
let _ = viewer_tx_recv.send(ViewerEvent::Frame(frame_data)).await; let _ = viewer_tx_recv.send(ViewerEvent::Frame(frame_data)).await;
} }
} Some(proto::video_frame::Encoding::H264(enc)) => {
// Forward to the decode worker (Windows). On other
// platforms H.264 is never negotiated, so this is dead.
#[cfg(windows)]
{
if h264_tx.send((enc.data, enc.pts)).is_err() {
warn!("H.264 decode worker unavailable; dropping frame");
}
}
#[cfg(not(windows))]
{
let _ = enc;
}
}
// VP9/H265 not implemented on the viewer (raw + H.264 only).
_ => {}
},
Some(proto::message::Payload::CursorPosition(pos)) => { Some(proto::message::Payload::CursorPosition(pos)) => {
let _ = viewer_tx_recv let _ = viewer_tx_recv
.send(ViewerEvent::CursorPosition(pos.x, pos.y, pos.visible)) .send(ViewerEvent::CursorPosition(pos.x, pos.y, pos.visible))

View File

@@ -1,6 +1,5 @@
//! Window rendering and frame display //! Window rendering and frame display
#[cfg(windows)]
use super::input; use super::input;
use super::{InputEvent, ViewerEvent}; use super::{InputEvent, ViewerEvent};
use crate::proto; use crate::proto;
@@ -25,9 +24,55 @@ pub struct FrameData {
pub height: u32, pub height: u32,
pub data: Vec<u8>, pub data: Vec<u8>,
pub compressed: bool, pub compressed: bool,
// Carried through from the wire frame; the renderer does not branch on it yet.
#[allow(dead_code)]
pub is_keyframe: bool, pub is_keyframe: bool,
} }
/// Viewer-local tracker of which modifier keys are currently held down on the remote.
///
/// Mirrors what the viewer has forwarded so that on focus loss it can emit explicit
/// key-ups for anything still pressed, preventing a stuck Ctrl/Alt/Shift/Win.
#[derive(Default)]
struct ViewerModifierState {
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
}
impl ViewerModifierState {
/// Record a modifier transition for `vk_code`.
fn update(&mut self, vk_code: u32, down: bool) {
match vk_code {
0x11 | 0xA2 | 0xA3 => self.ctrl = down, // Ctrl / LCtrl / RCtrl
0x12 | 0xA4 | 0xA5 => self.alt = down, // Alt / LAlt / RAlt
0x10 | 0xA0 | 0xA1 => self.shift = down, // Shift / LShift / RShift
0x5B | 0x5C => self.meta = down, // LWin / RWin
_ => {}
}
}
/// Return the canonical VK of every held modifier, then clear all state.
fn drain_held(&mut self) -> Vec<u16> {
let mut held = Vec::new();
if self.ctrl {
held.push(0x11u16);
}
if self.alt {
held.push(0x12);
}
if self.shift {
held.push(0x10);
}
if self.meta {
held.push(0x5B);
}
*self = ViewerModifierState::default();
held
}
}
struct ViewerApp { struct ViewerApp {
window: Option<Arc<Window>>, window: Option<Arc<Window>>,
surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>, surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>,
@@ -38,6 +83,7 @@ struct ViewerApp {
input_tx: mpsc::Sender<InputEvent>, input_tx: mpsc::Sender<InputEvent>,
mouse_x: i32, mouse_x: i32,
mouse_y: i32, mouse_y: i32,
modifiers: ViewerModifierState,
#[cfg(windows)] #[cfg(windows)]
keyboard_hook: Option<input::KeyboardHook>, keyboard_hook: Option<input::KeyboardHook>,
} }
@@ -54,6 +100,7 @@ impl ViewerApp {
input_tx, input_tx,
mouse_x: 0, mouse_x: 0,
mouse_y: 0, mouse_y: 0,
modifiers: ViewerModifierState::default(),
#[cfg(windows)] #[cfg(windows)]
keyboard_hook: None, keyboard_hook: None,
} }
@@ -214,24 +261,56 @@ impl ViewerApp {
let _ = self.input_tx.try_send(InputEvent::Mouse(event)); let _ = self.input_tx.try_send(InputEvent::Mouse(event));
} }
fn send_key_event(&self, key: PhysicalKey, state: ElementState) { fn send_key_event(&mut self, key: PhysicalKey, state: ElementState) {
let vk_code = match key { let vk_code = match key {
PhysicalKey::Code(code) => keycode_to_vk(code), PhysicalKey::Code(code) => keycode_to_vk(code),
_ => return, _ => return,
}; };
if vk_code == 0 {
return;
}
let down = state == ElementState::Pressed;
// Track modifier state locally so focus loss can release anything still held.
self.modifiers.update(vk_code, down);
// The winit path has no hardware scan code; the agent derives one from the VK.
// The extended-key flag is derived from the VK so extended keys (arrows, etc.)
// still inject correctly without a captured LLKHF_EXTENDED bit.
let event = proto::KeyEvent { let event = proto::KeyEvent {
down: state == ElementState::Pressed, down,
key_type: proto::KeyEventType::KeyVk as i32, key_type: proto::KeyEventType::KeyVk as i32,
vk_code, vk_code,
scan_code: 0, scan_code: 0,
unicode: String::new(), unicode: String::new(),
is_extended: crate::input::vk_is_extended(vk_code as u16),
modifiers: Some(proto::Modifiers::default()), modifiers: Some(proto::Modifiers::default()),
}; };
let _ = self.input_tx.try_send(InputEvent::Key(event)); let _ = self.input_tx.try_send(InputEvent::Key(event));
} }
/// Release every modifier this viewer currently believes is held on the remote.
///
/// Invoked on focus loss and at window close so that a Ctrl/Alt/Shift/Win whose
/// key-up the viewer never saw (because focus left mid-press) is explicitly released
/// on the remote, preventing a "stuck modifier".
fn release_held_modifiers(&mut self) {
for vk in self.modifiers.drain_held() {
let event = proto::KeyEvent {
down: false,
key_type: proto::KeyEventType::KeyVk as i32,
vk_code: vk as u32,
scan_code: 0,
unicode: String::new(),
is_extended: crate::input::vk_is_extended(vk),
modifiers: Some(proto::Modifiers::default()),
};
let _ = self.input_tx.try_send(InputEvent::Key(event));
}
}
fn screen_to_frame_coords(&self, x: f64, y: f64) -> (i32, i32) { fn screen_to_frame_coords(&self, x: f64, y: f64) -> (i32, i32) {
let Some(window) = &self.window else { let Some(window) = &self.window else {
return (x as i32, y as i32); return (x as i32, y as i32);
@@ -316,6 +395,8 @@ impl ApplicationHandler for ViewerApp {
match event { match event {
WindowEvent::CloseRequested => { WindowEvent::CloseRequested => {
info!("Window close requested"); info!("Window close requested");
// Release any modifiers still held so the remote isn't left latched.
self.release_held_modifiers();
event_loop.exit(); event_loop.exit();
} }
WindowEvent::RedrawRequested => { WindowEvent::RedrawRequested => {
@@ -343,13 +424,39 @@ impl ApplicationHandler for ViewerApp {
}; };
self.send_mouse_wheel(dx, dy); self.send_mouse_wheel(dx, dy);
} }
WindowEvent::KeyboardInput { event, .. } => { // Focus changes drive the low-level hook's focus gate. The hook is GLOBAL
// Note: This handles keys that aren't captured by the low-level hook // (fires for all desktop input), so it must only divert system keys while the
// The hook handles Win key and other special keys // viewer is focused; we flip `set_viewer_focused` here. On blur we also release
if !event.repeat { // any held modifiers so they don't stay latched on the remote — winit's hook
self.send_key_event(event.physical_key, event.state); // pump only runs while we have focus, so this is the safety net for a modifier
// pressed-but-not-released across the blur.
WindowEvent::Focused(focused) => {
input::set_viewer_focused(focused);
if focused {
debug!("Viewer gained focus; system-key forwarding active");
} else {
debug!("Viewer lost focus; releasing held modifiers on remote");
self.release_held_modifiers();
} }
} }
// Note: This handles keys that aren't captured by the low-level hook.
// The hook handles the Windows key and other diverted system combinations.
WindowEvent::KeyboardInput { event, .. } if !event.repeat => {
// Host key: Pause/Break toggles "send system keys to remote". It is
// intercepted locally (not forwarded) so the technician can flip the
// behavior without affecting the remote. Only act on key-down.
if matches!(event.physical_key, PhysicalKey::Code(KeyCode::Pause))
&& event.state == ElementState::Pressed
{
let enabled = input::toggle_send_system_keys();
info!(
"Send-system-keys toggled {} (Pause/Break host key)",
if enabled { "ON" } else { "OFF" }
);
return;
}
self.send_key_event(event.physical_key, event.state);
}
_ => {} _ => {}
} }
} }
@@ -358,9 +465,11 @@ impl ApplicationHandler for ViewerApp {
// Keep checking for events // Keep checking for events
event_loop.set_control_flow(ControlFlow::Poll); event_loop.set_control_flow(ControlFlow::Poll);
// Process Windows messages for keyboard hook // NOTE: do NOT manually pump the Win32 message queue here. winit's own
#[cfg(windows)] // run_app loop already pumps this thread's messages (which also services
input::pump_messages(); // the low-level keyboard hook). A manual PeekMessage/DispatchMessage pump
// inside about_to_wait steals winit's messages and re-enters its window
// proc, freezing the event loop after one iteration (blank viewer).
// Request redraw periodically to check for new frames // Request redraw periodically to check for new frames
if let Some(window) = &self.window { if let Some(window) = &self.window {

View File

@@ -1,14 +1,58 @@
## [0.2.0] - 2026-05-29 ## [0.3.0] - 2026-06-01
### Added ### Added
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2) - Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fix ### Fixed
- Use Self:: for static method calls (cc35d111) - Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security ### Security
- Require authentication for all WebSocket and API endpoints (4614df04) - Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View File

@@ -1,14 +1,58 @@
## [0.2.0] - 2026-05-29 ## [0.3.0] - 2026-06-01
### Added ### Added
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2) - Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fix ### Fixed
- Use Self:: for static method calls (cc35d111) - Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security ### Security
- Require authentication for all WebSocket and API endpoints (4614df04) - Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View File

@@ -1,14 +1,58 @@
## [0.2.0] - 2026-05-29 ## [0.3.0] - 2026-06-01
### Added ### Added
- Operational tooling — signing, versioning, changelog, roadmap (SPEC-001) (60519be2) - Operator removal UI for stale machines/sessions (SPEC-004 Task 5) (96f9c0ab)
- Operator removal of stale sessions/machines (SPEC-004 Task 5, server) (5ee66753)
- Reap stale persistent sessions + same-machine supersede (SPEC-004 Task 4) (4e80573c)
- Dedup machines on machine_uid (SPEC-004 Task 2) (ffca7f0c)
- Derive + report deterministic machine_uid (SPEC-004 Task 1) (b3e8f327)
- Per-agent H.264 test override (h264-test tag) [Task 8 prep] (df51d400)
- GuruConnect v2 Users admin view (96b4fd77)
- GuruConnect v2 Support Codes view (664f33d5)
- Serve dashboard SPA with deep-link fallback; remove v1 portal (67f3722b)
- GuruConnect v2 Sessions view (pass 2) (6ecb937e)
- GuruConnect v2 operator console (pass 1) (43a9432b)
- V2 secure-session-core Task 7 - HW H.264 + negotiated raw fallback (f9bdecbf)
- V2 secure-session-core Task 6 - full key fidelity (bb73ba66)
- V2 secure-session-core Task 5 - attended consent (9082e114)
- V2 secure-session-core Task 4 - rate limit + single-use codes (bfcdbb53)
- Viewer-token view-only/control split - closes CRITICAL #1 (a453e798)
- V2 secure-session-core Task 3 - secure relay WS (0f258788)
- V2 secure-session-core Task 2 - auth rebuild (41691bfb)
- V2 secure-session-core Task 1 - schema + per-agent keys (fef8111f)
### Fix ### Fixed
- Use Self:: for static method calls (cc35d111) - Make native H.264 viewer render live frames (97780304)
- Revoke viewer tokens on logout + stop logging chat content (c98692e4)
- Close auto-update TLS bypass (MITM -> RCE) [HIGH] (8119292b)
- Apply Tasks 3-5 review fixes (non-blocking) (442eecef)
- Tolerate NULL connect_machines columns (tags decode bug) (abc55abb)
- Trusted-proxy client-IP extraction for rate-limit/audit keying (5d5cd265)
- Clippy fixes for Task 4 (CI green) (21189423)
### Security ### Security
- Require authentication for all WebSocket and API endpoints (4614df04) - Security pass re-audit (2026-05-30) — 3 CRITICALs verified CLOSED (9f448072)
### Spec
- Add SPEC-015 Configurable Notification Overlay (afbf0d81)
- Add SPEC-014 Branding and White-Label Configuration (b45c683a)
- Add SPEC-013 Windows Session Selection and Backstage Mode (5637e4c1)
- Add v2-stable-identity implementation plan (SPEC-004 breakdown) (92bc522c)
- Update SPEC-012 to include both Serial Console + PTY Shell modes (761bae5d)
- Add SPEC-012 Headless Linux Mode (Direct TTY Access) (a062a825)
- Add SPEC-011 Mobile Agent Support (iOS and Android) (b1862800)
- Add SPEC-010 Cross-Platform Agent Support (macOS and Linux) (5e232550)
- Add SPEC-009 feature-rich documented API (7ab87384)
- Add SPEC-008 valuable error messages (65eff5cf)
- Add SPEC-007 managed-agent installer builder (008d2bf3)
- Add SPEC-006 universal machine search (0eb38520)
- Add SPEC-005 machines list view (dual indicators + rich rows) (cdc182f0)
- SPEC-004 add stable machine-derived identity as the primary fix (f8bd4d1d)
- Add SPEC-004 session lifecycle reaping + operator removal (ee900c63)
- Add SPEC-003 full machine inventory in connection DB (abf499cb)
- Add v2-secure-session-core shape spec (81e4b99a)

View 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)

View 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)

View 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
View 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
View File

@@ -0,0 +1,5 @@
node_modules
dist
*.local
.vite
node_modules/.tmp

138
dashboard/README.md Normal file
View 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>`.

View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,37 @@
{ {
"name": "@guruconnect/dashboard", "name": "@guruconnect/dashboard",
"version": "0.2.0", "version": "0.3.0",
"description": "GuruConnect Remote Desktop Viewer Components", "description": "GuruConnect v2 operator dashboard",
"author": "AZ Computer Guru", "author": "AZ Computer Guru",
"license": "Proprietary", "license": "Proprietary",
"main": "src/components/index.ts", "private": true,
"types": "src/components/index.ts", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "dev": "vite",
"lint": "eslint src" "build": "tsc -b && vite build",
}, "preview": "vite preview",
"peerDependencies": { "lint": "eslint .",
"react": "^18.0.0", "typecheck": "tsc --noEmit"
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"typescript": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"fzstd": "^0.1.1" "@fontsource/hanken-grotesk": "^5.0.8",
"@fontsource/jetbrains-mono": "^5.0.18",
"@tanstack/react-query": "^5.59.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0",
"typescript": "^5.6.2",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
} }
} }

51
dashboard/src/App.tsx Normal file
View 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
View 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
View 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" }),
};

View 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`);
}

View 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";

View 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)}`,
);
}

View 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`,
);
}

View 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
View 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;
}

View 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)}`);
}

View 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 />;
}

View 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;
}

View 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>;
}

View 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 />;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
);
}

View 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>
);
}

View 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,
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;
}
}

View 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;
}

View 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;
}

View 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>
);
}

View 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);
}

View 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>
);
}

View 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;
}

View 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
}
/>
);
}

View 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(" ");
}

View 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>
);
}

View 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;
}

View 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 });
},
});
}

View 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>
}
/>
);
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import type { Machine } from "../../api/types";
import { Button } from "../../components/ui/Button";
import { Modal } from "../../components/ui/Modal";
import { useToast } from "../../components/ui/toast-context";
import { useDeleteMachine } from "./hooks";
interface DeleteMachineDialogProps {
machine: Machine | null;
onClose: () => void;
}
/**
* INTENTIONALLY UNWIRED. This legacy per-row delete dialog was superseded by
* the admin-only purge Remove (RemoveMachineDialog / BulkRemoveMachinesDialog)
* and currently has no caller. It is kept — not deleted — because it is the
* only remaining caller pattern for the `uninstall`/`export` machine-delete
* params, pending a future "full uninstall/export" admin action that will
* re-wire it. Do not treat its lack of references as a wiring bug.
*
* Destructive machine removal with two options:
* - uninstall: also command the agent to uninstall (only meaningful online)
* - export: return full history in the delete response before removal
*/
export function DeleteMachineDialog({ machine, onClose }: DeleteMachineDialogProps) {
const toast = useToast();
const del = useDeleteMachine();
const [uninstall, setUninstall] = useState(false);
const [exportHistory, setExportHistory] = useState(false);
// Reset options each time a new machine is targeted.
useEffect(() => {
if (machine) {
setUninstall(false);
setExportHistory(false);
}
}, [machine]);
function handleConfirm() {
if (!machine) return;
del.mutate(
{ agentId: machine.agent_id, params: { uninstall, export: exportHistory } },
{
onSuccess: (res) => {
if (exportHistory && res.history) {
downloadHistory(machine.hostname, res.history);
}
toast.success(
"Machine deleted",
res.uninstall_sent
? "Uninstall command sent to the agent."
: undefined,
);
onClose();
},
onError: (err) => {
toast.error(
"Could not delete machine",
err instanceof ApiError
? err.message
: "The server did not respond. The machine was not deleted.",
);
},
},
);
}
return (
<Modal
open={machine != null}
title="Delete machine"
onClose={del.isPending ? () => {} : onClose}
dismissable={!del.isPending}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={del.isPending}>
Keep machine
</Button>
<Button variant="danger" onClick={handleConfirm} loading={del.isPending}>
Delete machine
</Button>
</>
}
>
<p style={{ marginTop: 0 }}>
Permanently delete{" "}
<span className="mono">{machine?.hostname}</span> from GuruConnect,
including its registration and full history. This cannot be undone.
</p>
<label className="optline">
<input
type="checkbox"
checked={uninstall}
onChange={(e) => setUninstall(e.target.checked)}
/>
<span>
Also uninstall the agent
{machine && machine.status !== "online" && (
<em className="optline__note">
{" "}
(offline now; queued until the agent next checks in)
</em>
)}
</span>
</label>
<label className="optline">
<input
type="checkbox"
checked={exportHistory}
onChange={(e) => setExportHistory(e.target.checked)}
/>
<span>Export full history (download JSON) before removal</span>
</label>
</Modal>
);
}
function downloadHistory(hostname: string, history: unknown) {
const blob = new Blob([JSON.stringify(history, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${hostname}-history-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,72 @@
import { Button } from "../../components/ui/Button";
import { Modal } from "../../components/ui/Modal";
import { CopyIcon } from "../../components/layout/icons";
import { useClipboard } from "../../lib/useClipboard";
interface KeyRevealModalProps {
/** The plaintext `cak_...` key, or null when closed. */
plaintextKey: string | null;
onClose: () => void;
}
/**
* Copy-once key reveal. The server returns the plaintext key exactly once on
* creation; this is the only place it is ever shown. The user is warned and
* given a copy button. Closing dismisses it for good.
*/
export function KeyRevealModal({ plaintextKey, onClose }: KeyRevealModalProps) {
const { copied, copy } = useClipboard();
const open = plaintextKey != null;
return (
<Modal
open={open}
title="Agent key created"
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>Done</Button>}
>
<div className="keyreveal__warn" role="alert">
<svg
className="keyreveal__warnicon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10.3 3.7 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z" />
<path d="M12 9v4M12 17h.01" />
</svg>
<div>
<strong>Copy this key now. You will not see it again.</strong>
<span>
The key is shown only at creation and cannot be recovered. If you
lose it, revoke it and create a new one.
</span>
</div>
</div>
<div className="keyreveal__value">
<code className="keyreveal__key">{plaintextKey}</code>
<Button
variant="ghost"
size="sm"
onClick={() => plaintextKey && void copy(plaintextKey)}
aria-label={copied ? "Key copied to clipboard" : "Copy key to clipboard"}
>
<CopyIcon width={14} height={14} />
{copied ? "Copied" : "Copy"}
</Button>
</div>
<p className="keyreveal__hint">
Use this key to enroll the agent as a persistent, individually revocable
identity.
</p>
</Modal>
);
}

View File

@@ -0,0 +1,153 @@
import { ApiError } from "../../api/client";
import type { Machine } from "../../api/types";
import { Badge } from "../../components/ui/Badge";
import { Drawer } from "../../components/ui/Drawer";
import { Spinner } from "../../components/ui/Spinner";
import { EmptyState, ErrorState } from "../../components/ui/States";
import { machineTone } from "../../components/ui/status";
import { StatusDot } from "../../components/ui/StatusDot";
import { absoluteTime, formatDuration, relativeTime } from "../../lib/time";
import { useMachineHistory } from "./hooks";
interface MachineDetailDrawerProps {
machine: Machine | null;
onClose: () => void;
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<div className="mdetail__k">{label}</div>
<div className="mdetail__v">{children}</div>
</>
);
}
/**
* Read and inspect surface for a single machine: facts plus session and event
* history. A side drawer (not a modal): inspecting a machine is a lightweight,
* non-blocking read, and the list stays visible behind it for context.
*/
export function MachineDetailDrawer({ machine, onClose }: MachineDetailDrawerProps) {
const history = useMachineHistory(machine?.agent_id ?? null);
return (
<Drawer
open={machine != null}
ariaLabel={`Machine detail: ${machine?.hostname ?? ""}`}
title={
<>
{machine && (
<StatusDot tone={machineTone(machine.status)} label={machine.status} />
)}
<span className="mono">{machine?.hostname}</span>
</>
}
subtitle={machine ? `Agent ${machine.agent_id}` : undefined}
onClose={onClose}
>
{machine && (
<div className="mdetail__grid">
<Row label="Status">
<Badge tone={machineTone(machine.status)} dot>
{machine.status}
</Badge>
</Row>
<Row label="OS version">{machine.os_version ?? "Unknown"}</Row>
<Row label="Connection">
{machine.is_persistent ? (
<Badge tone="accent">Persistent</Badge>
) : (
<Badge tone="neutral">Attended</Badge>
)}{" "}
{machine.is_elevated && <Badge tone="ok">Elevated</Badge>}
</Row>
<Row label="First seen">
<span className="mono" title={absoluteTime(machine.first_seen)}>
{relativeTime(machine.first_seen)}
</span>
</Row>
<Row label="Last seen">
<span className="mono" title={absoluteTime(machine.last_seen)}>
{relativeTime(machine.last_seen)}
</span>
</Row>
</div>
)}
<div className="mdetail__section">
<h3>Session history</h3>
{history.isLoading ? (
<Spinner label="Loading history" />
) : history.isError ? (
<ErrorState
title="Could not load history"
message={
history.error instanceof ApiError
? history.error.message
: "The server did not respond. Try reopening this panel."
}
/>
) : !history.data || history.data.sessions.length === 0 ? (
<EmptyState
title="No sessions yet"
message="Support and managed sessions for this machine will be listed here."
/>
) : (
<table className="minitable">
<thead>
<tr>
<th>Started</th>
<th>Duration</th>
<th>Type</th>
<th>Code</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{history.data.sessions.map((s) => (
<tr key={s.id}>
<td className="mono" title={absoluteTime(s.started_at)}>
{relativeTime(s.started_at)}
</td>
<td className="mono">{formatDuration(s.duration_secs)}</td>
<td>{s.is_support_session ? "Support" : "Managed"}</td>
<td className="mono">{s.support_code ?? "None"}</td>
<td>{s.status}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{history.data && history.data.events.length > 0 && (
<div className="mdetail__section">
<h3>Recent events</h3>
<table className="minitable">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Viewer</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{history.data.events.slice(0, 25).map((e) => (
<tr key={e.id}>
<td className="mono" title={absoluteTime(e.timestamp)}>
{relativeTime(e.timestamp)}
</td>
<td>{e.event_type}</td>
<td>{e.viewer_name ?? "None"}</td>
<td className="mono">{e.ip_address ?? "None"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,197 @@
import { useState } from "react";
import { ApiError } from "../../api/client";
import type { KeyMetadata, Machine } from "../../api/types";
import { Button } from "../../components/ui/Button";
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
import { Modal } from "../../components/ui/Modal";
import { Badge } from "../../components/ui/Badge";
import { Spinner } from "../../components/ui/Spinner";
import { EmptyState, ErrorState } from "../../components/ui/States";
import { useToast } from "../../components/ui/toast-context";
import { absoluteTime, relativeTime } from "../../lib/time";
import {
useCreateMachineKey,
useMachineKeys,
useRevokeMachineKey,
} from "./hooks";
import { KeyRevealModal } from "./KeyRevealModal";
interface MachineKeysModalProps {
machine: Machine | null;
onClose: () => void;
}
function keyState(k: KeyMetadata): { tone: "ok" | "neutral"; label: string } {
return k.revoked_at
? { tone: "neutral", label: "Revoked" }
: { tone: "ok", label: "Active" };
}
/**
* Admin-only per-agent key management. Lists key metadata (never the secret),
* mints new keys (revealed once via KeyRevealModal), and revokes existing keys.
*/
export function MachineKeysModal({ machine, onClose }: MachineKeysModalProps) {
const toast = useToast();
const agentId = machine?.agent_id ?? "";
const keysQuery = useMachineKeys(machine?.agent_id ?? null, machine != null);
const createKey = useCreateMachineKey(agentId);
const revokeKey = useRevokeMachineKey(agentId);
const [revealKey, setRevealKey] = useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<KeyMetadata | null>(null);
function handleCreate() {
createKey.mutate(undefined, {
onSuccess: (created) => {
setRevealKey(created.key);
toast.success("Key created", "Copy it now — it is shown only once.");
},
onError: (err) => {
toast.error(
"Could not create key",
err instanceof ApiError ? err.message : "Unexpected error.",
);
},
});
}
function handleRevoke() {
if (!pendingRevoke) return;
const id = pendingRevoke.id;
revokeKey.mutate(id, {
onSuccess: () => {
toast.success("Key revoked");
setPendingRevoke(null);
},
onError: (err) => {
toast.error(
"Could not revoke key",
err instanceof ApiError ? err.message : "Unexpected error.",
);
setPendingRevoke(null);
},
});
}
const keys = keysQuery.data ?? [];
return (
<>
<Modal
open={machine != null && revealKey == null}
title={
<>
Agent keys ·{" "}
<span className="mono" style={{ fontWeight: 500 }}>
{machine?.hostname}
</span>
</>
}
ariaLabel={`Agent keys for ${machine?.hostname ?? "machine"}`}
onClose={onClose}
wide
footer={
<>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
<Button
variant="primary"
onClick={handleCreate}
loading={createKey.isPending}
>
Create key
</Button>
</>
}
>
{keysQuery.isLoading ? (
<div style={{ padding: "24px 0", display: "grid", placeItems: "center" }}>
<Spinner label="Loading keys" />
</div>
) : keysQuery.isError ? (
<ErrorState
title="Failed to load keys"
message={
keysQuery.error instanceof ApiError
? keysQuery.error.message
: "Unexpected error."
}
/>
) : keys.length === 0 ? (
<EmptyState
title="No keys issued"
message="This machine has no per-agent keys. Create one to enroll it as a persistent identity."
/>
) : (
<table className="minitable">
<thead>
<tr>
<th>State</th>
<th>Key ID</th>
<th>Created</th>
<th>Last used</th>
<th />
</tr>
</thead>
<tbody>
{keys.map((k) => {
const s = keyState(k);
return (
<tr key={k.id} className={k.revoked_at ? "key--revoked" : undefined}>
<td>
<Badge tone={s.tone} dot>
{s.label}
</Badge>
</td>
<td className="mono" title={k.id}>
{k.id}
</td>
<td className="mono" title={absoluteTime(k.created_at)}>
{relativeTime(k.created_at)}
</td>
<td className="mono" title={absoluteTime(k.last_used_at)}>
{k.last_used_at ? relativeTime(k.last_used_at) : "never"}
</td>
<td style={{ textAlign: "right" }}>
{!k.revoked_at && (
<Button
variant="danger"
size="sm"
onClick={() => setPendingRevoke(k)}
>
Revoke
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</Modal>
<KeyRevealModal plaintextKey={revealKey} onClose={() => setRevealKey(null)} />
<ConfirmDialog
open={pendingRevoke != null}
title="Revoke agent key"
danger
busy={revokeKey.isPending}
confirmLabel="Revoke key"
body={
<span>
Revoking this key immediately blocks any agent authenticating with
it. This cannot be undone. Key{" "}
<code className="mono">{pendingRevoke?.id}</code>.
</span>
}
onConfirm={handleRevoke}
onCancel={() => setPendingRevoke(null)}
/>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More