From 33893ea73b63afe48eb7aa3fbf29b324553bca4a Mon Sep 17 00:00:00 2001 From: AZ Computer Guru Date: Sun, 21 Dec 2025 17:18:05 -0700 Subject: [PATCH] Initial GuruConnect implementation - Phase 1 MVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent: DXGI/GDI screen capture, mouse/keyboard input, WebSocket transport - Server: Axum relay, session management, REST API - Dashboard: React viewer components with TypeScript - Protocol: Protobuf definitions for all message types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 28 + CLAUDE.md | 117 + Cargo.lock | 3206 ++++++++++++++++++ Cargo.toml | 27 + agent/Cargo.toml | 78 + agent/build.rs | 11 + agent/src/capture/display.rs | 156 + agent/src/capture/dxgi.rs | 325 ++ agent/src/capture/gdi.rs | 150 + agent/src/capture/mod.rs | 102 + agent/src/config.rs | 199 ++ agent/src/encoder/mod.rs | 52 + agent/src/encoder/raw.rs | 232 ++ agent/src/input/keyboard.rs | 287 ++ agent/src/input/mod.rs | 91 + agent/src/input/mouse.rs | 217 ++ agent/src/main.rs | 70 + agent/src/session/mod.rs | 194 ++ agent/src/transport/mod.rs | 5 + agent/src/transport/websocket.rs | 183 + dashboard/package.json | 25 + dashboard/src/components/RemoteViewer.tsx | 215 ++ dashboard/src/components/SessionControls.tsx | 187 + dashboard/src/components/index.ts | 22 + dashboard/src/hooks/useRemoteSession.ts | 239 ++ dashboard/src/lib/protobuf.ts | 162 + dashboard/src/types/protocol.ts | 135 + dashboard/tsconfig.json | 21 + proto/guruconnect.proto | 286 ++ server/Cargo.toml | 62 + server/build.rs | 11 + server/src/api/mod.rs | 54 + server/src/auth/mod.rs | 61 + server/src/config.rs | 45 + server/src/db/mod.rs | 45 + server/src/main.rs | 82 + server/src/relay/mod.rs | 194 ++ server/src/session/mod.rs | 148 + 38 files changed, 7724 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 agent/Cargo.toml create mode 100644 agent/build.rs create mode 100644 agent/src/capture/display.rs create mode 100644 agent/src/capture/dxgi.rs create mode 100644 agent/src/capture/gdi.rs create mode 100644 agent/src/capture/mod.rs create mode 100644 agent/src/config.rs create mode 100644 agent/src/encoder/mod.rs create mode 100644 agent/src/encoder/raw.rs create mode 100644 agent/src/input/keyboard.rs create mode 100644 agent/src/input/mod.rs create mode 100644 agent/src/input/mouse.rs create mode 100644 agent/src/main.rs create mode 100644 agent/src/session/mod.rs create mode 100644 agent/src/transport/mod.rs create mode 100644 agent/src/transport/websocket.rs create mode 100644 dashboard/package.json create mode 100644 dashboard/src/components/RemoteViewer.tsx create mode 100644 dashboard/src/components/SessionControls.tsx create mode 100644 dashboard/src/components/index.ts create mode 100644 dashboard/src/hooks/useRemoteSession.ts create mode 100644 dashboard/src/lib/protobuf.ts create mode 100644 dashboard/src/types/protocol.ts create mode 100644 dashboard/tsconfig.json create mode 100644 proto/guruconnect.proto create mode 100644 server/Cargo.toml create mode 100644 server/build.rs create mode 100644 server/src/api/mod.rs create mode 100644 server/src/auth/mod.rs create mode 100644 server/src/config.rs create mode 100644 server/src/db/mod.rs create mode 100644 server/src/main.rs create mode 100644 server/src/relay/mod.rs create mode 100644 server/src/session/mod.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba5db9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Build artifacts +/target/ +**/target/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +*.env + +# Logs +*.log +logs/ + +# Dependencies (if vendored) +vendor/ + +# Generated files +*.generated.* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..74a8d6e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# GuruConnect + +Remote desktop solution similar to ScreenConnect, integrated with GuruRMM. + +## Project Overview + +GuruConnect provides remote screen control and backstage tools for Windows systems. +It's designed to be fast, secure, and enterprise-ready. + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Dashboard │◄───────►│ GuruConnect │◄───────►│ GuruConnect │ +│ (React) │ WSS │ Server (Rust) │ WSS │ Agent (Rust) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## Directory Structure + +- `agent/` - Windows remote desktop agent (Rust) +- `server/` - Relay server (Rust + Axum) +- `dashboard/` - Web viewer (React, to be integrated with GuruRMM) +- `proto/` - Protobuf protocol definitions + +## Building + +### Prerequisites + +- Rust 1.75+ (install via rustup) +- Windows SDK (for agent) +- protoc (Protocol Buffers compiler) + +### Build Commands + +```bash +# Build all (from workspace root) +cargo build --release + +# Build agent only +cargo build -p guruconnect-agent --release + +# Build server only +cargo build -p guruconnect-server --release +``` + +### Cross-compilation (Agent for Windows) + +From Linux build server: +```bash +# Install Windows target +rustup target add x86_64-pc-windows-msvc + +# Build (requires cross or appropriate linker) +cross build -p guruconnect-agent --target x86_64-pc-windows-msvc --release +``` + +## Development + +### Running the Server + +```bash +# Development +cargo run -p guruconnect-server + +# With environment variables +DATABASE_URL=postgres://... JWT_SECRET=... cargo run -p guruconnect-server +``` + +### Testing the Agent + +The agent must be run on Windows: +```powershell +# Run from Windows +.\target\release\guruconnect-agent.exe +``` + +## Protocol + +Uses Protocol Buffers for efficient message serialization. +See `proto/guruconnect.proto` for message definitions. + +Key message types: +- `VideoFrame` - Screen frames (raw+zstd, VP9, H264) +- `MouseEvent` - Mouse input +- `KeyEvent` - Keyboard input +- `SessionRequest/Response` - Session management + +## Encoding Strategy + +| Scenario | Encoding | +|----------|----------| +| LAN (<20ms RTT) | Raw BGRA + Zstd + dirty rects | +| WAN + GPU | H264 hardware | +| WAN - GPU | VP9 software | + +## Key References + +- RustDesk source: `~/claude-projects/reference/rustdesk/` +- GuruRMM: `~/claude-projects/gururmm/` +- Plan: `~/.claude/plans/shimmering-wandering-crane.md` + +## Phase 1 MVP Goals + +1. DXGI screen capture with GDI fallback +2. Raw + Zstd encoding with dirty rectangle detection +3. Mouse and keyboard input injection +4. WebSocket relay through server +5. Basic React viewer + +## Security Considerations + +- All connections use TLS +- JWT authentication for dashboard users +- API key authentication for agents +- Session audit logging +- Optional session recording (Phase 4) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0096198 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3206 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "async-compression" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compression-codecs" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "guruconnect-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "chrono", + "futures-util", + "hostname", + "prost", + "prost-build", + "prost-types", + "ring", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "toml", + "tracing", + "tracing-subscriber", + "uuid", + "windows", + "windows-service", + "zstd", +] + +[[package]] +name = "guruconnect-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "bytes", + "chrono", + "futures-util", + "jsonwebtoken", + "prost", + "prost-build", + "prost-types", + "ring", + "serde", + "serde_json", + "sqlx", + "thiserror 1.0.69", + "tokio", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.6.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.17", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.17", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9a5462e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[workspace] +resolver = "2" +members = [ + "agent", + "server", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["AZ Computer Guru"] +license = "Proprietary" + +[workspace.dependencies] +# Shared dependencies across workspace +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +prost = "0.13" +prost-types = "0.13" +bytes = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +anyhow = "1" +thiserror = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/agent/Cargo.toml b/agent/Cargo.toml new file mode 100644 index 0000000..eb05149 --- /dev/null +++ b/agent/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "guruconnect-agent" +version = "0.1.0" +edition = "2021" +authors = ["AZ Computer Guru"] +description = "GuruConnect Remote Desktop Agent" + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread", "macros"] } + +# WebSocket +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +futures-util = "0.3" + +# Compression +zstd = "0.13" + +# Protocol (protobuf) +prost = "0.13" +prost-types = "0.13" +bytes = "1" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Error handling +anyhow = "1" +thiserror = "1" + +# Configuration +toml = "0.8" + +# Crypto +ring = "0.17" + +# UUID +uuid = { version = "1", features = ["v4", "serde"] } + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Hostname +hostname = "0.4" + +[target.'cfg(windows)'.dependencies] +# Windows APIs for screen capture and input +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", + "Win32_System_LibraryLoader", + "Win32_System_Threading", + "Win32_Security", +]} + +# Windows service support +windows-service = "0.7" + +[build-dependencies] +prost-build = "0.13" + +[profile.release] +lto = true +codegen-units = 1 +opt-level = "z" +strip = true +panic = "abort" diff --git a/agent/build.rs b/agent/build.rs new file mode 100644 index 0000000..d1606dc --- /dev/null +++ b/agent/build.rs @@ -0,0 +1,11 @@ +use std::io::Result; + +fn main() -> Result<()> { + // Compile protobuf definitions + prost_build::compile_protos(&["../proto/guruconnect.proto"], &["../proto/"])?; + + // Rerun if proto changes + println!("cargo:rerun-if-changed=../proto/guruconnect.proto"); + + Ok(()) +} diff --git a/agent/src/capture/display.rs b/agent/src/capture/display.rs new file mode 100644 index 0000000..048b95e --- /dev/null +++ b/agent/src/capture/display.rs @@ -0,0 +1,156 @@ +//! Display enumeration and information + +use anyhow::Result; + +/// Information about a display/monitor +#[derive(Debug, Clone)] +pub struct Display { + /// Unique display ID + pub id: u32, + + /// Display name (e.g., "\\\\.\\DISPLAY1") + pub name: String, + + /// X position in virtual screen coordinates + pub x: i32, + + /// Y position in virtual screen coordinates + pub y: i32, + + /// Width in pixels + pub width: u32, + + /// Height in pixels + pub height: u32, + + /// Whether this is the primary display + pub is_primary: bool, + + /// Platform-specific handle (HMONITOR on Windows) + #[cfg(windows)] + pub handle: isize, +} + +/// Display info for protocol messages +#[derive(Debug, Clone)] +pub struct DisplayInfo { + pub displays: Vec, + pub primary_id: u32, +} + +impl Display { + /// Total pixels in the display + pub fn pixel_count(&self) -> u32 { + self.width * self.height + } + + /// Bytes needed for BGRA frame buffer + pub fn buffer_size(&self) -> usize { + (self.width * self.height * 4) as usize + } +} + +/// Enumerate all connected displays +#[cfg(windows)] +pub fn enumerate_displays() -> Result> { + use windows::Win32::Graphics::Gdi::{ + EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITORINFOEXW, + }; + use windows::Win32::Foundation::{BOOL, LPARAM, RECT}; + use std::mem; + + let mut displays = Vec::new(); + let mut display_id = 0u32; + + // Callback for EnumDisplayMonitors + unsafe extern "system" fn enum_callback( + hmonitor: HMONITOR, + _hdc: windows::Win32::Graphics::Gdi::HDC, + _rect: *mut RECT, + lparam: LPARAM, + ) -> BOOL { + let displays = &mut *(lparam.0 as *mut Vec<(HMONITOR, u32)>); + let id = displays.len() as u32; + displays.push((hmonitor, id)); + BOOL(1) // Continue enumeration + } + + // Collect all monitor handles + let mut monitors: Vec<(windows::Win32::Graphics::Gdi::HMONITOR, u32)> = Vec::new(); + unsafe { + EnumDisplayMonitors( + None, + None, + Some(enum_callback), + LPARAM(&mut monitors as *mut _ as isize), + )?; + } + + // Get detailed info for each monitor + for (hmonitor, id) in monitors { + let mut info: MONITORINFOEXW = unsafe { mem::zeroed() }; + info.monitorInfo.cbSize = mem::size_of::() as u32; + + unsafe { + if GetMonitorInfoW(hmonitor, &mut info.monitorInfo as *mut _ as *mut _).as_bool() { + let rect = info.monitorInfo.rcMonitor; + let name = String::from_utf16_lossy( + &info.szDevice[..info.szDevice.iter().position(|&c| c == 0).unwrap_or(info.szDevice.len())] + ); + + let is_primary = (info.monitorInfo.dwFlags & 1) != 0; // MONITORINFOF_PRIMARY + + displays.push(Display { + id, + name, + x: rect.left, + y: rect.top, + width: (rect.right - rect.left) as u32, + height: (rect.bottom - rect.top) as u32, + is_primary, + handle: hmonitor.0 as isize, + }); + } + } + } + + // Sort by position (left to right, top to bottom) + displays.sort_by(|a, b| { + if a.y != b.y { + a.y.cmp(&b.y) + } else { + a.x.cmp(&b.x) + } + }); + + // Reassign IDs after sorting + for (i, display) in displays.iter_mut().enumerate() { + display.id = i as u32; + } + + if displays.is_empty() { + anyhow::bail!("No displays found"); + } + + Ok(displays) +} + +#[cfg(not(windows))] +pub fn enumerate_displays() -> Result> { + anyhow::bail!("Display enumeration only supported on Windows") +} + +/// Get display info for protocol +pub fn get_display_info() -> Result { + let displays = enumerate_displays()?; + let primary_id = displays + .iter() + .find(|d| d.is_primary) + .map(|d| d.id) + .unwrap_or(0); + + Ok(DisplayInfo { + displays, + primary_id, + }) +} diff --git a/agent/src/capture/dxgi.rs b/agent/src/capture/dxgi.rs new file mode 100644 index 0000000..18dedc6 --- /dev/null +++ b/agent/src/capture/dxgi.rs @@ -0,0 +1,325 @@ +//! DXGI Desktop Duplication screen capture +//! +//! Uses the Windows Desktop Duplication API (available on Windows 8+) for +//! high-performance, low-latency screen capture with hardware acceleration. +//! +//! Reference: RustDesk's scrap library implementation + +use super::{CapturedFrame, Capturer, DirtyRect, Display}; +use anyhow::{Context, Result}; +use std::ptr; +use std::time::Instant; + +use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN; +use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, + D3D11_CPU_ACCESS_READ, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, + D3D11_USAGE_STAGING, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, +}; +use windows::Win32::Graphics::Dxgi::{ + CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIOutput, IDXGIOutput1, + IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST, + DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO, + DXGI_RESOURCE_PRIORITY_MAXIMUM, +}; +use windows::core::Interface; + +/// DXGI Desktop Duplication capturer +pub struct DxgiCapturer { + display: Display, + device: ID3D11Device, + context: ID3D11DeviceContext, + duplication: IDXGIOutputDuplication, + staging_texture: Option, + width: u32, + height: u32, + last_frame: Option>, +} + +impl DxgiCapturer { + /// Create a new DXGI capturer for the specified display + pub fn new(display: Display) -> Result { + let (device, context, duplication, desc) = Self::create_duplication(&display)?; + + Ok(Self { + display, + device, + context, + duplication, + staging_texture: None, + width: desc.ModeDesc.Width, + height: desc.ModeDesc.Height, + last_frame: None, + }) + } + + /// Create D3D device and output duplication + fn create_duplication( + display: &Display, + ) -> Result<(ID3D11Device, ID3D11DeviceContext, IDXGIOutputDuplication, DXGI_OUTDUPL_DESC)> { + unsafe { + // Create DXGI factory + let factory: IDXGIFactory1 = CreateDXGIFactory1() + .context("Failed to create DXGI factory")?; + + // Find the adapter and output for this display + let (adapter, output) = Self::find_adapter_output(&factory, display)?; + + // Create D3D11 device + let mut device: Option = None; + let mut context: Option = None; + + D3D11CreateDevice( + &adapter, + D3D_DRIVER_TYPE_UNKNOWN, + None, + Default::default(), + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + .context("Failed to create D3D11 device")?; + + let device = device.context("D3D11 device is None")?; + let context = context.context("D3D11 context is None")?; + + // Get IDXGIOutput1 interface + let output1: IDXGIOutput1 = output.cast() + .context("Failed to get IDXGIOutput1 interface")?; + + // Create output duplication + let duplication = output1.DuplicateOutput(&device) + .context("Failed to create output duplication")?; + + // Get duplication description + let mut desc = DXGI_OUTDUPL_DESC::default(); + duplication.GetDesc(&mut desc); + + tracing::info!( + "Created DXGI duplication: {}x{}, display: {}", + desc.ModeDesc.Width, + desc.ModeDesc.Height, + display.name + ); + + Ok((device, context, duplication, desc)) + } + } + + /// Find the adapter and output for the specified display + fn find_adapter_output( + factory: &IDXGIFactory1, + display: &Display, + ) -> Result<(IDXGIAdapter1, IDXGIOutput)> { + unsafe { + let mut adapter_idx = 0u32; + + loop { + // Enumerate adapters + let adapter: IDXGIAdapter1 = match factory.EnumAdapters1(adapter_idx) { + Ok(a) => a, + Err(_) => break, + }; + + let mut output_idx = 0u32; + + loop { + // Enumerate outputs for this adapter + let output: IDXGIOutput = match adapter.EnumOutputs(output_idx) { + Ok(o) => o, + Err(_) => break, + }; + + // Check if this is the display we want + let mut desc = Default::default(); + output.GetDesc(&mut desc)?; + + let name = String::from_utf16_lossy( + &desc.DeviceName[..desc.DeviceName.iter().position(|&c| c == 0).unwrap_or(desc.DeviceName.len())] + ); + + if name == display.name || desc.Monitor.0 as isize == display.handle { + return Ok((adapter, output)); + } + + output_idx += 1; + } + + adapter_idx += 1; + } + + // If we didn't find the specific display, use the first one + let adapter: IDXGIAdapter1 = factory.EnumAdapters1(0) + .context("No adapters found")?; + let output: IDXGIOutput = adapter.EnumOutputs(0) + .context("No outputs found")?; + + Ok((adapter, output)) + } + } + + /// Create or get the staging texture for CPU access + fn get_staging_texture(&mut self, src_texture: &ID3D11Texture2D) -> Result<&ID3D11Texture2D> { + if self.staging_texture.is_none() { + unsafe { + let mut desc = D3D11_TEXTURE2D_DESC::default(); + src_texture.GetDesc(&mut desc); + + desc.Usage = D3D11_USAGE_STAGING; + desc.BindFlags = Default::default(); + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + desc.MiscFlags = Default::default(); + + let staging = self.device.CreateTexture2D(&desc, None) + .context("Failed to create staging texture")?; + + // Set high priority + let resource: IDXGIResource = staging.cast()?; + resource.SetEvictionPriority(DXGI_RESOURCE_PRIORITY_MAXIMUM.0)?; + + self.staging_texture = Some(staging); + } + } + + Ok(self.staging_texture.as_ref().unwrap()) + } + + /// Acquire the next frame from the desktop + fn acquire_frame(&mut self, timeout_ms: u32) -> Result> { + unsafe { + let mut frame_info = DXGI_OUTDUPL_FRAME_INFO::default(); + let mut desktop_resource: Option = None; + + let result = self.duplication.AcquireNextFrame( + timeout_ms, + &mut frame_info, + &mut desktop_resource, + ); + + match result { + Ok(_) => { + let resource = desktop_resource.context("Desktop resource is None")?; + + // Check if there's actually a new frame + if frame_info.LastPresentTime == 0 { + self.duplication.ReleaseFrame().ok(); + return Ok(None); + } + + let texture: ID3D11Texture2D = resource.cast() + .context("Failed to cast to ID3D11Texture2D")?; + + Ok(Some((texture, frame_info))) + } + Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => { + // No new frame available + Ok(None) + } + Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST => { + // Desktop duplication was invalidated, need to recreate + tracing::warn!("Desktop duplication access lost, will need to recreate"); + Err(anyhow::anyhow!("Access lost")) + } + Err(e) => { + Err(e).context("Failed to acquire frame") + } + } + } + } + + /// Copy frame data to CPU-accessible memory + fn copy_frame_data(&mut self, texture: &ID3D11Texture2D) -> Result> { + unsafe { + // Get or create staging texture + let staging = self.get_staging_texture(texture)?.clone(); + + // Copy from GPU texture to staging texture + self.context.CopyResource(&staging, texture); + + // Map the staging texture for CPU read + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + self.context + .Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped)) + .context("Failed to map staging texture")?; + + // Copy pixel data + let src_pitch = mapped.RowPitch as usize; + let dst_pitch = (self.width * 4) as usize; + let height = self.height as usize; + + let mut data = vec![0u8; dst_pitch * height]; + + let src_ptr = mapped.pData as *const u8; + for y in 0..height { + let src_row = src_ptr.add(y * src_pitch); + let dst_row = data.as_mut_ptr().add(y * dst_pitch); + ptr::copy_nonoverlapping(src_row, dst_row, dst_pitch); + } + + // Unmap + self.context.Unmap(&staging, 0); + + Ok(data) + } + } + + /// Extract dirty rectangles from frame info + fn extract_dirty_rects(&self, _frame_info: &DXGI_OUTDUPL_FRAME_INFO) -> Option> { + // TODO: Implement dirty rectangle extraction using + // IDXGIOutputDuplication::GetFrameDirtyRects and GetFrameMoveRects + // For now, return None to indicate full frame update + None + } +} + +impl Capturer for DxgiCapturer { + fn capture(&mut self) -> Result> { + // Try to acquire a frame with 100ms timeout + let frame_result = self.acquire_frame(100)?; + + let (texture, frame_info) = match frame_result { + Some((t, f)) => (t, f), + None => return Ok(None), // No new frame + }; + + // Copy frame data to CPU memory + let data = self.copy_frame_data(&texture)?; + + // Release the frame + unsafe { + self.duplication.ReleaseFrame().ok(); + } + + // Extract dirty rectangles if available + let dirty_rects = self.extract_dirty_rects(&frame_info); + + Ok(Some(CapturedFrame { + width: self.width, + height: self.height, + data, + timestamp: Instant::now(), + display_id: self.display.id, + dirty_rects, + })) + } + + fn display(&self) -> &Display { + &self.display + } + + fn is_valid(&self) -> bool { + // Could check if duplication is still valid + true + } +} + +impl Drop for DxgiCapturer { + fn drop(&mut self) { + // Release any held frame + unsafe { + self.duplication.ReleaseFrame().ok(); + } + } +} diff --git a/agent/src/capture/gdi.rs b/agent/src/capture/gdi.rs new file mode 100644 index 0000000..0e85225 --- /dev/null +++ b/agent/src/capture/gdi.rs @@ -0,0 +1,150 @@ +//! GDI screen capture fallback +//! +//! Uses Windows GDI (Graphics Device Interface) for screen capture. +//! Slower than DXGI but works on older systems and edge cases. + +use super::{CapturedFrame, Capturer, Display}; +use anyhow::{Context, Result}; +use std::time::Instant; + +use windows::Win32::Graphics::Gdi::{ + BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, + GetDIBits, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + SRCCOPY, GetDC, ReleaseDC, +}; +use windows::Win32::Foundation::HWND; + +/// GDI-based screen capturer +pub struct GdiCapturer { + display: Display, + width: u32, + height: u32, +} + +impl GdiCapturer { + /// Create a new GDI capturer for the specified display + pub fn new(display: Display) -> Result { + Ok(Self { + width: display.width, + height: display.height, + display, + }) + } + + /// Capture the screen using GDI + fn capture_gdi(&self) -> Result> { + unsafe { + // Get device context for the entire screen + let screen_dc = GetDC(HWND::default()); + if screen_dc.is_invalid() { + anyhow::bail!("Failed to get screen DC"); + } + + // Create compatible DC and bitmap + let mem_dc = CreateCompatibleDC(screen_dc); + if mem_dc.is_invalid() { + ReleaseDC(HWND::default(), screen_dc); + anyhow::bail!("Failed to create compatible DC"); + } + + let bitmap = CreateCompatibleBitmap(screen_dc, self.width as i32, self.height as i32); + if bitmap.is_invalid() { + DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + anyhow::bail!("Failed to create compatible bitmap"); + } + + // Select bitmap into memory DC + let old_bitmap = SelectObject(mem_dc, bitmap); + + // Copy screen to memory DC + let result = BitBlt( + mem_dc, + 0, + 0, + self.width as i32, + self.height as i32, + screen_dc, + self.display.x, + self.display.y, + SRCCOPY, + ); + + if !result.as_bool() { + SelectObject(mem_dc, old_bitmap); + DeleteObject(bitmap); + DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + anyhow::bail!("BitBlt failed"); + } + + // Prepare bitmap info for GetDIBits + let mut bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: self.width as i32, + biHeight: -(self.height as i32), // Negative for top-down + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + biSizeImage: 0, + biXPelsPerMeter: 0, + biYPelsPerMeter: 0, + biClrUsed: 0, + biClrImportant: 0, + }, + bmiColors: [Default::default()], + }; + + // Allocate buffer for pixel data + let buffer_size = (self.width * self.height * 4) as usize; + let mut data = vec![0u8; buffer_size]; + + // Get the bits + let lines = GetDIBits( + mem_dc, + bitmap, + 0, + self.height, + Some(data.as_mut_ptr() as *mut _), + &mut bmi, + DIB_RGB_COLORS, + ); + + // Cleanup + SelectObject(mem_dc, old_bitmap); + DeleteObject(bitmap); + DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + + if lines == 0 { + anyhow::bail!("GetDIBits failed"); + } + + Ok(data) + } + } +} + +impl Capturer for GdiCapturer { + fn capture(&mut self) -> Result> { + let data = self.capture_gdi()?; + + Ok(Some(CapturedFrame { + width: self.width, + height: self.height, + data, + timestamp: Instant::now(), + display_id: self.display.id, + dirty_rects: None, // GDI doesn't provide dirty rects + })) + } + + fn display(&self) -> &Display { + &self.display + } + + fn is_valid(&self) -> bool { + true + } +} diff --git a/agent/src/capture/mod.rs b/agent/src/capture/mod.rs new file mode 100644 index 0000000..407bc19 --- /dev/null +++ b/agent/src/capture/mod.rs @@ -0,0 +1,102 @@ +//! Screen capture module +//! +//! Provides DXGI Desktop Duplication for high-performance screen capture on Windows 8+, +//! with GDI fallback for legacy systems or edge cases. + +#[cfg(windows)] +mod dxgi; +#[cfg(windows)] +mod gdi; +mod display; + +pub use display::{Display, DisplayInfo}; + +use anyhow::Result; +use std::time::Instant; + +/// Captured frame data +#[derive(Debug)] +pub struct CapturedFrame { + /// Frame width in pixels + pub width: u32, + + /// Frame height in pixels + pub height: u32, + + /// Raw BGRA pixel data (4 bytes per pixel) + pub data: Vec, + + /// Timestamp when frame was captured + pub timestamp: Instant, + + /// Display ID this frame is from + pub display_id: u32, + + /// Regions that changed since last frame (if available) + pub dirty_rects: Option>, +} + +/// Rectangular region that changed +#[derive(Debug, Clone, Copy)] +pub struct DirtyRect { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +/// Screen capturer trait +pub trait Capturer: Send { + /// Capture the next frame + /// + /// Returns None if no new frame is available (screen unchanged) + fn capture(&mut self) -> Result>; + + /// Get the current display info + fn display(&self) -> &Display; + + /// Check if capturer is still valid (display may have changed) + fn is_valid(&self) -> bool; +} + +/// Create a capturer for the specified display +#[cfg(windows)] +pub fn create_capturer(display: Display, use_dxgi: bool, gdi_fallback: bool) -> Result> { + if use_dxgi { + match dxgi::DxgiCapturer::new(display.clone()) { + Ok(capturer) => { + tracing::info!("Using DXGI Desktop Duplication for capture"); + return Ok(Box::new(capturer)); + } + Err(e) => { + tracing::warn!("DXGI capture failed: {}, trying fallback", e); + if !gdi_fallback { + return Err(e); + } + } + } + } + + // GDI fallback + tracing::info!("Using GDI for capture"); + Ok(Box::new(gdi::GdiCapturer::new(display)?)) +} + +#[cfg(not(windows))] +pub fn create_capturer(_display: Display, _use_dxgi: bool, _gdi_fallback: bool) -> Result> { + anyhow::bail!("Screen capture only supported on Windows") +} + +/// Get all available displays +pub fn enumerate_displays() -> Result> { + display::enumerate_displays() +} + +/// Get the primary display +pub fn primary_display() -> Result { + let displays = enumerate_displays()?; + displays + .into_iter() + .find(|d| d.is_primary) + .ok_or_else(|| anyhow::anyhow!("No primary display found")) +} diff --git a/agent/src/config.rs b/agent/src/config.rs new file mode 100644 index 0000000..d8067f9 --- /dev/null +++ b/agent/src/config.rs @@ -0,0 +1,199 @@ +//! Agent configuration management + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Agent configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Server WebSocket URL (e.g., wss://connect.example.com/ws) + pub server_url: String, + + /// Agent API key for authentication + pub api_key: String, + + /// Optional hostname override + pub hostname_override: Option, + + /// Capture settings + #[serde(default)] + pub capture: CaptureConfig, + + /// Encoding settings + #[serde(default)] + pub encoding: EncodingConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureConfig { + /// Target frames per second (1-60) + #[serde(default = "default_fps")] + pub fps: u32, + + /// Use DXGI Desktop Duplication (recommended) + #[serde(default = "default_true")] + pub use_dxgi: bool, + + /// Fall back to GDI if DXGI fails + #[serde(default = "default_true")] + pub gdi_fallback: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncodingConfig { + /// Preferred codec (auto, raw, vp9, h264) + #[serde(default = "default_codec")] + pub codec: String, + + /// Quality (1-100, higher = better quality, more bandwidth) + #[serde(default = "default_quality")] + pub quality: u32, + + /// Use hardware encoding if available + #[serde(default = "default_true")] + pub hardware_encoding: bool, +} + +fn default_fps() -> u32 { + 30 +} + +fn default_true() -> bool { + true +} + +fn default_codec() -> String { + "auto".to_string() +} + +fn default_quality() -> u32 { + 75 +} + +impl Default for CaptureConfig { + fn default() -> Self { + Self { + fps: default_fps(), + use_dxgi: true, + gdi_fallback: true, + } + } +} + +impl Default for EncodingConfig { + fn default() -> Self { + Self { + codec: default_codec(), + quality: default_quality(), + hardware_encoding: true, + } + } +} + +impl Config { + /// Load configuration from file or environment + pub fn load() -> Result { + // Try loading from config file + let config_path = Self::config_path(); + + if config_path.exists() { + let contents = std::fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read config from {:?}", config_path))?; + + let config: Config = toml::from_str(&contents) + .with_context(|| "Failed to parse config file")?; + + return Ok(config); + } + + // Fall back to environment variables + let server_url = std::env::var("GURUCONNECT_SERVER_URL") + .unwrap_or_else(|_| "wss://localhost:3002/ws".to_string()); + + let api_key = std::env::var("GURUCONNECT_API_KEY") + .unwrap_or_else(|_| "dev-key".to_string()); + + Ok(Config { + server_url, + api_key, + hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(), + capture: CaptureConfig::default(), + encoding: EncodingConfig::default(), + }) + } + + /// Get the configuration file path + fn config_path() -> PathBuf { + // Check for config in current directory first + let local_config = PathBuf::from("guruconnect.toml"); + if local_config.exists() { + return local_config; + } + + // Check in program data directory (Windows) + #[cfg(windows)] + { + if let Ok(program_data) = std::env::var("ProgramData") { + let path = PathBuf::from(program_data) + .join("GuruConnect") + .join("agent.toml"); + if path.exists() { + return path; + } + } + } + + // Default to local config + local_config + } + + /// Get the hostname to use + pub fn hostname(&self) -> String { + self.hostname_override + .clone() + .unwrap_or_else(|| { + hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) + }) + } + + /// Save current configuration to file + pub fn save(&self) -> Result<()> { + let config_path = Self::config_path(); + + // Ensure parent directory exists + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let contents = toml::to_string_pretty(self)?; + std::fs::write(&config_path, contents)?; + + Ok(()) + } +} + +/// Example configuration file content +pub fn example_config() -> &'static str { + r#"# GuruConnect Agent Configuration + +# Server connection +server_url = "wss://connect.example.com/ws" +api_key = "your-agent-api-key" + +# Optional: override hostname +# hostname_override = "custom-hostname" + +[capture] +fps = 30 +use_dxgi = true +gdi_fallback = true + +[encoding] +codec = "auto" # auto, raw, vp9, h264 +quality = 75 # 1-100 +hardware_encoding = true +"# +} diff --git a/agent/src/encoder/mod.rs b/agent/src/encoder/mod.rs new file mode 100644 index 0000000..74a174c --- /dev/null +++ b/agent/src/encoder/mod.rs @@ -0,0 +1,52 @@ +//! Frame encoding module +//! +//! Encodes captured frames for transmission. Supports: +//! - Raw BGRA + Zstd compression (lowest latency, LAN mode) +//! - VP9 software encoding (universal fallback) +//! - H264 hardware encoding (when GPU available) + +mod raw; + +pub use raw::RawEncoder; + +use crate::capture::CapturedFrame; +use crate::proto::{VideoFrame, RawFrame, DirtyRect as ProtoDirtyRect}; +use anyhow::Result; + +/// Encoded frame ready for transmission +#[derive(Debug)] +pub struct EncodedFrame { + /// Protobuf video frame message + pub frame: VideoFrame, + + /// Size in bytes after encoding + pub size: usize, + + /// Whether this is a keyframe (full frame) + pub is_keyframe: bool, +} + +/// Frame encoder trait +pub trait Encoder: Send { + /// Encode a captured frame + fn encode(&mut self, frame: &CapturedFrame) -> Result; + + /// Request a keyframe on next encode + fn request_keyframe(&mut self); + + /// Get encoder name/type + fn name(&self) -> &str; +} + +/// Create an encoder based on configuration +pub fn create_encoder(codec: &str, quality: u32) -> Result> { + match codec.to_lowercase().as_str() { + "raw" | "zstd" => Ok(Box::new(RawEncoder::new(quality)?)), + // "vp9" => Ok(Box::new(Vp9Encoder::new(quality)?)), + // "h264" => Ok(Box::new(H264Encoder::new(quality)?)), + "auto" | _ => { + // Default to raw for now (best for LAN) + Ok(Box::new(RawEncoder::new(quality)?)) + } + } +} diff --git a/agent/src/encoder/raw.rs b/agent/src/encoder/raw.rs new file mode 100644 index 0000000..3282438 --- /dev/null +++ b/agent/src/encoder/raw.rs @@ -0,0 +1,232 @@ +//! Raw frame encoder with Zstd compression +//! +//! Best for LAN connections where bandwidth is plentiful and latency is critical. +//! Compresses BGRA pixel data using Zstd for fast compression/decompression. + +use super::{EncodedFrame, Encoder}; +use crate::capture::{CapturedFrame, DirtyRect}; +use crate::proto::{video_frame, DirtyRect as ProtoDirtyRect, RawFrame, VideoFrame}; +use anyhow::Result; + +/// Raw frame encoder with Zstd compression +pub struct RawEncoder { + /// Compression level (1-22, default 3 for speed) + compression_level: i32, + + /// Previous frame for delta detection + previous_frame: Option>, + + /// Force keyframe on next encode + force_keyframe: bool, + + /// Frame counter + sequence: u32, +} + +impl RawEncoder { + /// Create a new raw encoder + /// + /// Quality 1-100 maps to Zstd compression level: + /// - Low quality (1-33): Level 1-3 (fastest) + /// - Medium quality (34-66): Level 4-9 + /// - High quality (67-100): Level 10-15 (best compression) + pub fn new(quality: u32) -> Result { + let compression_level = Self::quality_to_level(quality); + + Ok(Self { + compression_level, + previous_frame: None, + force_keyframe: true, // Start with keyframe + sequence: 0, + }) + } + + /// Convert quality (1-100) to Zstd compression level + fn quality_to_level(quality: u32) -> i32 { + // Lower quality = faster compression (level 1-3) + // Higher quality = better compression (level 10-15) + // We optimize for speed, so cap at 6 + match quality { + 0..=33 => 1, + 34..=50 => 2, + 51..=66 => 3, + 67..=80 => 4, + 81..=90 => 5, + _ => 6, + } + } + + /// Compress data using Zstd + fn compress(&self, data: &[u8]) -> Result> { + let compressed = zstd::encode_all(data, self.compression_level)?; + Ok(compressed) + } + + /// Detect dirty rectangles by comparing with previous frame + fn detect_dirty_rects( + &self, + current: &[u8], + previous: &[u8], + width: u32, + height: u32, + ) -> Vec { + // Simple block-based dirty detection + // Divide screen into 64x64 blocks and check which changed + const BLOCK_SIZE: u32 = 64; + + let mut dirty_rects = Vec::new(); + let stride = (width * 4) as usize; + + let blocks_x = (width + BLOCK_SIZE - 1) / BLOCK_SIZE; + let blocks_y = (height + BLOCK_SIZE - 1) / BLOCK_SIZE; + + for by in 0..blocks_y { + for bx in 0..blocks_x { + let x = bx * BLOCK_SIZE; + let y = by * BLOCK_SIZE; + let block_w = (BLOCK_SIZE).min(width - x); + let block_h = (BLOCK_SIZE).min(height - y); + + // Check if this block changed + let mut changed = false; + 'block_check: for row in 0..block_h { + let row_start = ((y + row) as usize * stride) + (x as usize * 4); + let row_end = row_start + (block_w as usize * 4); + + if row_end <= current.len() && row_end <= previous.len() { + if current[row_start..row_end] != previous[row_start..row_end] { + changed = true; + break 'block_check; + } + } else { + changed = true; + break 'block_check; + } + } + + if changed { + dirty_rects.push(DirtyRect { + x, + y, + width: block_w, + height: block_h, + }); + } + } + } + + // Merge adjacent dirty rects (simple optimization) + // TODO: Implement proper rectangle merging + + dirty_rects + } + + /// Extract pixels for dirty rectangles only + fn extract_dirty_pixels( + &self, + data: &[u8], + width: u32, + dirty_rects: &[DirtyRect], + ) -> Vec { + let stride = (width * 4) as usize; + let mut pixels = Vec::new(); + + for rect in dirty_rects { + for row in 0..rect.height { + let row_start = ((rect.y + row) as usize * stride) + (rect.x as usize * 4); + let row_end = row_start + (rect.width as usize * 4); + + if row_end <= data.len() { + pixels.extend_from_slice(&data[row_start..row_end]); + } + } + } + + pixels + } +} + +impl Encoder for RawEncoder { + fn encode(&mut self, frame: &CapturedFrame) -> Result { + self.sequence = self.sequence.wrapping_add(1); + + let is_keyframe = self.force_keyframe || self.previous_frame.is_none(); + self.force_keyframe = false; + + let (data_to_compress, dirty_rects, full_frame) = if is_keyframe { + // Keyframe: send full frame + (frame.data.clone(), Vec::new(), true) + } else if let Some(ref previous) = self.previous_frame { + // Delta frame: detect and send only changed regions + let dirty_rects = + self.detect_dirty_rects(&frame.data, previous, frame.width, frame.height); + + if dirty_rects.is_empty() { + // No changes, skip frame + return Ok(EncodedFrame { + frame: VideoFrame::default(), + size: 0, + is_keyframe: false, + }); + } + + // If too many dirty rects, just send full frame + if dirty_rects.len() > 50 { + (frame.data.clone(), Vec::new(), true) + } else { + let dirty_pixels = self.extract_dirty_pixels(&frame.data, frame.width, &dirty_rects); + (dirty_pixels, dirty_rects, false) + } + } else { + (frame.data.clone(), Vec::new(), true) + }; + + // Compress the data + let compressed = self.compress(&data_to_compress)?; + let size = compressed.len(); + + // Build protobuf message + let proto_dirty_rects: Vec = dirty_rects + .iter() + .map(|r| ProtoDirtyRect { + x: r.x as i32, + y: r.y as i32, + width: r.width as i32, + height: r.height as i32, + }) + .collect(); + + let raw_frame = RawFrame { + width: frame.width as i32, + height: frame.height as i32, + data: compressed, + compressed: true, + dirty_rects: proto_dirty_rects, + is_keyframe: full_frame, + }; + + let video_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::Raw(raw_frame)), + }; + + // Save current frame for next comparison + self.previous_frame = Some(frame.data.clone()); + + Ok(EncodedFrame { + frame: video_frame, + size, + is_keyframe: full_frame, + }) + } + + fn request_keyframe(&mut self) { + self.force_keyframe = true; + } + + fn name(&self) -> &str { + "raw+zstd" + } +} diff --git a/agent/src/input/keyboard.rs b/agent/src/input/keyboard.rs new file mode 100644 index 0000000..6d20a00 --- /dev/null +++ b/agent/src/input/keyboard.rs @@ -0,0 +1,287 @@ +//! Keyboard input simulation using Windows SendInput API + +use anyhow::Result; + +#[cfg(windows)] +use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, + KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE, KEYBDINPUT, + MapVirtualKeyW, MAPVK_VK_TO_VSC_EX, +}; + +/// Keyboard input controller +pub struct KeyboardController { + // Track modifier states for proper handling + #[allow(dead_code)] + modifiers: ModifierState, +} + +#[derive(Default)] +struct ModifierState { + ctrl: bool, + alt: bool, + shift: bool, + meta: bool, +} + +impl KeyboardController { + /// Create a new keyboard controller + pub fn new() -> Result { + Ok(Self { + modifiers: ModifierState::default(), + }) + } + + /// Press a key down by virtual key code + #[cfg(windows)] + pub fn key_down(&mut self, vk_code: u16) -> Result<()> { + self.send_key(vk_code, true) + } + + /// Release a key by virtual key code + #[cfg(windows)] + pub fn key_up(&mut self, vk_code: u16) -> Result<()> { + self.send_key(vk_code, false) + } + + /// Send a key event + #[cfg(windows)] + fn send_key(&mut self, vk_code: u16, down: bool) -> Result<()> { + // Get scan code from virtual key + let scan_code = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 }; + + let mut flags = KEYBD_EVENT_FLAGS::default(); + + // Add extended key flag for certain keys + if Self::is_extended_key(vk_code) || (scan_code >> 8) == 0xE0 { + flags |= KEYEVENTF_EXTENDEDKEY; + } + + if !down { + flags |= KEYEVENTF_KEYUP; + } + + let input = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(vk_code), + wScan: scan_code, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + + self.send_input(&[input]) + } + + /// Type a unicode character + #[cfg(windows)] + pub fn type_char(&mut self, ch: char) -> Result<()> { + let mut inputs = Vec::new(); + + // For characters that fit in a single u16 + for code_unit in ch.encode_utf16(&mut [0; 2]) { + // Key down + inputs.push(INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0), + wScan: code_unit, + dwFlags: KEYEVENTF_UNICODE, + time: 0, + dwExtraInfo: 0, + }, + }, + }); + + // Key up + inputs.push(INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0), + wScan: code_unit, + dwFlags: KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, + time: 0, + dwExtraInfo: 0, + }, + }, + }); + } + + self.send_input(&inputs) + } + + /// Type a string of text + #[cfg(windows)] + pub fn type_string(&mut self, text: &str) -> Result<()> { + for ch in text.chars() { + self.type_char(ch)?; + } + Ok(()) + } + + /// Send Secure Attention Sequence (Ctrl+Alt+Delete) + /// + /// Note: This requires special privileges on Windows. + /// The agent typically needs to run as SYSTEM or use SAS API. + #[cfg(windows)] + pub fn send_sas(&mut self) -> Result<()> { + // Try using the SAS library if available + // For now, we'll attempt to send the key combination + // This won't work in all contexts due to Windows security + + // Load the sas.dll and call SendSAS if available + use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; + use windows::core::PCWSTR; + + unsafe { + let dll_name: Vec = "sas.dll\0".encode_utf16().collect(); + let lib = LoadLibraryW(PCWSTR(dll_name.as_ptr())); + + if let Ok(lib) = lib { + let proc_name = b"SendSAS\0"; + if let Some(proc) = GetProcAddress(lib, windows::core::PCSTR(proc_name.as_ptr())) { + // SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del + let send_sas: extern "system" fn(i32) = std::mem::transmute(proc); + send_sas(0); // FALSE = Ctrl+Alt+Del + return Ok(()); + } + } + } + + // Fallback: Try sending the keys (won't work without proper privileges) + tracing::warn!("SAS library not available, Ctrl+Alt+Del may not work"); + + // VK codes + const VK_CONTROL: u16 = 0x11; + const VK_MENU: u16 = 0x12; // Alt + const VK_DELETE: u16 = 0x2E; + + // Press keys + self.key_down(VK_CONTROL)?; + self.key_down(VK_MENU)?; + self.key_down(VK_DELETE)?; + + // Release keys + self.key_up(VK_DELETE)?; + self.key_up(VK_MENU)?; + self.key_up(VK_CONTROL)?; + + Ok(()) + } + + /// Check if a virtual key code is an extended key + #[cfg(windows)] + fn is_extended_key(vk: u16) -> bool { + matches!( + vk, + 0x21..=0x28 | // Page Up, Page Down, End, Home, Arrow keys + 0x2D | 0x2E | // Insert, Delete + 0x5B | 0x5C | // Left/Right Windows keys + 0x5D | // Applications key + 0x6F | // Numpad Divide + 0x90 | // Num Lock + 0x91 // Scroll Lock + ) + } + + /// Send input events + #[cfg(windows)] + fn send_input(&self, inputs: &[INPUT]) -> Result<()> { + let sent = unsafe { SendInput(inputs, std::mem::size_of::() as i32) }; + + if sent as usize != inputs.len() { + anyhow::bail!( + "SendInput failed: sent {} of {} inputs", + sent, + inputs.len() + ); + } + + Ok(()) + } + + #[cfg(not(windows))] + pub fn key_down(&mut self, _vk_code: u16) -> Result<()> { + anyhow::bail!("Keyboard input only supported on Windows") + } + + #[cfg(not(windows))] + pub fn key_up(&mut self, _vk_code: u16) -> Result<()> { + anyhow::bail!("Keyboard input only supported on Windows") + } + + #[cfg(not(windows))] + pub fn type_char(&mut self, _ch: char) -> Result<()> { + anyhow::bail!("Keyboard input only supported on Windows") + } + + #[cfg(not(windows))] + pub fn send_sas(&mut self) -> Result<()> { + anyhow::bail!("SAS only supported on Windows") + } +} + +/// Common Windows virtual key codes +#[allow(dead_code)] +pub mod vk { + pub const BACK: u16 = 0x08; + pub const TAB: u16 = 0x09; + pub const RETURN: u16 = 0x0D; + pub const SHIFT: u16 = 0x10; + pub const CONTROL: u16 = 0x11; + pub const MENU: u16 = 0x12; // Alt + pub const PAUSE: u16 = 0x13; + pub const CAPITAL: u16 = 0x14; // Caps Lock + pub const ESCAPE: u16 = 0x1B; + pub const SPACE: u16 = 0x20; + pub const PRIOR: u16 = 0x21; // Page Up + pub const NEXT: u16 = 0x22; // Page Down + pub const END: u16 = 0x23; + pub const HOME: u16 = 0x24; + pub const LEFT: u16 = 0x25; + pub const UP: u16 = 0x26; + pub const RIGHT: u16 = 0x27; + pub const DOWN: u16 = 0x28; + pub const INSERT: u16 = 0x2D; + pub const DELETE: u16 = 0x2E; + + // 0-9 keys + pub const KEY_0: u16 = 0x30; + pub const KEY_9: u16 = 0x39; + + // A-Z keys + pub const KEY_A: u16 = 0x41; + pub const KEY_Z: u16 = 0x5A; + + // Windows keys + pub const LWIN: u16 = 0x5B; + pub const RWIN: u16 = 0x5C; + + // Function keys + pub const F1: u16 = 0x70; + pub const F2: u16 = 0x71; + pub const F3: u16 = 0x72; + pub const F4: u16 = 0x73; + pub const F5: u16 = 0x74; + pub const F6: u16 = 0x75; + pub const F7: u16 = 0x76; + pub const F8: u16 = 0x77; + pub const F9: u16 = 0x78; + pub const F10: u16 = 0x79; + pub const F11: u16 = 0x7A; + pub const F12: u16 = 0x7B; + + // Modifier keys + pub const LSHIFT: u16 = 0xA0; + pub const RSHIFT: u16 = 0xA1; + pub const LCONTROL: u16 = 0xA2; + pub const RCONTROL: u16 = 0xA3; + pub const LMENU: u16 = 0xA4; // Left Alt + pub const RMENU: u16 = 0xA5; // Right Alt +} diff --git a/agent/src/input/mod.rs b/agent/src/input/mod.rs new file mode 100644 index 0000000..d6ac3b5 --- /dev/null +++ b/agent/src/input/mod.rs @@ -0,0 +1,91 @@ +//! Input injection module +//! +//! Handles mouse and keyboard input simulation using Windows SendInput API. + +mod mouse; +mod keyboard; + +pub use mouse::MouseController; +pub use keyboard::KeyboardController; + +use anyhow::Result; + +/// Combined input controller for mouse and keyboard +pub struct InputController { + mouse: MouseController, + keyboard: KeyboardController, +} + +impl InputController { + /// Create a new input controller + pub fn new() -> Result { + Ok(Self { + mouse: MouseController::new()?, + keyboard: KeyboardController::new()?, + }) + } + + /// Get mouse controller + pub fn mouse(&mut self) -> &mut MouseController { + &mut self.mouse + } + + /// Get keyboard controller + pub fn keyboard(&mut self) -> &mut KeyboardController { + &mut self.keyboard + } + + /// Move mouse to absolute position + pub fn mouse_move(&mut self, x: i32, y: i32) -> Result<()> { + self.mouse.move_to(x, y) + } + + /// Click mouse button + pub fn mouse_click(&mut self, button: MouseButton, down: bool) -> Result<()> { + if down { + self.mouse.button_down(button) + } else { + self.mouse.button_up(button) + } + } + + /// Scroll mouse wheel + pub fn mouse_scroll(&mut self, delta_x: i32, delta_y: i32) -> Result<()> { + self.mouse.scroll(delta_x, delta_y) + } + + /// Press or release a key + pub fn key_event(&mut self, vk_code: u16, down: bool) -> Result<()> { + if down { + self.keyboard.key_down(vk_code) + } else { + self.keyboard.key_up(vk_code) + } + } + + /// Type a unicode character + pub fn type_unicode(&mut self, ch: char) -> Result<()> { + self.keyboard.type_char(ch) + } + + /// Send Ctrl+Alt+Delete (requires special handling on Windows) + pub fn send_ctrl_alt_del(&mut self) -> Result<()> { + self.keyboard.send_sas() + } +} + +/// Mouse button types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MouseButton { + Left, + Right, + Middle, + X1, + X2, +} + +impl Default for InputController { + fn default() -> Self { + Self::new().expect("Failed to create input controller") + } +} diff --git a/agent/src/input/mouse.rs b/agent/src/input/mouse.rs new file mode 100644 index 0000000..02d95d8 --- /dev/null +++ b/agent/src/input/mouse.rs @@ -0,0 +1,217 @@ +//! Mouse input simulation using Windows SendInput API + +use super::MouseButton; +use anyhow::Result; + +#[cfg(windows)] +use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, + MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, + MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, + MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, XBUTTON1, XBUTTON2, +}; + +#[cfg(windows)] +use windows::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, +}; + +/// Mouse input controller +pub struct MouseController { + /// Virtual screen dimensions for coordinate translation + #[cfg(windows)] + virtual_screen: VirtualScreen, +} + +#[cfg(windows)] +struct VirtualScreen { + x: i32, + y: i32, + width: i32, + height: i32, +} + +impl MouseController { + /// Create a new mouse controller + pub fn new() -> Result { + #[cfg(windows)] + { + let virtual_screen = unsafe { + VirtualScreen { + x: GetSystemMetrics(SM_XVIRTUALSCREEN), + y: GetSystemMetrics(SM_YVIRTUALSCREEN), + width: GetSystemMetrics(SM_CXVIRTUALSCREEN), + height: GetSystemMetrics(SM_CYVIRTUALSCREEN), + } + }; + + Ok(Self { virtual_screen }) + } + + #[cfg(not(windows))] + { + anyhow::bail!("Mouse input only supported on Windows") + } + } + + /// Move mouse to absolute screen coordinates + #[cfg(windows)] + pub fn move_to(&mut self, x: i32, y: i32) -> Result<()> { + // Convert screen coordinates to normalized absolute coordinates (0-65535) + let norm_x = ((x - self.virtual_screen.x) * 65535) / self.virtual_screen.width; + let norm_y = ((y - self.virtual_screen.y) * 65535) / self.virtual_screen.height; + + let input = INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: norm_x, + dy: norm_y, + mouseData: 0, + dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + + self.send_input(&[input]) + } + + /// Press mouse button down + #[cfg(windows)] + pub fn button_down(&mut self, button: MouseButton) -> Result<()> { + let (flags, data) = match button { + MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, 0), + MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, 0), + MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, 0), + MouseButton::X1 => (MOUSEEVENTF_XDOWN, XBUTTON1 as u32), + MouseButton::X2 => (MOUSEEVENTF_XDOWN, XBUTTON2 as u32), + }; + + let input = INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: data, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + + self.send_input(&[input]) + } + + /// Release mouse button + #[cfg(windows)] + pub fn button_up(&mut self, button: MouseButton) -> Result<()> { + let (flags, data) = match button { + MouseButton::Left => (MOUSEEVENTF_LEFTUP, 0), + MouseButton::Right => (MOUSEEVENTF_RIGHTUP, 0), + MouseButton::Middle => (MOUSEEVENTF_MIDDLEUP, 0), + MouseButton::X1 => (MOUSEEVENTF_XUP, XBUTTON1 as u32), + MouseButton::X2 => (MOUSEEVENTF_XUP, XBUTTON2 as u32), + }; + + let input = INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: data, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + + self.send_input(&[input]) + } + + /// Scroll mouse wheel + #[cfg(windows)] + pub fn scroll(&mut self, delta_x: i32, delta_y: i32) -> Result<()> { + let mut inputs = Vec::new(); + + // Vertical scroll + if delta_y != 0 { + inputs.push(INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: delta_y as u32, + dwFlags: MOUSEEVENTF_WHEEL, + time: 0, + dwExtraInfo: 0, + }, + }, + }); + } + + // Horizontal scroll + if delta_x != 0 { + inputs.push(INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: delta_x as u32, + dwFlags: MOUSEEVENTF_HWHEEL, + time: 0, + dwExtraInfo: 0, + }, + }, + }); + } + + if !inputs.is_empty() { + self.send_input(&inputs)?; + } + + Ok(()) + } + + /// Send input events + #[cfg(windows)] + fn send_input(&self, inputs: &[INPUT]) -> Result<()> { + let sent = unsafe { + SendInput(inputs, std::mem::size_of::() as i32) + }; + + if sent as usize != inputs.len() { + anyhow::bail!("SendInput failed: sent {} of {} inputs", sent, inputs.len()); + } + + Ok(()) + } + + #[cfg(not(windows))] + pub fn move_to(&mut self, _x: i32, _y: i32) -> Result<()> { + anyhow::bail!("Mouse input only supported on Windows") + } + + #[cfg(not(windows))] + pub fn button_down(&mut self, _button: MouseButton) -> Result<()> { + anyhow::bail!("Mouse input only supported on Windows") + } + + #[cfg(not(windows))] + pub fn button_up(&mut self, _button: MouseButton) -> Result<()> { + anyhow::bail!("Mouse input only supported on Windows") + } + + #[cfg(not(windows))] + pub fn scroll(&mut self, _delta_x: i32, _delta_y: i32) -> Result<()> { + anyhow::bail!("Mouse input only supported on Windows") + } +} diff --git a/agent/src/main.rs b/agent/src/main.rs new file mode 100644 index 0000000..c7e321b --- /dev/null +++ b/agent/src/main.rs @@ -0,0 +1,70 @@ +//! GuruConnect Agent - Remote Desktop Agent for Windows +//! +//! Provides screen capture, input injection, and remote control capabilities. + +mod capture; +mod config; +mod encoder; +mod input; +mod session; +mod transport; + +pub mod proto { + include!(concat!(env!("OUT_DIR"), "/guruconnect.rs")); +} + +use anyhow::Result; +use tracing::{info, error, Level}; +use tracing_subscriber::FmtSubscriber; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .with_target(true) + .with_thread_ids(true) + .init(); + + info!("GuruConnect Agent v{}", env!("CARGO_PKG_VERSION")); + + // Load configuration + let config = config::Config::load()?; + info!("Loaded configuration for server: {}", config.server_url); + + // Run the agent + if let Err(e) = run_agent(config).await { + error!("Agent error: {}", e); + return Err(e); + } + + Ok(()) +} + +async fn run_agent(config: config::Config) -> Result<()> { + // Create session manager + let mut session = session::SessionManager::new(config.clone()); + + // Connect to server and run main loop + loop { + info!("Connecting to server..."); + + match session.connect().await { + Ok(_) => { + info!("Connected to server"); + + // Run session until disconnect + if let Err(e) = session.run().await { + error!("Session error: {}", e); + } + } + Err(e) => { + error!("Connection failed: {}", e); + } + } + + // Wait before reconnecting + info!("Reconnecting in 5 seconds..."); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } +} diff --git a/agent/src/session/mod.rs b/agent/src/session/mod.rs new file mode 100644 index 0000000..5dfac4b --- /dev/null +++ b/agent/src/session/mod.rs @@ -0,0 +1,194 @@ +//! Session management for the agent +//! +//! Handles the lifecycle of a remote session including: +//! - Connection to server +//! - Authentication +//! - Frame capture and encoding loop +//! - Input event handling + +use crate::capture::{self, Capturer, Display}; +use crate::config::Config; +use crate::encoder::{self, Encoder}; +use crate::input::InputController; +use crate::proto::{Message, message}; +use crate::transport::WebSocketTransport; +use anyhow::Result; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +/// Session manager handles the remote control session +pub struct SessionManager { + config: Config, + transport: Option, + state: SessionState, +} + +#[derive(Debug, Clone, PartialEq)] +enum SessionState { + Disconnected, + Connecting, + Connected, + Active, +} + +impl SessionManager { + /// Create a new session manager + pub fn new(config: Config) -> Self { + Self { + config, + transport: None, + state: SessionState::Disconnected, + } + } + + /// Connect to the server + pub async fn connect(&mut self) -> Result<()> { + self.state = SessionState::Connecting; + + let transport = WebSocketTransport::connect( + &self.config.server_url, + &self.config.api_key, + ).await?; + + self.transport = Some(transport); + self.state = SessionState::Connected; + + Ok(()) + } + + /// Run the session main loop + pub async fn run(&mut self) -> Result<()> { + let transport = self.transport.as_mut() + .ok_or_else(|| anyhow::anyhow!("Not connected"))?; + + self.state = SessionState::Active; + + // Get primary display + let display = capture::primary_display()?; + tracing::info!("Using display: {} ({}x{})", display.name, display.width, display.height); + + // Create capturer + let mut capturer = capture::create_capturer( + display.clone(), + self.config.capture.use_dxgi, + self.config.capture.gdi_fallback, + )?; + + // Create encoder + let mut encoder = encoder::create_encoder( + &self.config.encoding.codec, + self.config.encoding.quality, + )?; + + // Create input controller + let mut input = InputController::new()?; + + // Calculate frame interval + let frame_interval = Duration::from_millis(1000 / self.config.capture.fps as u64); + let mut last_frame_time = Instant::now(); + + // Main loop + loop { + // Check for incoming messages (non-blocking) + while let Some(msg) = transport.try_recv()? { + self.handle_message(&mut input, msg)?; + } + + // Capture and send frame if interval elapsed + if last_frame_time.elapsed() >= frame_interval { + last_frame_time = Instant::now(); + + if let Some(frame) = capturer.capture()? { + let encoded = encoder.encode(&frame)?; + + // Skip empty frames (no changes) + if encoded.size > 0 { + let msg = Message { + payload: Some(message::Payload::VideoFrame(encoded.frame)), + }; + transport.send(msg).await?; + } + } + } + + // Small sleep to prevent busy loop + tokio::time::sleep(Duration::from_millis(1)).await; + + // Check if still connected + if !transport.is_connected() { + tracing::warn!("Connection lost"); + break; + } + } + + self.state = SessionState::Disconnected; + Ok(()) + } + + /// Handle incoming message from server + fn handle_message(&mut self, input: &mut InputController, msg: Message) -> Result<()> { + match msg.payload { + Some(message::Payload::MouseEvent(mouse)) => { + // Handle mouse event + use crate::proto::MouseEventType; + use crate::input::MouseButton; + + match MouseEventType::try_from(mouse.event_type).unwrap_or(MouseEventType::MouseMove) { + MouseEventType::MouseMove => { + input.mouse_move(mouse.x, mouse.y)?; + } + MouseEventType::MouseDown => { + input.mouse_move(mouse.x, mouse.y)?; + if let Some(ref buttons) = mouse.buttons { + if buttons.left { input.mouse_click(MouseButton::Left, true)?; } + if buttons.right { input.mouse_click(MouseButton::Right, true)?; } + if buttons.middle { input.mouse_click(MouseButton::Middle, true)?; } + } + } + MouseEventType::MouseUp => { + if let Some(ref buttons) = mouse.buttons { + if buttons.left { input.mouse_click(MouseButton::Left, false)?; } + if buttons.right { input.mouse_click(MouseButton::Right, false)?; } + if buttons.middle { input.mouse_click(MouseButton::Middle, false)?; } + } + } + MouseEventType::MouseWheel => { + input.mouse_scroll(mouse.wheel_delta_x, mouse.wheel_delta_y)?; + } + } + } + + Some(message::Payload::KeyEvent(key)) => { + // Handle keyboard event + input.key_event(key.vk_code as u16, key.down)?; + } + + Some(message::Payload::SpecialKey(special)) => { + use crate::proto::SpecialKey; + match SpecialKey::try_from(special.key).ok() { + Some(SpecialKey::CtrlAltDel) => { + input.send_ctrl_alt_del()?; + } + _ => {} + } + } + + Some(message::Payload::Heartbeat(_)) => { + // Respond to heartbeat + // TODO: Send heartbeat ack + } + + Some(message::Payload::Disconnect(disc)) => { + tracing::info!("Disconnect requested: {}", disc.reason); + return Err(anyhow::anyhow!("Disconnect: {}", disc.reason)); + } + + _ => { + // Ignore unknown messages + } + } + + Ok(()) + } +} diff --git a/agent/src/transport/mod.rs b/agent/src/transport/mod.rs new file mode 100644 index 0000000..c0da8ce --- /dev/null +++ b/agent/src/transport/mod.rs @@ -0,0 +1,5 @@ +//! WebSocket transport for agent-server communication + +mod websocket; + +pub use websocket::WebSocketTransport; diff --git a/agent/src/transport/websocket.rs b/agent/src/transport/websocket.rs new file mode 100644 index 0000000..805e9f3 --- /dev/null +++ b/agent/src/transport/websocket.rs @@ -0,0 +1,183 @@ +//! WebSocket client transport +//! +//! Handles WebSocket connection to the GuruConnect server with: +//! - TLS encryption +//! - Automatic reconnection +//! - Protobuf message serialization + +use crate::proto::Message; +use anyhow::{Context, Result}; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use prost::Message as ProstMessage; +use std::collections::VecDeque; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio_tungstenite::{ + connect_async, tungstenite::protocol::Message as WsMessage, MaybeTlsStream, WebSocketStream, +}; + +type WsStream = WebSocketStream>; + +/// WebSocket transport for server communication +pub struct WebSocketTransport { + stream: Arc>, + incoming: VecDeque, + connected: bool, +} + +impl WebSocketTransport { + /// Connect to the server + pub async fn connect(url: &str, api_key: &str) -> Result { + // Append API key as query parameter + let url_with_auth = if url.contains('?') { + format!("{}&api_key={}", url, api_key) + } else { + format!("{}?api_key={}", url, api_key) + }; + + tracing::info!("Connecting to {}", url); + + let (ws_stream, response) = connect_async(&url_with_auth) + .await + .context("Failed to connect to WebSocket server")?; + + tracing::info!("Connected, status: {}", response.status()); + + Ok(Self { + stream: Arc::new(Mutex::new(ws_stream)), + incoming: VecDeque::new(), + connected: true, + }) + } + + /// Send a protobuf message + pub async fn send(&mut self, msg: Message) -> Result<()> { + let mut stream = self.stream.lock().await; + + // Serialize to protobuf binary + let mut buf = Vec::with_capacity(msg.encoded_len()); + msg.encode(&mut buf)?; + + // Send as binary WebSocket message + stream + .send(WsMessage::Binary(buf.into())) + .await + .context("Failed to send message")?; + + Ok(()) + } + + /// Try to receive a message (non-blocking) + pub fn try_recv(&mut self) -> Result> { + // Return buffered message if available + if let Some(msg) = self.incoming.pop_front() { + return Ok(Some(msg)); + } + + // Try to receive more messages + let stream = self.stream.clone(); + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let mut stream = stream.lock().await; + + // Use try_next for non-blocking receive + match tokio::time::timeout( + std::time::Duration::from_millis(1), + stream.next(), + ) + .await + { + Ok(Some(Ok(ws_msg))) => Ok(Some(ws_msg)), + Ok(Some(Err(e))) => Err(anyhow::anyhow!("WebSocket error: {}", e)), + Ok(None) => { + // Connection closed + Ok(None) + } + Err(_) => { + // Timeout - no message available + Ok(None) + } + } + }) + }); + + match result? { + Some(ws_msg) => { + if let Some(msg) = self.parse_message(ws_msg)? { + Ok(Some(msg)) + } else { + Ok(None) + } + } + None => Ok(None), + } + } + + /// Receive a message (blocking) + pub async fn recv(&mut self) -> Result> { + // Return buffered message if available + if let Some(msg) = self.incoming.pop_front() { + return Ok(Some(msg)); + } + + let mut stream = self.stream.lock().await; + + match stream.next().await { + Some(Ok(ws_msg)) => self.parse_message(ws_msg), + Some(Err(e)) => { + self.connected = false; + Err(anyhow::anyhow!("WebSocket error: {}", e)) + } + None => { + self.connected = false; + Ok(None) + } + } + } + + /// Parse a WebSocket message into a protobuf message + fn parse_message(&mut self, ws_msg: WsMessage) -> Result> { + match ws_msg { + WsMessage::Binary(data) => { + let msg = Message::decode(Bytes::from(data)) + .context("Failed to decode protobuf message")?; + Ok(Some(msg)) + } + WsMessage::Ping(data) => { + // Pong is sent automatically by tungstenite + tracing::trace!("Received ping"); + Ok(None) + } + WsMessage::Pong(_) => { + tracing::trace!("Received pong"); + Ok(None) + } + WsMessage::Close(frame) => { + tracing::info!("Connection closed: {:?}", frame); + self.connected = false; + Ok(None) + } + WsMessage::Text(text) => { + // We expect binary protobuf, but log text messages + tracing::warn!("Received unexpected text message: {}", text); + Ok(None) + } + _ => Ok(None), + } + } + + /// Check if connected + pub fn is_connected(&self) -> bool { + self.connected + } + + /// Close the connection + pub async fn close(&mut self) -> Result<()> { + let mut stream = self.stream.lock().await; + stream.close(None).await?; + self.connected = false; + Ok(()) + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..d9cb22a --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,25 @@ +{ + "name": "@guruconnect/dashboard", + "version": "0.1.0", + "description": "GuruConnect Remote Desktop Viewer Components", + "author": "AZ Computer Guru", + "license": "Proprietary", + "main": "src/components/index.ts", + "types": "src/components/index.ts", + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint src" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.0.0" + }, + "dependencies": { + "fzstd": "^0.1.1" + } +} diff --git a/dashboard/src/components/RemoteViewer.tsx b/dashboard/src/components/RemoteViewer.tsx new file mode 100644 index 0000000..eb35ad9 --- /dev/null +++ b/dashboard/src/components/RemoteViewer.tsx @@ -0,0 +1,215 @@ +/** + * 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 = ({ + serverUrl, + sessionId, + className = '', + onStatusChange, + autoConnect = true, + showStatusBar = true, +}) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const ctxRef = useRef(null); + + // Display dimensions from received frames + const [displaySize, setDisplaySize] = useState({ width: 1920, height: 1080 }); + + // Frame buffer for rendering + const frameBufferRef = useRef(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) => { + const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 0); + sendMouseEvent(event); + }, [getCanvasRect, displaySize, sendMouseEvent]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 1); + sendMouseEvent(event); + }, [getCanvasRect, displaySize, sendMouseEvent]); + + const handleMouseUp = useCallback((e: React.MouseEvent) => { + const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 2); + sendMouseEvent(event); + }, [getCanvasRect, displaySize, sendMouseEvent]); + + const handleWheel = useCallback((e: React.WheelEvent) => { + 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) => { + e.preventDefault(); // Prevent browser context menu + }, []); + + // Keyboard event handlers + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + e.preventDefault(); + const event = createKeyEvent(e, true); + sendKeyEvent(event); + }, [sendKeyEvent]); + + const handleKeyUp = useCallback((e: React.KeyboardEvent) => { + e.preventDefault(); + const event = createKeyEvent(e, false); + sendKeyEvent(event); + }, [sendKeyEvent]); + + return ( +
+ + + {showStatusBar && ( +
+ + {status.connected ? ( + Connected + ) : ( + Disconnected + )} + + {displaySize.width}x{displaySize.height} + {status.fps !== undefined && {status.fps} FPS} + {status.latencyMs !== undefined && {status.latencyMs}ms} +
+ )} +
+ ); +}; + +export default RemoteViewer; diff --git a/dashboard/src/components/SessionControls.tsx b/dashboard/src/components/SessionControls.tsx new file mode 100644 index 0000000..899acf8 --- /dev/null +++ b/dashboard/src/components/SessionControls.tsx @@ -0,0 +1,187 @@ +/** + * 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 = ({ + 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 ( +
+ {/* Display selector */} + {displays.length > 1 && ( + + )} + + {/* Quality dropdown */} +
+ + + {showQuality && ( +
+ {(['auto', 'low', 'balanced', 'high'] as const).map((preset) => ( + + ))} +
+ )} +
+ + {/* Special keys */} + + + + + + + {/* Spacer */} +
+ + {/* Disconnect */} + +
+ ); +}; + +export default SessionControls; diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts new file mode 100644 index 0000000..de94f60 --- /dev/null +++ b/dashboard/src/components/index.ts @@ -0,0 +1,22 @@ +/** + * 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'; diff --git a/dashboard/src/hooks/useRemoteSession.ts b/dashboard/src/hooks/useRemoteSession.ts new file mode 100644 index 0000000..27e54e3 --- /dev/null +++ b/dashboard/src/hooks/useRemoteSession.ts @@ -0,0 +1,239 @@ +/** + * React hook for managing remote desktop session connection + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { ConnectionStatus, VideoFrame, MouseEvent as ProtoMouseEvent, KeyEvent as ProtoKeyEvent, MouseEventType, KeyEventType, Modifiers } from '../types/protocol'; +import { encodeMouseEvent, encodeKeyEvent, decodeVideoFrame } from '../lib/protobuf'; + +interface UseRemoteSessionOptions { + serverUrl: string; + sessionId: string; + onFrame?: (frame: VideoFrame) => void; + onStatusChange?: (status: ConnectionStatus) => void; +} + +interface UseRemoteSessionReturn { + status: ConnectionStatus; + connect: () => void; + disconnect: () => void; + sendMouseEvent: (event: ProtoMouseEvent) => void; + sendKeyEvent: (event: ProtoKeyEvent) => void; +} + +export function useRemoteSession(options: UseRemoteSessionOptions): UseRemoteSessionReturn { + const { serverUrl, sessionId, onFrame, onStatusChange } = options; + + const [status, setStatus] = useState({ + connected: false, + }); + + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const frameCountRef = useRef(0); + const lastFpsUpdateRef = useRef(Date.now()); + + // Update status and notify + const updateStatus = useCallback((newStatus: Partial) => { + setStatus(prev => { + const updated = { ...prev, ...newStatus }; + onStatusChange?.(updated); + return updated; + }); + }, [onStatusChange]); + + // Calculate FPS + const updateFps = useCallback(() => { + const now = Date.now(); + const elapsed = now - lastFpsUpdateRef.current; + if (elapsed >= 1000) { + const fps = Math.round((frameCountRef.current * 1000) / elapsed); + updateStatus({ fps }); + frameCountRef.current = 0; + lastFpsUpdateRef.current = now; + } + }, [updateStatus]); + + // Handle incoming WebSocket messages + const handleMessage = useCallback((event: MessageEvent) => { + if (event.data instanceof Blob) { + event.data.arrayBuffer().then(buffer => { + const data = new Uint8Array(buffer); + const frame = decodeVideoFrame(data); + if (frame) { + frameCountRef.current++; + updateFps(); + onFrame?.(frame); + } + }); + } else if (event.data instanceof ArrayBuffer) { + const data = new Uint8Array(event.data); + const frame = decodeVideoFrame(data); + if (frame) { + frameCountRef.current++; + updateFps(); + onFrame?.(frame); + } + } + }, [onFrame, updateFps]); + + // Connect to server + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + return; + } + + // Clear any pending reconnect + if (reconnectTimeoutRef.current) { + window.clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + const wsUrl = `${serverUrl}/ws/viewer?session_id=${encodeURIComponent(sessionId)}`; + const ws = new WebSocket(wsUrl); + ws.binaryType = 'arraybuffer'; + + ws.onopen = () => { + updateStatus({ + connected: true, + sessionId, + }); + }; + + ws.onmessage = handleMessage; + + ws.onclose = (event) => { + updateStatus({ + connected: false, + latencyMs: undefined, + fps: undefined, + }); + + // Auto-reconnect after 2 seconds + if (!event.wasClean) { + reconnectTimeoutRef.current = window.setTimeout(() => { + connect(); + }, 2000); + } + }; + + ws.onerror = () => { + updateStatus({ connected: false }); + }; + + wsRef.current = ws; + }, [serverUrl, sessionId, handleMessage, updateStatus]); + + // Disconnect from server + const disconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + window.clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + if (wsRef.current) { + wsRef.current.close(1000, 'User disconnected'); + wsRef.current = null; + } + + updateStatus({ + connected: false, + sessionId: undefined, + latencyMs: undefined, + fps: undefined, + }); + }, [updateStatus]); + + // Send mouse event + const sendMouseEvent = useCallback((event: ProtoMouseEvent) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + const data = encodeMouseEvent(event); + wsRef.current.send(data); + } + }, []); + + // Send key event + const sendKeyEvent = useCallback((event: ProtoKeyEvent) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + const data = encodeKeyEvent(event); + wsRef.current.send(data); + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + disconnect(); + }; + }, [disconnect]); + + return { + status, + connect, + disconnect, + sendMouseEvent, + sendKeyEvent, + }; +} + +/** + * Helper to create mouse event from DOM mouse event + */ +export function createMouseEvent( + domEvent: React.MouseEvent, + canvasRect: DOMRect, + displayWidth: number, + displayHeight: number, + eventType: MouseEventType +): ProtoMouseEvent { + // Calculate position relative to canvas and scale to display coordinates + const scaleX = displayWidth / canvasRect.width; + const scaleY = displayHeight / canvasRect.height; + + const x = Math.round((domEvent.clientX - canvasRect.left) * scaleX); + const y = Math.round((domEvent.clientY - canvasRect.top) * scaleY); + + return { + x, + y, + buttons: { + left: (domEvent.buttons & 1) !== 0, + right: (domEvent.buttons & 2) !== 0, + middle: (domEvent.buttons & 4) !== 0, + x1: (domEvent.buttons & 8) !== 0, + x2: (domEvent.buttons & 16) !== 0, + }, + wheelDeltaX: 0, + wheelDeltaY: 0, + eventType, + }; +} + +/** + * Helper to create key event from DOM keyboard event + */ +export function createKeyEvent( + domEvent: React.KeyboardEvent, + down: boolean +): ProtoKeyEvent { + const modifiers: Modifiers = { + ctrl: domEvent.ctrlKey, + alt: domEvent.altKey, + shift: domEvent.shiftKey, + meta: domEvent.metaKey, + capsLock: domEvent.getModifierState('CapsLock'), + numLock: domEvent.getModifierState('NumLock'), + }; + + // Use key code for special keys, unicode for regular characters + const isCharacter = domEvent.key.length === 1; + + return { + down, + keyType: isCharacter ? 2 : 0, // KEY_UNICODE or KEY_VK + vkCode: domEvent.keyCode, + scanCode: 0, // Not available in browser + unicode: isCharacter ? domEvent.key : undefined, + modifiers, + }; +} diff --git a/dashboard/src/lib/protobuf.ts b/dashboard/src/lib/protobuf.ts new file mode 100644 index 0000000..397ad0d --- /dev/null +++ b/dashboard/src/lib/protobuf.ts @@ -0,0 +1,162 @@ +/** + * Minimal protobuf encoder/decoder for GuruConnect messages + * + * For MVP, we use a simplified binary format. In production, + * this would use a proper protobuf library like protobufjs. + */ + +import type { MouseEvent, KeyEvent, MouseEventType, KeyEventType, VideoFrame, RawFrame } from '../types/protocol'; + +// Message type identifiers (matching proto field numbers) +const MSG_VIDEO_FRAME = 10; +const MSG_MOUSE_EVENT = 20; +const MSG_KEY_EVENT = 21; + +/** + * Encode a mouse event to binary format + */ +export function encodeMouseEvent(event: MouseEvent): Uint8Array { + const buffer = new ArrayBuffer(32); + const view = new DataView(buffer); + + // Message type + view.setUint8(0, MSG_MOUSE_EVENT); + + // Event type + view.setUint8(1, event.eventType); + + // Coordinates (scaled to 16-bit for efficiency) + view.setInt16(2, event.x, true); + view.setInt16(4, event.y, true); + + // Buttons bitmask + let buttons = 0; + if (event.buttons.left) buttons |= 1; + if (event.buttons.right) buttons |= 2; + if (event.buttons.middle) buttons |= 4; + if (event.buttons.x1) buttons |= 8; + if (event.buttons.x2) buttons |= 16; + view.setUint8(6, buttons); + + // Wheel deltas + view.setInt16(7, event.wheelDeltaX, true); + view.setInt16(9, event.wheelDeltaY, true); + + return new Uint8Array(buffer, 0, 11); +} + +/** + * Encode a key event to binary format + */ +export function encodeKeyEvent(event: KeyEvent): Uint8Array { + const buffer = new ArrayBuffer(32); + const view = new DataView(buffer); + + // Message type + view.setUint8(0, MSG_KEY_EVENT); + + // Key down/up + view.setUint8(1, event.down ? 1 : 0); + + // Key type + view.setUint8(2, event.keyType); + + // Virtual key code + view.setUint16(3, event.vkCode, true); + + // Scan code + view.setUint16(5, event.scanCode, true); + + // Modifiers bitmask + let mods = 0; + if (event.modifiers.ctrl) mods |= 1; + if (event.modifiers.alt) mods |= 2; + if (event.modifiers.shift) mods |= 4; + if (event.modifiers.meta) mods |= 8; + if (event.modifiers.capsLock) mods |= 16; + if (event.modifiers.numLock) mods |= 32; + view.setUint8(7, mods); + + // Unicode character (if present) + if (event.unicode && event.unicode.length > 0) { + const charCode = event.unicode.charCodeAt(0); + view.setUint16(8, charCode, true); + return new Uint8Array(buffer, 0, 10); + } + + return new Uint8Array(buffer, 0, 8); +} + +/** + * Decode a video frame from binary format + */ +export function decodeVideoFrame(data: Uint8Array): VideoFrame | null { + if (data.length < 2) return null; + + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const msgType = view.getUint8(0); + + if (msgType !== MSG_VIDEO_FRAME) return null; + + const encoding = view.getUint8(1); + const displayId = view.getUint8(2); + const sequence = view.getUint32(3, true); + const timestamp = Number(view.getBigInt64(7, true)); + + // Frame dimensions + const width = view.getUint16(15, true); + const height = view.getUint16(17, true); + + // Compressed flag + const compressed = view.getUint8(19) === 1; + + // Is keyframe + const isKeyframe = view.getUint8(20) === 1; + + // Frame data starts at offset 21 + const frameData = data.slice(21); + + const encodingStr = ['raw', 'vp9', 'h264', 'h265'][encoding] as 'raw' | 'vp9' | 'h264' | 'h265'; + + if (encodingStr === 'raw') { + return { + timestamp, + displayId, + sequence, + encoding: 'raw', + raw: { + width, + height, + data: frameData, + compressed, + dirtyRects: [], // TODO: Parse dirty rects + isKeyframe, + }, + }; + } + + return { + timestamp, + displayId, + sequence, + encoding: encodingStr, + encoded: { + data: frameData, + keyframe: isKeyframe, + pts: timestamp, + dts: timestamp, + }, + }; +} + +/** + * Simple zstd decompression placeholder + * In production, use a proper zstd library like fzstd + */ +export async function decompressZstd(data: Uint8Array): Promise { + // For MVP, assume uncompressed frames or use fzstd library + // This is a placeholder - actual implementation would use: + // import { decompress } from 'fzstd'; + // return decompress(data); + return data; +} diff --git a/dashboard/src/types/protocol.ts b/dashboard/src/types/protocol.ts new file mode 100644 index 0000000..7baa07b --- /dev/null +++ b/dashboard/src/types/protocol.ts @@ -0,0 +1,135 @@ +/** + * TypeScript types matching guruconnect.proto definitions + * These are used for WebSocket message handling in the viewer + */ + +export enum SessionType { + SCREEN_CONTROL = 0, + VIEW_ONLY = 1, + BACKSTAGE = 2, + FILE_TRANSFER = 3, +} + +export interface SessionRequest { + agentId: string; + sessionToken: string; + sessionType: SessionType; + clientVersion: string; +} + +export interface SessionResponse { + success: boolean; + sessionId: string; + error?: string; + displayInfo?: DisplayInfo; +} + +export interface DisplayInfo { + displays: Display[]; + primaryDisplay: number; +} + +export interface Display { + id: number; + name: string; + x: number; + y: number; + width: number; + height: number; + isPrimary: boolean; +} + +export interface DirtyRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface RawFrame { + width: number; + height: number; + data: Uint8Array; + compressed: boolean; + dirtyRects: DirtyRect[]; + isKeyframe: boolean; +} + +export interface EncodedFrame { + data: Uint8Array; + keyframe: boolean; + pts: number; + dts: number; +} + +export interface VideoFrame { + timestamp: number; + displayId: number; + sequence: number; + encoding: 'raw' | 'vp9' | 'h264' | 'h265'; + raw?: RawFrame; + encoded?: EncodedFrame; +} + +export enum MouseEventType { + MOUSE_MOVE = 0, + MOUSE_DOWN = 1, + MOUSE_UP = 2, + MOUSE_WHEEL = 3, +} + +export interface MouseButtons { + left: boolean; + right: boolean; + middle: boolean; + x1: boolean; + x2: boolean; +} + +export interface MouseEvent { + x: number; + y: number; + buttons: MouseButtons; + wheelDeltaX: number; + wheelDeltaY: number; + eventType: MouseEventType; +} + +export enum KeyEventType { + KEY_VK = 0, + KEY_SCAN = 1, + KEY_UNICODE = 2, +} + +export interface Modifiers { + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; + capsLock: boolean; + numLock: boolean; +} + +export interface KeyEvent { + down: boolean; + keyType: KeyEventType; + vkCode: number; + scanCode: number; + unicode?: string; + modifiers: Modifiers; +} + +export interface QualitySettings { + preset: 'auto' | 'low' | 'balanced' | 'high'; + customFps?: number; + customBitrate?: number; + codec: 'auto' | 'raw' | 'vp9' | 'h264' | 'h265'; +} + +export interface ConnectionStatus { + connected: boolean; + sessionId?: string; + latencyMs?: number; + fps?: number; + bitrateKbps?: number; +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..106610c --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/proto/guruconnect.proto b/proto/guruconnect.proto new file mode 100644 index 0000000..d8d6353 --- /dev/null +++ b/proto/guruconnect.proto @@ -0,0 +1,286 @@ +syntax = "proto3"; +package guruconnect; + +// ============================================================================ +// Session Management +// ============================================================================ + +message SessionRequest { + string agent_id = 1; + string session_token = 2; + SessionType session_type = 3; + string client_version = 4; +} + +message SessionResponse { + bool success = 1; + string session_id = 2; + string error = 3; + DisplayInfo display_info = 4; +} + +enum SessionType { + SCREEN_CONTROL = 0; + VIEW_ONLY = 1; + BACKSTAGE = 2; + FILE_TRANSFER = 3; +} + +// ============================================================================ +// Display Information +// ============================================================================ + +message DisplayInfo { + repeated Display displays = 1; + int32 primary_display = 2; +} + +message Display { + int32 id = 1; + string name = 2; + int32 x = 3; + int32 y = 4; + int32 width = 5; + int32 height = 6; + bool is_primary = 7; +} + +message SwitchDisplay { + int32 display_id = 1; +} + +// ============================================================================ +// Video Frames +// ============================================================================ + +message VideoFrame { + int64 timestamp = 1; + int32 display_id = 2; + int32 sequence = 3; + + oneof encoding { + RawFrame raw = 10; + EncodedFrame vp9 = 11; + EncodedFrame h264 = 12; + EncodedFrame h265 = 13; + } +} + +message RawFrame { + int32 width = 1; + int32 height = 2; + bytes data = 3; // Zstd compressed BGRA + bool compressed = 4; + repeated DirtyRect dirty_rects = 5; + bool is_keyframe = 6; // Full frame vs incremental +} + +message DirtyRect { + int32 x = 1; + int32 y = 2; + int32 width = 3; + int32 height = 4; +} + +message EncodedFrame { + bytes data = 1; + bool keyframe = 2; + int64 pts = 3; + int64 dts = 4; +} + +message VideoAck { + int32 sequence = 1; + int64 timestamp = 2; +} + +// ============================================================================ +// Cursor +// ============================================================================ + +message CursorShape { + uint64 id = 1; + int32 hotspot_x = 2; + int32 hotspot_y = 3; + int32 width = 4; + int32 height = 5; + bytes data = 6; // BGRA bitmap +} + +message CursorPosition { + int32 x = 1; + int32 y = 2; + bool visible = 3; +} + +// ============================================================================ +// Input Events +// ============================================================================ + +message MouseEvent { + int32 x = 1; + int32 y = 2; + MouseButtons buttons = 3; + int32 wheel_delta_x = 4; + int32 wheel_delta_y = 5; + MouseEventType event_type = 6; +} + +enum MouseEventType { + MOUSE_MOVE = 0; + MOUSE_DOWN = 1; + MOUSE_UP = 2; + MOUSE_WHEEL = 3; +} + +message MouseButtons { + bool left = 1; + bool right = 2; + bool middle = 3; + bool x1 = 4; + bool x2 = 5; +} + +message KeyEvent { + bool down = 1; // true = key down, false = key up + KeyEventType key_type = 2; + uint32 vk_code = 3; // Virtual key code (Windows VK_*) + uint32 scan_code = 4; // Hardware scan code + string unicode = 5; // Unicode character (for text input) + Modifiers modifiers = 6; +} + +enum KeyEventType { + KEY_VK = 0; // Virtual key code + KEY_SCAN = 1; // Scan code + KEY_UNICODE = 2; // Unicode character +} + +message Modifiers { + bool ctrl = 1; + bool alt = 2; + bool shift = 3; + bool meta = 4; // Windows key + bool caps_lock = 5; + bool num_lock = 6; +} + +message SpecialKeyEvent { + SpecialKey key = 1; +} + +enum SpecialKey { + CTRL_ALT_DEL = 0; + LOCK_SCREEN = 1; + PRINT_SCREEN = 2; +} + +// ============================================================================ +// Clipboard +// ============================================================================ + +message ClipboardData { + ClipboardFormat format = 1; + bytes data = 2; + string mime_type = 3; +} + +enum ClipboardFormat { + CLIPBOARD_TEXT = 0; + CLIPBOARD_HTML = 1; + CLIPBOARD_RTF = 2; + CLIPBOARD_IMAGE = 3; + CLIPBOARD_FILES = 4; +} + +message ClipboardRequest { + // Request current clipboard content +} + +// ============================================================================ +// Quality Control +// ============================================================================ + +message QualitySettings { + QualityPreset preset = 1; + int32 custom_fps = 2; // 1-60 + int32 custom_bitrate = 3; // kbps + CodecPreference codec = 4; +} + +enum QualityPreset { + QUALITY_AUTO = 0; + QUALITY_LOW = 1; // Low bandwidth + QUALITY_BALANCED = 2; + QUALITY_HIGH = 3; // Best quality +} + +enum CodecPreference { + CODEC_AUTO = 0; + CODEC_RAW = 1; // Raw + Zstd (LAN) + CODEC_VP9 = 2; + CODEC_H264 = 3; + CODEC_H265 = 4; +} + +message LatencyReport { + int64 rtt_ms = 1; + int32 fps = 2; + int32 bitrate_kbps = 3; +} + +// ============================================================================ +// Control Messages +// ============================================================================ + +message Heartbeat { + int64 timestamp = 1; +} + +message HeartbeatAck { + int64 client_timestamp = 1; + int64 server_timestamp = 2; +} + +message Disconnect { + string reason = 1; +} + +// ============================================================================ +// Top-Level Message Wrapper +// ============================================================================ + +message Message { + oneof payload { + // Session + SessionRequest session_request = 1; + SessionResponse session_response = 2; + + // Video + VideoFrame video_frame = 10; + VideoAck video_ack = 11; + SwitchDisplay switch_display = 12; + + // Cursor + CursorShape cursor_shape = 15; + CursorPosition cursor_position = 16; + + // Input + MouseEvent mouse_event = 20; + KeyEvent key_event = 21; + SpecialKeyEvent special_key = 22; + + // Clipboard + ClipboardData clipboard_data = 30; + ClipboardRequest clipboard_request = 31; + + // Quality + QualitySettings quality_settings = 40; + LatencyReport latency_report = 41; + + // Control + Heartbeat heartbeat = 50; + HeartbeatAck heartbeat_ack = 51; + Disconnect disconnect = 52; + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..15f3932 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "guruconnect-server" +version = "0.1.0" +edition = "2021" +authors = ["AZ Computer Guru"] +description = "GuruConnect Remote Desktop Relay Server" + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread", "macros"] } + +# Web framework +axum = { version = "0.7", features = ["ws", "macros"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] } + +# WebSocket +futures-util = "0.3" + +# Database +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] } + +# Protocol (protobuf) +prost = "0.13" +prost-types = "0.13" +bytes = "1" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Error handling +anyhow = "1" +thiserror = "1" + +# Configuration +toml = "0.8" + +# Auth +jsonwebtoken = "9" +argon2 = "0.5" + +# Crypto +ring = "0.17" + +# UUID +uuid = { version = "1", features = ["v4", "serde"] } + +# Time +chrono = { version = "0.4", features = ["serde"] } + +[build-dependencies] +prost-build = "0.13" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/server/build.rs b/server/build.rs new file mode 100644 index 0000000..d1606dc --- /dev/null +++ b/server/build.rs @@ -0,0 +1,11 @@ +use std::io::Result; + +fn main() -> Result<()> { + // Compile protobuf definitions + prost_build::compile_protos(&["../proto/guruconnect.proto"], &["../proto/"])?; + + // Rerun if proto changes + println!("cargo:rerun-if-changed=../proto/guruconnect.proto"); + + Ok(()) +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs new file mode 100644 index 0000000..684ed77 --- /dev/null +++ b/server/src/api/mod.rs @@ -0,0 +1,54 @@ +//! REST API endpoints + +use axum::{ + extract::{Path, State}, + Json, +}; +use serde::Serialize; +use uuid::Uuid; + +use crate::session::SessionManager; + +/// Session info returned by API +#[derive(Debug, Serialize)] +pub struct SessionInfo { + pub id: String, + pub agent_id: String, + pub agent_name: String, + pub started_at: String, + pub viewer_count: usize, +} + +impl From for SessionInfo { + fn from(s: crate::session::Session) -> Self { + Self { + id: s.id.to_string(), + agent_id: s.agent_id, + agent_name: s.agent_name, + started_at: s.started_at.to_rfc3339(), + viewer_count: s.viewer_count, + } + } +} + +/// List all active sessions +pub async fn list_sessions( + State(sessions): State, +) -> Json> { + let sessions = sessions.list_sessions().await; + Json(sessions.into_iter().map(SessionInfo::from).collect()) +} + +/// Get a specific session by ID +pub async fn get_session( + State(sessions): State, + Path(id): Path, +) -> Result, (axum::http::StatusCode, &'static str)> { + let session_id = Uuid::parse_str(&id) + .map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?; + + let session = sessions.get_session(session_id).await + .ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?; + + Ok(Json(SessionInfo::from(session))) +} diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs new file mode 100644 index 0000000..80117a9 --- /dev/null +++ b/server/src/auth/mod.rs @@ -0,0 +1,61 @@ +//! Authentication module +//! +//! Handles JWT validation for dashboard users and API key +//! validation for agents. + +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; + +/// Authenticated user from JWT +#[derive(Debug, Clone)] +pub struct AuthenticatedUser { + pub user_id: String, + pub email: String, + pub roles: Vec, +} + +/// Authenticated agent from API key +#[derive(Debug, Clone)] +pub struct AuthenticatedAgent { + pub agent_id: String, + pub org_id: String, +} + +/// Extract authenticated user from request (placeholder for MVP) +#[axum::async_trait] +impl FromRequestParts for AuthenticatedUser +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // TODO: Implement JWT validation + // For MVP, accept any request + + // Look for Authorization header + let _auth_header = parts + .headers + .get("Authorization") + .and_then(|v| v.to_str().ok()); + + // Placeholder - in production, validate JWT + Ok(AuthenticatedUser { + user_id: "mvp-user".to_string(), + email: "mvp@example.com".to_string(), + roles: vec!["admin".to_string()], + }) + } +} + +/// Validate an agent API key (placeholder for MVP) +pub fn validate_agent_key(_api_key: &str) -> Option { + // TODO: Implement actual API key validation + // For MVP, accept any key + Some(AuthenticatedAgent { + agent_id: "mvp-agent".to_string(), + org_id: "mvp-org".to_string(), + }) +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..774b28b --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,45 @@ +//! Server configuration + +use anyhow::Result; +use serde::Deserialize; +use std::env; + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + /// Address to listen on (e.g., "0.0.0.0:8080") + pub listen_addr: String, + + /// Database URL (optional for MVP) + pub database_url: Option, + + /// JWT secret for authentication + pub jwt_secret: Option, + + /// Enable debug logging + pub debug: bool, +} + +impl Config { + /// Load configuration from environment variables + pub fn load() -> Result { + Ok(Self { + listen_addr: env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()), + database_url: env::var("DATABASE_URL").ok(), + jwt_secret: env::var("JWT_SECRET").ok(), + debug: env::var("DEBUG") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false), + }) + } +} + +impl Default for Config { + fn default() -> Self { + Self { + listen_addr: "0.0.0.0:8080".to_string(), + database_url: None, + jwt_secret: None, + debug: false, + } + } +} diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs new file mode 100644 index 0000000..690b366 --- /dev/null +++ b/server/src/db/mod.rs @@ -0,0 +1,45 @@ +//! Database module +//! +//! Handles session logging and persistence. +//! Optional for MVP - sessions are kept in memory only. + +use anyhow::Result; + +/// Database connection pool (placeholder) +#[derive(Clone)] +pub struct Database { + // TODO: Add sqlx pool when PostgreSQL is needed + _placeholder: (), +} + +impl Database { + /// Initialize database connection + pub async fn init(_database_url: &str) -> Result { + // TODO: Initialize PostgreSQL connection pool + Ok(Self { _placeholder: () }) + } +} + +/// Session event for audit logging +#[derive(Debug)] +pub struct SessionEvent { + pub session_id: String, + pub event_type: SessionEventType, + pub details: Option, +} + +#[derive(Debug)] +pub enum SessionEventType { + Started, + ViewerJoined, + ViewerLeft, + Ended, +} + +impl Database { + /// Log a session event (placeholder) + pub async fn log_session_event(&self, _event: SessionEvent) -> Result<()> { + // TODO: Insert into connect_session_events table + Ok(()) + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..183660d --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,82 @@ +//! GuruConnect Server - WebSocket Relay Server +//! +//! Handles connections from both agents and dashboard viewers, +//! relaying video frames and input events between them. + +mod config; +mod relay; +mod session; +mod auth; +mod api; +mod db; + +pub mod proto { + include!(concat!(env!("OUT_DIR"), "/guruconnect.rs")); +} + +use anyhow::Result; +use axum::{ + Router, + routing::get, +}; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::trace::TraceLayer; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + let _subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .with_target(true) + .init(); + + info!("GuruConnect Server v{}", env!("CARGO_PKG_VERSION")); + + // Load configuration + let config = config::Config::load()?; + info!("Loaded configuration, listening on {}", config.listen_addr); + + // Initialize database connection (optional for MVP) + // let db = db::init(&config.database_url).await?; + + // Create session manager + let sessions = session::SessionManager::new(); + + // Build router + let app = Router::new() + // Health check + .route("/health", get(health)) + // WebSocket endpoints + .route("/ws/agent", get(relay::agent_ws_handler)) + .route("/ws/viewer", get(relay::viewer_ws_handler)) + // REST API + .route("/api/sessions", get(api::list_sessions)) + .route("/api/sessions/:id", get(api::get_session)) + // State + .with_state(sessions) + // Middleware + .layer(TraceLayer::new_for_http()) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ); + + // Start server + let addr: SocketAddr = config.listen_addr.parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + + info!("Server listening on {}", addr); + + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn health() -> &'static str { + "OK" +} diff --git a/server/src/relay/mod.rs b/server/src/relay/mod.rs new file mode 100644 index 0000000..5ef72a8 --- /dev/null +++ b/server/src/relay/mod.rs @@ -0,0 +1,194 @@ +//! WebSocket relay handlers +//! +//! Handles WebSocket connections from agents and viewers, +//! relaying video frames and input events between them. + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Query, State, + }, + response::IntoResponse, +}; +use futures_util::{SinkExt, StreamExt}; +use prost::Message as ProstMessage; +use serde::Deserialize; +use tracing::{error, info, warn}; + +use crate::proto; +use crate::session::SessionManager; + +#[derive(Debug, Deserialize)] +pub struct AgentParams { + agent_id: String, + #[serde(default)] + agent_name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ViewerParams { + session_id: String, +} + +/// WebSocket handler for agent connections +pub async fn agent_ws_handler( + ws: WebSocketUpgrade, + State(sessions): State, + Query(params): Query, +) -> impl IntoResponse { + let agent_id = params.agent_id; + let agent_name = params.agent_name.unwrap_or_else(|| agent_id.clone()); + + ws.on_upgrade(move |socket| handle_agent_connection(socket, sessions, agent_id, agent_name)) +} + +/// WebSocket handler for viewer connections +pub async fn viewer_ws_handler( + ws: WebSocketUpgrade, + State(sessions): State, + Query(params): Query, +) -> impl IntoResponse { + let session_id = params.session_id; + + ws.on_upgrade(move |socket| handle_viewer_connection(socket, sessions, session_id)) +} + +/// Handle an agent WebSocket connection +async fn handle_agent_connection( + socket: WebSocket, + sessions: SessionManager, + agent_id: String, + agent_name: String, +) { + info!("Agent connected: {} ({})", agent_name, agent_id); + + // Register the agent and get channels + let (session_id, frame_tx, mut input_rx) = sessions.register_agent(agent_id.clone(), agent_name.clone()).await; + + info!("Session created: {}", session_id); + + let (mut ws_sender, mut ws_receiver) = socket.split(); + + // Task to forward input events from viewers to agent + let input_forward = tokio::spawn(async move { + while let Some(input_data) = input_rx.recv().await { + if ws_sender.send(Message::Binary(input_data.into())).await.is_err() { + break; + } + } + }); + + // Main loop: receive frames from agent and broadcast to viewers + while let Some(msg) = ws_receiver.next().await { + match msg { + Ok(Message::Binary(data)) => { + // Try to decode as protobuf message + match proto::Message::decode(data.as_ref()) { + Ok(proto_msg) => { + if let Some(proto::message::Payload::VideoFrame(_)) = &proto_msg.payload { + // Broadcast frame to all viewers + let _ = frame_tx.send(data.to_vec()); + } + } + Err(e) => { + warn!("Failed to decode agent message: {}", e); + } + } + } + Ok(Message::Close(_)) => { + info!("Agent disconnected: {}", agent_id); + break; + } + Ok(Message::Ping(data)) => { + // Pong is handled automatically by axum + let _ = data; + } + Ok(_) => {} + Err(e) => { + error!("WebSocket error from agent {}: {}", agent_id, e); + break; + } + } + } + + // Cleanup + input_forward.abort(); + sessions.remove_session(session_id).await; + info!("Session {} ended", session_id); +} + +/// Handle a viewer WebSocket connection +async fn handle_viewer_connection( + socket: WebSocket, + sessions: SessionManager, + session_id_str: String, +) { + // Parse session ID + let session_id = match uuid::Uuid::parse_str(&session_id_str) { + Ok(id) => id, + Err(_) => { + warn!("Invalid session ID: {}", session_id_str); + return; + } + }; + + // Join the session + let (mut frame_rx, input_tx) = match sessions.join_session(session_id).await { + Some(channels) => channels, + None => { + warn!("Session not found: {}", session_id); + return; + } + }; + + info!("Viewer joined session: {}", session_id); + + let (mut ws_sender, mut ws_receiver) = socket.split(); + + // Task to forward frames from agent to this viewer + let frame_forward = tokio::spawn(async move { + while let Ok(frame_data) = frame_rx.recv().await { + if ws_sender.send(Message::Binary(frame_data.into())).await.is_err() { + break; + } + } + }); + + // Main loop: receive input from viewer and forward to agent + while let Some(msg) = ws_receiver.next().await { + match msg { + Ok(Message::Binary(data)) => { + // Try to decode as protobuf message + match proto::Message::decode(data.as_ref()) { + Ok(proto_msg) => { + match &proto_msg.payload { + Some(proto::message::Payload::MouseEvent(_)) | + Some(proto::message::Payload::KeyEvent(_)) => { + // Forward input to agent + let _ = input_tx.send(data.to_vec()).await; + } + _ => {} + } + } + Err(e) => { + warn!("Failed to decode viewer message: {}", e); + } + } + } + Ok(Message::Close(_)) => { + info!("Viewer disconnected from session: {}", session_id); + break; + } + Ok(_) => {} + Err(e) => { + error!("WebSocket error from viewer: {}", e); + break; + } + } + } + + // Cleanup + frame_forward.abort(); + sessions.leave_session(session_id).await; + info!("Viewer left session: {}", session_id); +} diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs new file mode 100644 index 0000000..c2ab775 --- /dev/null +++ b/server/src/session/mod.rs @@ -0,0 +1,148 @@ +//! Session management for GuruConnect +//! +//! Manages active remote desktop sessions, tracking which agents +//! are connected and which viewers are watching them. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{broadcast, RwLock}; +use uuid::Uuid; + +/// Unique identifier for a session +pub type SessionId = Uuid; + +/// Unique identifier for an agent +pub type AgentId = String; + +/// Session state +#[derive(Debug, Clone)] +pub struct Session { + pub id: SessionId, + pub agent_id: AgentId, + pub agent_name: String, + pub started_at: chrono::DateTime, + pub viewer_count: usize, +} + +/// Channel for sending frames from agent to viewers +pub type FrameSender = broadcast::Sender>; +pub type FrameReceiver = broadcast::Receiver>; + +/// Channel for sending input events from viewer to agent +pub type InputSender = tokio::sync::mpsc::Sender>; +pub type InputReceiver = tokio::sync::mpsc::Receiver>; + +/// Internal session data with channels +struct SessionData { + info: Session, + /// Channel for video frames (agent -> viewers) + frame_tx: FrameSender, + /// Channel for input events (viewer -> agent) + input_tx: InputSender, + input_rx: Option, +} + +/// Manages all active sessions +#[derive(Clone)] +pub struct SessionManager { + sessions: Arc>>, + agents: Arc>>, +} + +impl SessionManager { + pub fn new() -> Self { + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + agents: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Register a new agent and create a session + pub async fn register_agent(&self, agent_id: AgentId, agent_name: String) -> (SessionId, FrameSender, InputReceiver) { + let session_id = Uuid::new_v4(); + + // Create channels + let (frame_tx, _) = broadcast::channel(16); // Buffer 16 frames + let (input_tx, input_rx) = tokio::sync::mpsc::channel(64); // Buffer 64 input events + + let session = Session { + id: session_id, + agent_id: agent_id.clone(), + agent_name, + started_at: chrono::Utc::now(), + viewer_count: 0, + }; + + let session_data = SessionData { + info: session, + frame_tx: frame_tx.clone(), + input_tx, + input_rx: None, // Will be taken by the agent handler + }; + + let mut sessions = self.sessions.write().await; + sessions.insert(session_id, session_data); + + let mut agents = self.agents.write().await; + agents.insert(agent_id, session_id); + + (session_id, frame_tx, input_rx) + } + + /// Get a session by agent ID + pub async fn get_session_by_agent(&self, agent_id: &str) -> Option { + let agents = self.agents.read().await; + let session_id = agents.get(agent_id)?; + + let sessions = self.sessions.read().await; + sessions.get(session_id).map(|s| s.info.clone()) + } + + /// Get a session by session ID + pub async fn get_session(&self, session_id: SessionId) -> Option { + let sessions = self.sessions.read().await; + sessions.get(&session_id).map(|s| s.info.clone()) + } + + /// Join a session as a viewer + pub async fn join_session(&self, session_id: SessionId) -> Option<(FrameReceiver, InputSender)> { + let mut sessions = self.sessions.write().await; + let session_data = sessions.get_mut(&session_id)?; + + session_data.info.viewer_count += 1; + + let frame_rx = session_data.frame_tx.subscribe(); + let input_tx = session_data.input_tx.clone(); + + Some((frame_rx, input_tx)) + } + + /// Leave a session as a viewer + pub async fn leave_session(&self, session_id: SessionId) { + let mut sessions = self.sessions.write().await; + if let Some(session_data) = sessions.get_mut(&session_id) { + session_data.info.viewer_count = session_data.info.viewer_count.saturating_sub(1); + } + } + + /// Remove a session (when agent disconnects) + pub async fn remove_session(&self, session_id: SessionId) { + let mut sessions = self.sessions.write().await; + if let Some(session_data) = sessions.remove(&session_id) { + let mut agents = self.agents.write().await; + agents.remove(&session_data.info.agent_id); + } + } + + /// List all active sessions + pub async fn list_sessions(&self) -> Vec { + let sessions = self.sessions.read().await; + sessions.values().map(|s| s.info.clone()).collect() + } +} + +impl Default for SessionManager { + fn default() -> Self { + Self::new() + } +}