mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
fix(updates): atomic binary replace + accurate modal status
- deployServerBinary: use rename(2) for atomic replace, fixes ETXTBSY when target Go binary is busy (Linux kernel handles inode swap). Falls back to copyFileSync on cross-device rename or non-Linux. Windows: rename target out of the way first, then move new in. - settings.js: mark 'server' phase as error when build succeeded but deploy failed (was incorrectly marking 'done' from build alone). - settings.js: completion modal now shows error title, error message and pre-formatted stderr when serverDeploy.success === false. - i18n: added complete_with_errors, modal_done_with_errors_title in en/pl.
This commit is contained in:
@@ -888,6 +888,22 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git
|
||||
398. [x] **Attestation verify/revoke failing**: Same CSRF issue in `attestation.js` — both `verify()` and `revoke()` functions used non-existent meta tag. Fixed to use `window.BetterDesk?.csrfToken`.
|
||||
399. [x] **Toolkit API calls failing**: `toolkit.js` cached CSRF from meta tag at module init. Fixed to call `getCsrfToken()` dynamically which reads from `window.BetterDesk?.csrfToken`.
|
||||
|
||||
#### Agent Client — Security Hardening & Native Agent Completeness (Phase 54) ✅ COMPLETED 2026-04-10
|
||||
400. [x] **AGENT-C2 device ID entropy (CRITICAL)**: `betterdesk-agent-client/src-tauri/src/registration.rs::register` used 4 bytes of SHA-256 (~65k unique IDs, trivial brute-force). Extended to full 16 bytes (32 hex chars → 3.4·10³⁸ entropy) and mixed in hostname + package version alongside machine UID. Closes AUDIT_BETTERDESK_2026-04-17 C2 finding.
|
||||
401. [x] **AGENT-H3 URL scheme + private-IP guard (SSRF)**: New `validate_address()` in `registration.rs` — rejects non-`http(s)` schemes, literal IPs in RFC1918 / 169.254/16 / ::1 / fc00::/7 / fe80::/10 / multicast / broadcast. Called from all 4 validation steps, `register()` and `sync_config()`. Opt-out via `BETTERDESK_ALLOW_PRIVATE_IPS=1` env var for LAN deployments.
|
||||
402. [x] **Keyring wiring after registration**: `register()` now generates a local registration marker token, assigns it to `config.auth_token`, persists config, AND calls `config.store_token_secure()` (was previously defined but never invoked). Keyring failures now log `WARN` (not silent `INFO`) with explicit fallback message. Config JSON file remains as last-resort fallback.
|
||||
403. [x] **Native Go agent `clipboard_get` handler**: `betterdesk-agent/agent/agent.go` added `handleClipboardGet()` + `clipboard_get` dispatch case. Responds with `clipboard_data` envelope `{request_id, format, data[, error]}`. Returns explicit error when `cfg.Clipboard=false` instead of silent drop — the operator UI can now show meaningful state. Closes part of NATIVE-C1.
|
||||
404. [x] **Honest codec negotiation**: `handleCodecOffer` no longer hard-codes `"jpeg"`. `video_codec` set to `"jpeg"` only when `cfg.Screenshot=true`, otherwise empty string; `audio_codec` is always empty (os_agent does not stream audio). Server + operator panel now see true capabilities instead of a fake promise.
|
||||
405. [x] **Roadmap doc**: `docs/AGENT_CLIENT_ROADMAP_2026-04-10.md` — comprehensive audit with honest scope split. P0 (this session: device ID, URL validation, keyring, clipboard_get, codec honesty) = done. P1 (next session: sidecar Go agent in Tauri, chat server-side, TLS pinning UI) = scoped. P2 (separate phases, 4-6 weeks: screen capture, H.264, input injection, audio, E2E NaCl, policy engine, auto-update) = documented with exact crate choices.
|
||||
|
||||
#### Agent Client — Sidecar Architecture (Phase 55) ✅ COMPLETED 2026-04-21
|
||||
406. [x] **`sidecar.rs` created (350+ LOC)**: `SidecarManager` (`Arc<Inner>` for cheap Clone, Tauri managed state). `find_binary()` 4-step search (`$BETTERDESK_AGENT_BIN` env → exe dir → data dir → PATH). `write_go_config()` writes JSON in Go agent format (`GoAgentConfig` matching `betterdesk-agent/agent/config.go`). `spawn_process()` launches go agent with `-config <path>`. `monitor_loop()` tokio task — polls child every 5s, exponential backoff restart (5s×2^n, max 5min). `terminate_child()` — SIGTERM on Unix (`libc::kill`) + 5s grace + force kill. `Drop` impl kills child on Tauri exit.
|
||||
407. [x] **`config.rs` extended with CDAP + capability fields**: New fields: `api_key` (CDAP auth), `cdap_port` (default 21122), `allow_screen_capture` (default true), `require_consent` (default true), `allow_terminal` (default true), `allow_file_browser` (default true), `allow_clipboard` (default true), `auto_start_sidecar` (default true). Removed: `allow_remote`, `allow_file_transfer` (replaced by granular fields). Added `to_sidecar_config() -> SidecarConfig` conversion method using `directories::ProjectDirs` for data_dir.
|
||||
408. [x] **`commands.rs` — 4 new sidecar IPC commands**: `get_sidecar_status` → `SidecarStatus { running, pid, restart_count, state, binary_path, cdap_url }`. `start_sidecar` — stops previous, writes config, spawns binary, returns status. `stop_sidecar` — SIGTERM + cleanup. `restart_sidecar` — alias for start. `restart_agent_service` now delegates to `start_sidecar` instead of returning Err. `AgentSettings` struct updated with new capability fields. `save_agent_settings`/`get_agent_settings` updated to match.
|
||||
409. [x] **`lib.rs` — sidecar wired into Tauri state**: `pub mod sidecar` added. `SidecarManager` added to `AgentState`. Auto-start sidecar in `setup` closure if `auto_start_sidecar && is_registered`. 4 new sidecar commands registered in `invoke_handler![]`. Tray menu: new "Restart CDAP agent" item — calls `sidecar.stop()` + `sidecar.start()` using current config, no admin required.
|
||||
410. [x] **Agent is now truly hidden**: `skipTaskbar: true` + `visible: false` in `tauri.conf.json` already set. Agent does not appear in taskbar/dock. Main window only shows on tray click or first-time setup. Goal achieved: behaves like RustDesk desktop but invisible.
|
||||
411. [x] **Roadmap updated**: `docs/AGENT_CLIENT_ROADMAP_2026-04-21.md` — full architecture diagram, current state table, Phase 56-61 plan (bundling, continuous capture, input injection, H.264, audio, E2E NaCl). Sidecar testing procedures documented.
|
||||
|
||||
### Konfiguracja przez Zmienne Środowiskowe
|
||||
|
||||
```bash
|
||||
@@ -1157,4 +1173,4 @@ All code changes MUST include a security review as part of the implementation pr
|
||||
|
||||
---
|
||||
|
||||
*Ostatnia aktualizacja: 2026-04-10 (Phase 53: CSRF Token Fixes — Issue #112 policy save fix, attestation verify/revoke fix, toolkit API calls fix. Previous: Phase 51/52 GitHub Issue Triage, RBAC Permissions, 6-Role Hierarchy) przez GitHub Copilot*
|
||||
*Ostatnia aktualizacja: 2026-04-21 (Phase 55: Agent Client Sidecar Architecture — sidecar.rs manager, config.rs CDAP capabilities, commands.rs 4 new IPC commands, lib.rs auto-start + tray restart, roadmap 2026-04-21 created. Previous: Phase 54 Agent Security Hardening) przez GitHub Copilot*
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"test": "vitest run --environment node",
|
||||
"test:watch": "vitest --environment node",
|
||||
"tauri": "tauri",
|
||||
"tauri:build": "APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 tauri build",
|
||||
"tauri:build:deb": "APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 tauri build --bundles deb",
|
||||
"tauri:build:appimage": "APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 tauri build --bundles appimage",
|
||||
"tauri:dev": "tauri dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.14.0",
|
||||
@@ -17,6 +23,7 @@
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"sidebar": {
|
||||
"status": "Status",
|
||||
"chat": "Chat",
|
||||
"help": "Help",
|
||||
"settings": "Settings",
|
||||
"more": "More"
|
||||
},
|
||||
"app": {
|
||||
"close_agent": "Close agent",
|
||||
"close_confirm": "Are you sure you want to close the agent? It will no longer be available for remote assistance.",
|
||||
"quit_title": "Quit BetterDesk Agent",
|
||||
"quit_sudo_hint": "Enter your user password to close the agent.",
|
||||
"admin_only": "Administrator only",
|
||||
"admin_only_hint": "This section is restricted to administrators. Run the agent with administrator privileges to access it."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication Required",
|
||||
"subtitle": "Enter your user password to access this section.",
|
||||
"password": "Password",
|
||||
"password_placeholder": "Enter your password",
|
||||
"submit": "Authenticate",
|
||||
"cancel": "Cancel",
|
||||
"verifying": "Verifying\u2026",
|
||||
"wrong_password": "Incorrect password. Please try again.",
|
||||
"enter_password_error": "Password cannot be empty.",
|
||||
"sudo_error": "Authentication service is not available on this system."
|
||||
},
|
||||
"admin_required": {
|
||||
"title": "Administrator rights required",
|
||||
"message": "This section can only be accessed when the agent runs with local administrator privileges.",
|
||||
"hint": "Ask your IT administrator to launch the agent as Administrator, or right-click the installer and choose \"Run as administrator\"."
|
||||
},
|
||||
"status": {
|
||||
"title": "Connection Status",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"connecting": "Connecting…",
|
||||
"registering": "Registering…",
|
||||
"server": "Server",
|
||||
"device_id": "Device ID",
|
||||
"hostname": "Hostname",
|
||||
"platform": "Platform",
|
||||
"version": "Version",
|
||||
"uptime": "Session Uptime",
|
||||
"last_sync": "Last Sync",
|
||||
"copy_id": "Copy ID",
|
||||
"id_copied": "ID copied to clipboard",
|
||||
"reconnect": "Reconnect",
|
||||
"send_diagnostics": "Send Diagnostics",
|
||||
"diagnostics_sent": "Diagnostics sent to server",
|
||||
"diagnostics_error": "Failed to send diagnostics",
|
||||
"sidecar_title": "CDAP Agent",
|
||||
"sidecar_running": "Running",
|
||||
"sidecar_stopped": "Stopped",
|
||||
"sidecar_not_configured": "Not configured",
|
||||
"sidecar_restarts_hint": "Number of automatic restarts",
|
||||
"sidecar_start": "Start",
|
||||
"sidecar_stop": "Stop",
|
||||
"sidecar_restart": "Restart"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Server Setup",
|
||||
"subtitle": "Connect this device to a BetterDesk server",
|
||||
"server_address": "Server address",
|
||||
"server_placeholder": "e.g. betterdesk.example.com or 192.168.1.100",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"finish": "Finish",
|
||||
"step_address": "Server Address",
|
||||
"step_validate": "Validation",
|
||||
"step_register": "Registration",
|
||||
"step_sync": "Synchronization",
|
||||
"step_complete": "Complete",
|
||||
"validating": "Validating server…",
|
||||
"checking_availability": "Checking server availability",
|
||||
"checking_protocol": "Verifying protocol compatibility",
|
||||
"checking_registration": "Checking registration capability",
|
||||
"checking_certificate": "Validating certificate trust",
|
||||
"validation_ok": "Server validated successfully",
|
||||
"validation_failed": "Server validation failed",
|
||||
"registering": "Registering device…",
|
||||
"register_ok": "Device registered successfully",
|
||||
"register_failed": "Registration failed",
|
||||
"syncing": "Synchronizing configuration…",
|
||||
"sync_ok": "Configuration synchronized",
|
||||
"sync_failed": "Synchronization failed",
|
||||
"complete_title": "Setup Complete",
|
||||
"complete_message": "This device is now connected to the BetterDesk server and ready for remote management.",
|
||||
"error_empty": "Server address is required",
|
||||
"error_format": "Invalid server address format",
|
||||
"error_unreachable": "Server is not reachable",
|
||||
"error_protocol": "Protocol version mismatch",
|
||||
"error_registration": "Server does not accept registrations",
|
||||
"error_certificate": "Certificate validation failed"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chat",
|
||||
"placeholder": "Type a message…",
|
||||
"send": "Send",
|
||||
"no_messages": "No messages yet",
|
||||
"operator": "Operator",
|
||||
"you": "You",
|
||||
"new_message": "New message from operator",
|
||||
"connection_required": "Connection to server required for chat"
|
||||
},
|
||||
"help": {
|
||||
"title": "Request Help",
|
||||
"description": "Send a help request to an available operator. An operator will connect to assist you.",
|
||||
"message_label": "Describe your issue (optional)",
|
||||
"message_placeholder": "What do you need help with?",
|
||||
"send": "Request Help",
|
||||
"sending": "Sending…",
|
||||
"sent_title": "Help Requested",
|
||||
"sent_message": "An operator has been notified. They will connect shortly.",
|
||||
"cancel": "Cancel Request",
|
||||
"active_title": "Help Session Active",
|
||||
"active_message": "An operator is currently connected to your device.",
|
||||
"end_session": "End Session",
|
||||
"error": "Failed to send help request"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"section_connection": "Connection",
|
||||
"server_address": "Server address",
|
||||
"test_connection": "Test Connection",
|
||||
"connection_ok": "Connection successful",
|
||||
"connection_failed": "Connection failed",
|
||||
"section_cdap": "CDAP Agent",
|
||||
"api_key": "API Key",
|
||||
"api_key_placeholder": "Paste API key from server",
|
||||
"api_key_hint": "Create an API key in the web console under Server → API Keys.",
|
||||
"cdap_port": "CDAP port",
|
||||
"auto_start_sidecar": "Auto-start CDAP agent",
|
||||
"auto_start_sidecar_hint": "Start the Go CDAP agent automatically with the app",
|
||||
"section_privacy": "Privacy & Capabilities",
|
||||
"allow_screen_capture": "Allow screen capture",
|
||||
"allow_screen_capture_hint": "Operators can view and control this device’s screen",
|
||||
"require_consent": "Require consent for each session",
|
||||
"require_consent_hint": "A dialog will appear asking for approval before each remote session",
|
||||
"allow_terminal": "Allow terminal access",
|
||||
"allow_terminal_hint": "Operators can open a remote shell on this device",
|
||||
"allow_file_browser": "Allow file browser",
|
||||
"allow_file_browser_hint": "Operators can browse and transfer files",
|
||||
"allow_clipboard": "Allow clipboard sync",
|
||||
"allow_clipboard_hint": "Clipboard content is synced with the operator",
|
||||
"section_general": "General",
|
||||
"language": "Language",
|
||||
"start_with_system": "Start with system",
|
||||
"start_minimized": "Start minimized to tray",
|
||||
"section_about": "About",
|
||||
"app_version": "Agent version",
|
||||
"restart_service": "Restart Service",
|
||||
"service_restarted": "Service restarted",
|
||||
"unregister": "Unregister Device",
|
||||
"unregister_confirm": "Unregister this device from the server? You will need to set up the connection again.",
|
||||
"unregister_ok": "Device unregistered"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading…",
|
||||
"error": "Error",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Screen Access Request",
|
||||
"operator_suffix": "wants to view your screen.",
|
||||
"hint": "The operator will be able to see your current screen but cannot control your computer unless you also grant input access.",
|
||||
"auto_deny_in": "Auto-deny in",
|
||||
"allow": "Allow",
|
||||
"deny": "Deny"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"sidebar": {
|
||||
"status": "Status",
|
||||
"chat": "Czat",
|
||||
"help": "Pomoc",
|
||||
"settings": "Ustawienia",
|
||||
"more": "Więcej"
|
||||
},
|
||||
"app": {
|
||||
"close_agent": "Zamknij agenta",
|
||||
"close_confirm": "Czy na pewno chcesz zamknąć agenta? Nie będzie on dostępny do zdalnej pomocy.",
|
||||
"quit_title": "Zamknij BetterDesk Agent",
|
||||
"quit_sudo_hint": "Wpisz hasło użytkownika, aby zamknąć agenta.",
|
||||
"admin_only": "Tylko administrator",
|
||||
"admin_only_hint": "Ta sekcja jest dostępna tylko dla administratorów. Uruchom agenta z uprawnieniami administratora, aby uzyskać dostęp."
|
||||
},
|
||||
"auth": {
|
||||
"title": "Wymagane uwierzytelnienie",
|
||||
"subtitle": "Wpisz hasło użytkownika, aby uzyskać dostęp do tej sekcji.",
|
||||
"password": "Hasło",
|
||||
"password_placeholder": "Wpisz hasło",
|
||||
"submit": "Uwierzytelnij",
|
||||
"cancel": "Anuluj",
|
||||
"verifying": "Weryfikacja\u2026",
|
||||
"wrong_password": "Nieprawidłowe hasło. Spróbuj ponownie.",
|
||||
"enter_password_error": "Hasło nie może być puste.",
|
||||
"sudo_error": "Usługa uwierzytelniania jest niedostępna w tym systemie."
|
||||
},
|
||||
"admin_required": {
|
||||
"title": "Wymagane uprawnienia administratora",
|
||||
"message": "Ten ekran jest dostępny tylko gdy agent jest uruchomiony z uprawnieniami administratora.",
|
||||
"hint": "Poproś administratora IT o uruchomienie agenta jako Administrator lub kliknij instalator prawym przyciskiem i wybierz \"Uruchom jako administrator\"."
|
||||
},
|
||||
"status": {
|
||||
"title": "Status połączenia",
|
||||
"connected": "Połączono",
|
||||
"disconnected": "Rozłączono",
|
||||
"connecting": "Łączenie…",
|
||||
"registering": "Rejestracja…",
|
||||
"server": "Serwer",
|
||||
"device_id": "ID urządzenia",
|
||||
"hostname": "Nazwa hosta",
|
||||
"platform": "Platforma",
|
||||
"version": "Wersja",
|
||||
"uptime": "Czas sesji",
|
||||
"last_sync": "Ostatnia synchronizacja",
|
||||
"copy_id": "Kopiuj ID",
|
||||
"id_copied": "ID skopiowano do schowka",
|
||||
"reconnect": "Połącz ponownie",
|
||||
"send_diagnostics": "Wyślij diagnostykę",
|
||||
"sidecar_title": "Agent CDAP",
|
||||
"sidecar_running": "Uruchomiony",
|
||||
"sidecar_stopped": "Zatrzymany",
|
||||
"sidecar_not_configured": "Nie skonfigurowany",
|
||||
"sidecar_restarts_hint": "Liczba automatycznych restartów",
|
||||
"sidecar_start": "Uruchom",
|
||||
"sidecar_stop": "Zatrzymaj",
|
||||
"sidecar_restart": "Restartuj",
|
||||
"diagnostics_sent": "Diagnostyka wysłana na serwer",
|
||||
"diagnostics_error": "Nie udało się wysłać diagnostyki"
|
||||
},
|
||||
"setup": {
|
||||
"title": "Konfiguracja serwera",
|
||||
"subtitle": "Połącz to urządzenie z serwerem BetterDesk",
|
||||
"server_address": "Adres serwera",
|
||||
"server_placeholder": "np. betterdesk.example.com lub 192.168.1.100",
|
||||
"next": "Dalej",
|
||||
"back": "Wstecz",
|
||||
"finish": "Zakończ",
|
||||
"step_address": "Adres serwera",
|
||||
"step_validate": "Walidacja",
|
||||
"step_register": "Rejestracja",
|
||||
"step_sync": "Synchronizacja",
|
||||
"step_complete": "Gotowe",
|
||||
"validating": "Walidacja serwera…",
|
||||
"checking_availability": "Sprawdzanie dostępności serwera",
|
||||
"checking_protocol": "Weryfikacja zgodności protokołu",
|
||||
"checking_registration": "Sprawdzanie możliwości rejestracji",
|
||||
"checking_certificate": "Walidacja certyfikatu",
|
||||
"validation_ok": "Serwer zwalidowany pomyślnie",
|
||||
"validation_failed": "Walidacja serwera nie powiodła się",
|
||||
"registering": "Rejestracja urządzenia…",
|
||||
"register_ok": "Urządzenie zarejestrowane pomyślnie",
|
||||
"register_failed": "Rejestracja nie powiodła się",
|
||||
"syncing": "Synchronizacja konfiguracji…",
|
||||
"sync_ok": "Konfiguracja zsynchronizowana",
|
||||
"sync_failed": "Synchronizacja nie powiodła się",
|
||||
"complete_title": "Konfiguracja zakończona",
|
||||
"complete_message": "Urządzenie jest teraz połączone z serwerem BetterDesk i gotowe do zdalnego zarządzania.",
|
||||
"error_empty": "Adres serwera jest wymagany",
|
||||
"error_format": "Nieprawidłowy format adresu serwera",
|
||||
"error_unreachable": "Serwer jest nieosiągalny",
|
||||
"error_protocol": "Niezgodność wersji protokołu",
|
||||
"error_registration": "Serwer nie przyjmuje rejestracji",
|
||||
"error_certificate": "Walidacja certyfikatu nie powiodła się"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Czat",
|
||||
"placeholder": "Napisz wiadomość…",
|
||||
"send": "Wyślij",
|
||||
"no_messages": "Brak wiadomości",
|
||||
"operator": "Operator",
|
||||
"you": "Ty",
|
||||
"new_message": "Nowa wiadomość od operatora",
|
||||
"connection_required": "Wymagane połączenie z serwerem dla czatu"
|
||||
},
|
||||
"help": {
|
||||
"title": "Prośba o pomoc",
|
||||
"description": "Wyślij prośbę o pomoc do dostępnego operatora. Operator połączy się, aby Ci pomóc.",
|
||||
"message_label": "Opisz problem (opcjonalnie)",
|
||||
"message_placeholder": "W czym potrzebujesz pomocy?",
|
||||
"send": "Poproś o pomoc",
|
||||
"sending": "Wysyłanie…",
|
||||
"sent_title": "Prośba wysłana",
|
||||
"sent_message": "Operator został powiadomiony. Wkrótce się połączy.",
|
||||
"cancel": "Anuluj prośbę",
|
||||
"active_title": "Sesja pomocy aktywna",
|
||||
"active_message": "Operator jest aktualnie połączony z Twoim urządzeniem.",
|
||||
"end_session": "Zakończ sesję",
|
||||
"error": "Nie udało się wysłać prośby o pomoc"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia",
|
||||
"section_connection": "Połączenie",
|
||||
"server_address": "Adres serwera",
|
||||
"test_connection": "Testuj połączenie",
|
||||
"connection_ok": "Połączenie udane",
|
||||
"connection_failed": "Połączenie nieudane",
|
||||
"section_cdap": "Agent CDAP",
|
||||
"api_key": "Klucz API",
|
||||
"api_key_placeholder": "Wklej klucz API z serwera",
|
||||
"api_key_hint": "Utwórz klucz API w konsoli webowej: Serwer → Klucze API.",
|
||||
"cdap_port": "Port CDAP",
|
||||
"auto_start_sidecar": "Automatyczne uruchamianie agenta CDAP",
|
||||
"auto_start_sidecar_hint": "Uruchamia agenta Go CDAP razem z aplikacją",
|
||||
"section_privacy": "Prywatność i uprawnienia",
|
||||
"allow_screen_capture": "Zezwól na przechwytywanie ekranu",
|
||||
"allow_screen_capture_hint": "Operatorzy mogą wyświetlać i sterować ekranem urządzenia",
|
||||
"require_consent": "Wymagaj zgody na każdą sesję",
|
||||
"require_consent_hint": "Pojawi się okno z prośbą o zgodę przed każdą sesją zdalną",
|
||||
"allow_terminal": "Zezwól na dostęp do terminala",
|
||||
"allow_terminal_hint": "Operatorzy mogą otworzyć zdalne powłoki na tym urządzeniu",
|
||||
"allow_file_browser": "Zezwól na przeglądanie plików",
|
||||
"allow_file_browser_hint": "Operatorzy mogą przeglądać i przesyłać pliki",
|
||||
"allow_clipboard": "Zezwól na synchronizację schowka",
|
||||
"allow_clipboard_hint": "Zawartość schowka jest synchronizowana z operatorem",
|
||||
"section_general": "Ogólne",
|
||||
"language": "Język",
|
||||
"start_with_system": "Uruchom z systemem",
|
||||
"start_minimized": "Uruchom zminimalizowany do zasobnika",
|
||||
"section_about": "Informacje",
|
||||
"app_version": "Wersja agenta",
|
||||
"restart_service": "Uruchom ponownie usługę",
|
||||
"service_restarted": "Usługa uruchomiona ponownie",
|
||||
"unregister": "Wyrejestruj urządzenie",
|
||||
"unregister_confirm": "Wyrejestrować to urządzenie z serwera? Będziesz musiał ponownie skonfigurować połączenie.",
|
||||
"unregister_ok": "Urządzenie wyrejestrowane"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Ładowanie…",
|
||||
"error": "Błąd",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj",
|
||||
"confirm": "Potwierdź",
|
||||
"yes": "Tak",
|
||||
"no": "Nie",
|
||||
"ok": "OK"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Żądanie dostępu do ekranu",
|
||||
"operator_suffix": "chce wyświetlić Twój ekran.",
|
||||
"hint": "Operator będzie widział Twój bieżący ekran, ale nie będzie mógł sterować komputerem, chyba że udzielisz mu dostępu do klawiatury i myszy.",
|
||||
"auto_deny_in": "Automatyczna odmowa za",
|
||||
"allow": "Zezwól",
|
||||
"deny": "Odmów"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"sidebar": {
|
||||
"status": "狀態",
|
||||
"chat": "聊天",
|
||||
"help": "求助請求",
|
||||
"settings": "設定"
|
||||
},
|
||||
"admin_required": {
|
||||
"title": "需要管理員權限",
|
||||
"message": "此頁面僅在代理以本地管理員權限運行時可用。",
|
||||
"hint": "請 IT 管理員以管理員身份啟動代理,或右鍵點擊安裝程式並選擇「以管理員身份執行」。"
|
||||
},
|
||||
"status": {
|
||||
"title": "連線狀態",
|
||||
"connected": "已連線",
|
||||
"disconnected": "已斷線",
|
||||
"connecting": "連線中…",
|
||||
"registering": "註冊中…",
|
||||
"server": "伺服器",
|
||||
"device_id": "裝置 ID",
|
||||
"hostname": "主機名稱",
|
||||
"platform": "平台",
|
||||
"version": "版本",
|
||||
"uptime": "工作階段時間",
|
||||
"last_sync": "上次同步",
|
||||
"copy_id": "複製 ID",
|
||||
"id_copied": "ID 已複製到剪貼簿",
|
||||
"reconnect": "重新連線",
|
||||
"send_diagnostics": "傳送診斷資訊",
|
||||
"diagnostics_sent": "診斷資訊已傳送至伺服器",
|
||||
"diagnostics_error": "傳送診斷資訊失敗"
|
||||
},
|
||||
"setup": {
|
||||
"title": "伺服器設定",
|
||||
"subtitle": "將此裝置連線到 BetterDesk 伺服器",
|
||||
"server_address": "伺服器位址",
|
||||
"server_placeholder": "例如 betterdesk.example.com 或 192.168.1.100",
|
||||
"next": "下一步",
|
||||
"back": "上一步",
|
||||
"finish": "完成",
|
||||
"step_address": "伺服器位址",
|
||||
"step_validate": "驗證",
|
||||
"step_register": "註冊",
|
||||
"step_sync": "同步",
|
||||
"step_complete": "完成",
|
||||
"validating": "正在驗證伺服器…",
|
||||
"checking_availability": "正在檢查伺服器可用性",
|
||||
"checking_protocol": "正在驗證通訊協定相容性",
|
||||
"checking_registration": "正在檢查註冊功能",
|
||||
"checking_certificate": "正在驗證憑證信任",
|
||||
"validation_ok": "伺服器驗證成功",
|
||||
"validation_failed": "伺服器驗證失敗",
|
||||
"registering": "正在註冊裝置…",
|
||||
"register_ok": "裝置註冊成功",
|
||||
"register_failed": "註冊失敗",
|
||||
"syncing": "正在同步設定…",
|
||||
"sync_ok": "設定同步完成",
|
||||
"sync_failed": "同步失敗",
|
||||
"complete_title": "設定完成",
|
||||
"complete_message": "此裝置已連線到 BetterDesk 伺服器,可以進行遠端管理。",
|
||||
"error_empty": "伺服器位址為必填欄位",
|
||||
"error_format": "伺服器位址格式無效",
|
||||
"error_unreachable": "無法連線到伺服器",
|
||||
"error_protocol": "通訊協定版本不符",
|
||||
"error_registration": "伺服器不接受註冊",
|
||||
"error_certificate": "憑證驗證失敗"
|
||||
},
|
||||
"chat": {
|
||||
"title": "聊天",
|
||||
"placeholder": "輸入訊息…",
|
||||
"send": "傳送",
|
||||
"no_messages": "尚無訊息",
|
||||
"operator": "操作員",
|
||||
"you": "您",
|
||||
"new_message": "來自操作員的新訊息",
|
||||
"connection_required": "需要連線到伺服器才能使用聊天"
|
||||
},
|
||||
"help": {
|
||||
"title": "請求協助",
|
||||
"description": "向可用的操作員傳送協助請求。操作員將連線為您提供協助。",
|
||||
"message_label": "描述您的問題(選填)",
|
||||
"message_placeholder": "您需要什麼協助?",
|
||||
"send": "請求協助",
|
||||
"sending": "傳送中…",
|
||||
"sent_title": "已請求協助",
|
||||
"sent_message": "已通知操作員,他們將很快連線。",
|
||||
"cancel": "取消請求",
|
||||
"active_title": "協助工作階段進行中",
|
||||
"active_message": "操作員目前已連線到您的裝置。",
|
||||
"end_session": "結束工作階段",
|
||||
"error": "傳送協助請求失敗"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"section_connection": "連線",
|
||||
"server_address": "伺服器位址",
|
||||
"test_connection": "測試連線",
|
||||
"connection_ok": "連線成功",
|
||||
"connection_failed": "連線失敗",
|
||||
"section_privacy": "隱私與同意",
|
||||
"allow_remote": "允許遠端存取",
|
||||
"allow_remote_hint": "操作員可以檢視和控制此裝置",
|
||||
"require_consent": "每次工作階段需要同意",
|
||||
"require_consent_hint": "每次遠端工作階段前都會詢問您",
|
||||
"allow_file_transfer": "允許檔案傳輸",
|
||||
"allow_file_transfer_hint": "操作員可以傳送和接收檔案",
|
||||
"section_general": "一般",
|
||||
"language": "語言",
|
||||
"start_with_system": "隨系統啟動",
|
||||
"start_minimized": "啟動時最小化到系統匣",
|
||||
"section_about": "關於",
|
||||
"app_version": "Agent 版本",
|
||||
"restart_service": "重新啟動服務",
|
||||
"service_restarted": "服務已重新啟動",
|
||||
"unregister": "取消註冊裝置",
|
||||
"unregister_confirm": "要從伺服器取消註冊此裝置嗎?您需要重新設定連線。",
|
||||
"unregister_ok": "裝置已取消註冊"
|
||||
},
|
||||
"common": {
|
||||
"loading": "載入中…",
|
||||
"error": "錯誤",
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"confirm": "確認",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"ok": "確定"
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ tauri-plugin-notification = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "macros"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "macros", "process", "io-util"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
anyhow = "1"
|
||||
@@ -38,6 +38,16 @@ reqwest = { version = "0.12", features = ["json", "native-tls"] }
|
||||
keyring = "3"
|
||||
url = "2"
|
||||
sha2 = "0.10"
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
tokio-native-tls = "0.3"
|
||||
native-tls = "0.2"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
crypto_box = { version = "0.9", features = ["std"] }
|
||||
portable-pty = "0.8"
|
||||
home = "0.5"
|
||||
enigo = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
arboard = { version = "3", default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSScreenCaptureUsageDescription</key>
|
||||
<string>BetterDesk Agent needs Screen Recording to allow remote desktop access by operators.</string>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
<string>BetterDesk Agent needs Accessibility access to inject keyboard and mouse input during remote sessions.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>BetterDesk Agent may capture microphone audio during remote sessions when explicitly requested by an operator.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Name={{name}}
|
||||
Comment={{short_description}}
|
||||
Exec={{exec}}
|
||||
Icon={{icon}}
|
||||
StartupWMClass={{name}}
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Categories=Network;RemoteAccess;System;
|
||||
Keywords=remote;desktop;agent;management;betterdesk;
|
||||
NoDisplay=false
|
||||
BIN
Binary file not shown.
@@ -1,3 +1,100 @@
|
||||
use std::{env, path::PathBuf, process::Command};
|
||||
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
build_go_sidecar();
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
/// Compile the `betterdesk-agent` Go binary and place it in
|
||||
/// `src-tauri/binaries/` using the Tauri externalBin naming convention:
|
||||
/// `betterdesk-agent-<target-triple>[.exe]`.
|
||||
///
|
||||
/// Silently skips if Go is not installed or the agent source is missing —
|
||||
/// the developer can still run using the system-installed binary via PATH
|
||||
/// or `$BETTERDESK_AGENT_BIN`.
|
||||
fn build_go_sidecar() {
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
|
||||
// The agent lives two levels up: <repo>/betterdesk-agent/
|
||||
let agent_dir = manifest_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("betterdesk-agent"))
|
||||
.unwrap_or_default();
|
||||
|
||||
if !agent_dir.exists() {
|
||||
println!(
|
||||
"cargo:warning=[sidecar] betterdesk-agent not found at {:?} — skipping Go build",
|
||||
agent_dir
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map Cargo target triple → Go GOOS/GOARCH
|
||||
let target_triple = env::var("TARGET").unwrap_or_default();
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
|
||||
let (goos, goarch) = match (target_os.as_str(), target_arch.as_str()) {
|
||||
("windows", "x86_64") => ("windows", "amd64"),
|
||||
("windows", "aarch64") => ("windows", "arm64"),
|
||||
("linux", "x86_64") => ("linux", "amd64"),
|
||||
("linux", "aarch64") => ("linux", "arm64"),
|
||||
("macos" | "darwin", "x86_64") => ("darwin", "amd64"),
|
||||
("macos" | "darwin", "aarch64") => ("darwin", "arm64"),
|
||||
_ => ("linux", "amd64"),
|
||||
};
|
||||
|
||||
let bin_name = if goos == "windows" {
|
||||
format!("betterdesk-agent-{}.exe", target_triple)
|
||||
} else {
|
||||
format!("betterdesk-agent-{}", target_triple)
|
||||
};
|
||||
|
||||
let binaries_dir = manifest_dir.join("binaries");
|
||||
std::fs::create_dir_all(&binaries_dir).ok();
|
||||
let output_path = binaries_dir.join(&bin_name);
|
||||
|
||||
let status = Command::new("go")
|
||||
.current_dir(&agent_dir)
|
||||
.env("GOOS", goos)
|
||||
.env("GOARCH", goarch)
|
||||
.env("CGO_ENABLED", "0")
|
||||
.args(["build", "-ldflags", "-s -w", "-o", output_path.to_str().unwrap(), "."])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!(
|
||||
"cargo:warning=[sidecar] Built Go agent → {}",
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
Ok(s) => {
|
||||
println!(
|
||||
"cargo:warning=[sidecar] Go build failed (exit {}). The agent binary must be placed in PATH manually.",
|
||||
s
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"cargo:warning=[sidecar] Go not found ({}). Install Go 1.21+ or set BETTERDESK_AGENT_BIN.",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run whenever Go sources change
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
agent_dir.join("go.mod").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
agent_dir.join("main.go").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
agent_dir.join("agent").display()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default BetterDesk Agent capabilities",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{}
|
||||
{"default":{"identifier":"default","description":"Default BetterDesk Agent capabilities","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
//! OS-level autostart management.
|
||||
//!
|
||||
//! Wraps `tauri-plugin-autostart` so that our persisted `config.autostart`
|
||||
//! preference is always mirrored to the operating system:
|
||||
//! - Linux: `~/.config/autostart/betterdesk-agent-client.desktop`
|
||||
//! - Windows: HKCU `Software\Microsoft\Windows\CurrentVersion\Run` entry
|
||||
//! - macOS: `~/Library/LaunchAgents/com.betterdesk.agent.plist`
|
||||
//!
|
||||
//! The plugin launches the app with the `--autostart` CLI flag so the window
|
||||
//! stays hidden on boot and only the tray icon is shown.
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
/// Ensure the OS autostart registration matches `enabled`.
|
||||
///
|
||||
/// Safe to call multiple times — both `enable()` and `disable()` are idempotent
|
||||
/// in the plugin. Logs a warning on failure but never panics; persisted config
|
||||
/// remains authoritative even if the OS hook fails (e.g. read-only profile).
|
||||
pub fn sync_os_autostart(app: &AppHandle, enabled: bool) {
|
||||
let manager = app.autolaunch();
|
||||
|
||||
match manager.is_enabled() {
|
||||
Ok(current) if current == enabled => {
|
||||
info!(
|
||||
"[autostart] Already {} — no change",
|
||||
if enabled { "enabled" } else { "disabled" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!("[autostart] Failed to query state: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let result = if enabled {
|
||||
manager.enable()
|
||||
} else {
|
||||
manager.disable()
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => info!(
|
||||
"[autostart] OS registration {}",
|
||||
if enabled { "enabled" } else { "disabled" }
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"[autostart] Failed to {}: {}",
|
||||
if enabled { "enable" } else { "disable" },
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
//! E2E chat encryption — X25519 ECDH + XSalsa20-Poly1305 (NaCl box).
|
||||
//!
|
||||
//! Matches the Go server implementation in `betterdesk-server/cdap/crypto.go`.
|
||||
//! Key exchange flow:
|
||||
//! 1. Agent generates an X25519 keypair on first start; persisted to keyring.
|
||||
//! 2. On CDAP connection agent sends `key_exchange { type:"offer", public_key }`.
|
||||
//! 3. Server forwards offer to the operator panel; operator's ECDH public key
|
||||
//! arrives as `key_exchange { type:"answer", public_key }`.
|
||||
//! 4. Shared secret = ECDH(localPriv, remotePub).
|
||||
//! 5. Every chat message is encrypted with `crypto_box::seal` (random nonce).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use crypto_box::{
|
||||
aead::{Aead, AeadCore, OsRng},
|
||||
PublicKey, SalsaBox, SecretKey,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ── Serialisable keypair ──────────────────────────────────────────────────
|
||||
|
||||
/// X25519 keypair stored in the keyring / config.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatKeyPair {
|
||||
/// Base64-encoded 32-byte X25519 public key.
|
||||
pub public_key_b64: String,
|
||||
/// Base64-encoded 32-byte X25519 secret key.
|
||||
secret_key_b64: String,
|
||||
}
|
||||
|
||||
impl ChatKeyPair {
|
||||
/// Generate a new random keypair.
|
||||
pub fn generate() -> Self {
|
||||
let secret = SecretKey::generate(&mut OsRng);
|
||||
let public = secret.public_key();
|
||||
ChatKeyPair {
|
||||
public_key_b64: B64.encode(public.as_bytes()),
|
||||
secret_key_b64: B64.encode(secret.to_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore from stored Base64 values.
|
||||
pub fn from_b64(pub_b64: &str, priv_b64: &str) -> Result<Self> {
|
||||
// Validate both keys decode to 32 bytes.
|
||||
let pub_bytes = B64.decode(pub_b64).context("public key decode")?;
|
||||
let priv_bytes = B64.decode(priv_b64).context("secret key decode")?;
|
||||
if pub_bytes.len() != 32 || priv_bytes.len() != 32 {
|
||||
return Err(anyhow!("Key length mismatch (expected 32 bytes)"));
|
||||
}
|
||||
Ok(ChatKeyPair {
|
||||
public_key_b64: pub_b64.to_string(),
|
||||
secret_key_b64: priv_b64.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn secret_key(&self) -> Result<SecretKey> {
|
||||
let bytes: [u8; 32] = B64
|
||||
.decode(&self.secret_key_b64)
|
||||
.context("secret key decode")?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("secret key bad length"))?;
|
||||
Ok(SecretKey::from(bytes))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn public_key(&self) -> Result<PublicKey> {
|
||||
let bytes: [u8; 32] = B64
|
||||
.decode(&self.public_key_b64)
|
||||
.context("public key decode")?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("public key bad length"))?;
|
||||
Ok(PublicKey::from(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Encrypted message ─────────────────────────────────────────────────────
|
||||
|
||||
/// Wire format for an encrypted chat message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptedMessage {
|
||||
/// Base64-encoded 24-byte nonce.
|
||||
pub nonce: String,
|
||||
/// Base64-encoded ciphertext (XSalsa20-Poly1305).
|
||||
pub ciphertext: String,
|
||||
/// Sender's public key (Base64) so the receiver can derive the box.
|
||||
pub sender_pub: String,
|
||||
}
|
||||
|
||||
// ── Chat crypto session ───────────────────────────────────────────────────
|
||||
|
||||
/// Active E2E chat session between this agent and one operator.
|
||||
#[allow(dead_code)]
|
||||
struct Session {
|
||||
local_keypair: ChatKeyPair,
|
||||
/// Derived SalsaBox once remote public key is known.
|
||||
salsa_box: Option<SalsaBox>,
|
||||
remote_pub_b64: Option<String>,
|
||||
}
|
||||
|
||||
/// Thread-safe E2E chat crypto state.
|
||||
pub struct ChatCrypto {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
keypair: ChatKeyPair,
|
||||
session: Option<Session>,
|
||||
}
|
||||
|
||||
impl ChatCrypto {
|
||||
/// Initialise with a persistent keypair (generate if None).
|
||||
pub fn new(stored_keypair: Option<ChatKeyPair>) -> Self {
|
||||
let keypair = stored_keypair.unwrap_or_else(ChatKeyPair::generate);
|
||||
ChatCrypto {
|
||||
inner: Mutex::new(Inner {
|
||||
keypair,
|
||||
session: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return our X25519 public key in Base64 (for the key_exchange offer).
|
||||
pub fn public_key_b64(&self) -> String {
|
||||
self.inner.lock().unwrap().keypair.public_key_b64.clone()
|
||||
}
|
||||
|
||||
/// Persist the keypair (call after generating a new one).
|
||||
pub fn export_keypair(&self) -> ChatKeyPair {
|
||||
self.inner.lock().unwrap().keypair.clone()
|
||||
}
|
||||
|
||||
/// Accept the remote party's public key and derive the shared secret.
|
||||
pub fn accept_remote_key(&self, remote_pub_b64: &str) -> Result<()> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
let local_secret = inner.keypair.secret_key()?;
|
||||
|
||||
let remote_bytes: [u8; 32] = B64
|
||||
.decode(remote_pub_b64)
|
||||
.context("remote pub key decode")?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("remote public key bad length"))?;
|
||||
let remote_pub = PublicKey::from(remote_bytes);
|
||||
|
||||
let salsa_box = SalsaBox::new(&remote_pub, &local_secret);
|
||||
|
||||
inner.session = Some(Session {
|
||||
local_keypair: inner.keypair.clone(),
|
||||
salsa_box: Some(salsa_box),
|
||||
remote_pub_b64: Some(remote_pub_b64.to_string()),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext string. Returns `Err` if no session established.
|
||||
pub fn encrypt(&self, plaintext: &str) -> Result<EncryptedMessage> {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
let session = inner.session.as_ref().ok_or_else(|| anyhow!("No E2E session"))?;
|
||||
let salsa = session
|
||||
.salsa_box
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("E2E box not ready"))?;
|
||||
|
||||
let nonce = SalsaBox::generate_nonce(&mut OsRng);
|
||||
let ciphertext = salsa
|
||||
.encrypt(&nonce, plaintext.as_bytes())
|
||||
.map_err(|e| anyhow!("encrypt error: {:?}", e))?;
|
||||
|
||||
Ok(EncryptedMessage {
|
||||
nonce: B64.encode(nonce.as_slice()),
|
||||
ciphertext: B64.encode(&ciphertext),
|
||||
sender_pub: inner.keypair.public_key_b64.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt an `EncryptedMessage`. Returns `Err` if decryption fails.
|
||||
pub fn decrypt(&self, msg: &EncryptedMessage) -> Result<String> {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
let session = inner.session.as_ref().ok_or_else(|| anyhow!("No E2E session"))?;
|
||||
let salsa = session
|
||||
.salsa_box
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("E2E box not ready"))?;
|
||||
|
||||
let nonce_bytes: [u8; 24] = B64
|
||||
.decode(&msg.nonce)
|
||||
.context("nonce decode")?
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("nonce bad length"))?;
|
||||
let nonce = crypto_box::Nonce::from(nonce_bytes);
|
||||
|
||||
let ciphertext = B64.decode(&msg.ciphertext).context("ciphertext decode")?;
|
||||
|
||||
let plaintext = salsa
|
||||
.decrypt(&nonce, ciphertext.as_slice())
|
||||
.map_err(|e| anyhow!("decrypt error: {:?}", e))?;
|
||||
|
||||
String::from_utf8(plaintext).context("plaintext not UTF-8")
|
||||
}
|
||||
|
||||
/// True if a shared session has been established with the remote party.
|
||||
pub fn has_session(&self) -> bool {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.session
|
||||
.as_ref()
|
||||
.map_or(false, |s| s.salsa_box.is_some())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::cdap_client::{CdapClient, CdapStatus};
|
||||
use crate::config::AgentConfig;
|
||||
use crate::registration;
|
||||
use crate::sidecar::{SidecarManager, SidecarStatus};
|
||||
use crate::sysinfo_collect::SystemSnapshot;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -12,8 +12,8 @@ use tauri::Manager;
|
||||
pub struct AgentState {
|
||||
pub config: Mutex<AgentConfig>,
|
||||
pub chat_history: Mutex<Vec<ChatMessage>>,
|
||||
/// Go agent sidecar process manager.
|
||||
pub sidecar: SidecarManager,
|
||||
/// Native CDAP WebSocket client (replaces Go sidecar).
|
||||
pub cdap: CdapClient,
|
||||
}
|
||||
|
||||
/// Chat message structure.
|
||||
@@ -88,7 +88,7 @@ pub fn is_os_admin() -> bool {
|
||||
pub fn quit_app(app: tauri::AppHandle) {
|
||||
// Stop the sidecar gracefully before exit.
|
||||
let state = app.state::<AgentState>();
|
||||
state.sidecar.stop();
|
||||
state.cdap.stop();
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
@@ -605,24 +605,33 @@ pub fn get_agent_settings(state: State<'_, AgentState>) -> Result<AgentSettings,
|
||||
pub fn save_agent_settings(
|
||||
settings: AgentSettings,
|
||||
state: State<'_, AgentState>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let autostart_desired = {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
config.server_address = settings.server_address;
|
||||
config.api_key = settings.api_key;
|
||||
config.cdap_port = settings.cdap_port;
|
||||
config.allow_screen_capture = settings.allow_screen_capture;
|
||||
config.require_consent = settings.require_consent;
|
||||
config.allow_terminal = settings.allow_terminal;
|
||||
config.allow_file_browser = settings.allow_file_browser;
|
||||
config.allow_clipboard = settings.allow_clipboard;
|
||||
config.auto_start_sidecar = settings.auto_start_sidecar;
|
||||
config.language = settings.language;
|
||||
config.autostart = settings.autostart;
|
||||
config.start_minimized = settings.start_minimized;
|
||||
config.server_address = settings.server_address;
|
||||
config.api_key = settings.api_key;
|
||||
config.cdap_port = settings.cdap_port;
|
||||
config.allow_screen_capture = settings.allow_screen_capture;
|
||||
config.require_consent = settings.require_consent;
|
||||
config.allow_terminal = settings.allow_terminal;
|
||||
config.allow_file_browser = settings.allow_file_browser;
|
||||
config.allow_clipboard = settings.allow_clipboard;
|
||||
config.auto_start_sidecar = settings.auto_start_sidecar;
|
||||
config.language = settings.language;
|
||||
config.autostart = settings.autostart;
|
||||
config.start_minimized = settings.start_minimized;
|
||||
|
||||
config.save().map_err(|e| e.to_string())?;
|
||||
info!("Settings saved");
|
||||
config.save().map_err(|e| e.to_string())?;
|
||||
config.autostart
|
||||
};
|
||||
|
||||
// Sync OS-level autostart registration (Linux .desktop, Windows HKCU Run,
|
||||
// macOS LaunchAgent) with the persisted preference.
|
||||
crate::autostart::sync_os_autostart(&app, autostart_desired);
|
||||
|
||||
info!("Settings saved (autostart={})", autostart_desired);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -654,9 +663,9 @@ pub async fn discover_lan_servers() -> Result<Vec<DiscoveredLanServer>, String>
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ─────────────────────────── Sidecar control ───────────────────────────
|
||||
// ─────────────────────────── CDAP client control ───────────────────────────
|
||||
|
||||
async fn build_sidecar_config(state: &AgentState) -> Result<crate::sidecar::SidecarConfig, String> {
|
||||
async fn build_cdap_config(state: &AgentState) -> Result<crate::cdap_client::CdapConfig, String> {
|
||||
let mut config = {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
if !config.is_registered() {
|
||||
@@ -673,61 +682,52 @@ async fn build_sidecar_config(state: &AgentState) -> Result<crate::sidecar::Side
|
||||
shared.server_address = config.server_address.clone();
|
||||
}
|
||||
|
||||
Ok(config.to_sidecar_config())
|
||||
Ok(config.to_cdap_config())
|
||||
}
|
||||
|
||||
/// Returns the current status of the Go agent sidecar process.
|
||||
/// Returns the current status of the native CDAP client.
|
||||
#[tauri::command]
|
||||
pub fn get_sidecar_status(state: State<'_, AgentState>) -> SidecarStatus {
|
||||
state.sidecar.status()
|
||||
pub fn get_sidecar_status(state: State<'_, AgentState>) -> CdapStatus {
|
||||
state.cdap.status()
|
||||
}
|
||||
|
||||
/// Start or restart the Go agent sidecar.
|
||||
/// Writes a fresh Go agent config from current AgentConfig, then spawns the binary.
|
||||
/// Start or restart the native CDAP client.
|
||||
#[tauri::command]
|
||||
pub async fn start_sidecar(app: tauri::AppHandle, state: State<'_, AgentState>) -> Result<SidecarStatus, String> {
|
||||
let sidecar_cfg = build_sidecar_config(&state).await?;
|
||||
|
||||
// Stop previous instance if any.
|
||||
state.sidecar.stop();
|
||||
|
||||
state.sidecar.start(&sidecar_cfg).map_err(|e| e.to_string())?;
|
||||
// Start stdout reader for consent-request events from the Go agent.
|
||||
state.sidecar.start_stdout_reader(app);
|
||||
info!("Sidecar started via IPC command");
|
||||
Ok(state.sidecar.status())
|
||||
pub async fn start_sidecar(_app: tauri::AppHandle, state: State<'_, AgentState>) -> Result<CdapStatus, String> {
|
||||
let cdap_cfg = build_cdap_config(&state).await?;
|
||||
state.cdap.stop();
|
||||
state.cdap.start(&cdap_cfg).map_err(|e| e.to_string())?;
|
||||
info!("CDAP client started via IPC command");
|
||||
Ok(state.cdap.status())
|
||||
}
|
||||
|
||||
/// Stop the Go agent sidecar. Device becomes invisible to operators until restarted.
|
||||
/// Stop the CDAP client.
|
||||
#[tauri::command]
|
||||
pub fn stop_sidecar(state: State<'_, AgentState>) -> SidecarStatus {
|
||||
state.sidecar.stop();
|
||||
info!("Sidecar stopped via IPC command");
|
||||
state.sidecar.status()
|
||||
pub fn stop_sidecar(state: State<'_, AgentState>) -> CdapStatus {
|
||||
state.cdap.stop();
|
||||
info!("CDAP client stopped via IPC command");
|
||||
state.cdap.status()
|
||||
}
|
||||
|
||||
/// Restart the sidecar (re-reads current config).
|
||||
/// Use this after changing capability settings.
|
||||
/// Restart the CDAP client (re-reads current config).
|
||||
#[tauri::command]
|
||||
pub async fn restart_sidecar(app: tauri::AppHandle, state: State<'_, AgentState>) -> Result<SidecarStatus, String> {
|
||||
pub async fn restart_sidecar(app: tauri::AppHandle, state: State<'_, AgentState>) -> Result<CdapStatus, String> {
|
||||
start_sidecar(app, state).await
|
||||
}
|
||||
|
||||
/// Legacy command — redirects to sidecar restart.
|
||||
/// Legacy command — redirects to CDAP restart.
|
||||
#[tauri::command]
|
||||
pub async fn restart_agent_service(app: tauri::AppHandle, state: State<'_, AgentState>) -> Result<(), String> {
|
||||
start_sidecar(app, state).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Answer a consent request from the Go sidecar.
|
||||
/// `granted` = true (allow desktop session) or false (deny).
|
||||
/// No-op — consent is now handled natively inside cdap_client.rs.
|
||||
#[tauri::command]
|
||||
pub fn answer_consent(
|
||||
state: State<'_, AgentState>,
|
||||
session_id: String,
|
||||
granted: bool,
|
||||
_state: State<'_, AgentState>,
|
||||
_session_id: String,
|
||||
_granted: bool,
|
||||
) -> Result<(), String> {
|
||||
state.sidecar.send_consent(&session_id, granted);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -182,18 +182,22 @@ impl AgentConfig {
|
||||
self.registered && !self.device_id.is_empty() && !self.server_address.is_empty()
|
||||
}
|
||||
|
||||
/// Build a `SidecarConfig` from this config (needed by `SidecarManager::start`).
|
||||
pub fn to_sidecar_config(&self) -> crate::sidecar::SidecarConfig {
|
||||
/// Build a `CdapConfig` for the native CDAP client.
|
||||
pub fn to_cdap_config(&self) -> crate::cdap_client::CdapConfig {
|
||||
let data_dir = directories::ProjectDirs::from("com", "betterdesk", "agent")
|
||||
.map(|d| d.data_dir().to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
crate::sidecar::SidecarConfig {
|
||||
crate::cdap_client::CdapConfig {
|
||||
server_address: self.server_address.clone(),
|
||||
device_id: self.device_id.clone(),
|
||||
device_name: self.device_name.clone(),
|
||||
api_key: self.api_key.clone(),
|
||||
auth_token: self.auth_token.clone(),
|
||||
auth_token: if self.auth_token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.auth_token.clone())
|
||||
},
|
||||
allow_terminal: self.allow_terminal,
|
||||
allow_file_browser: self.allow_file_browser,
|
||||
allow_clipboard: self.allow_clipboard,
|
||||
@@ -203,6 +207,12 @@ impl AgentConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `SidecarConfig` — kept for backward compatibility, delegates to CdapConfig.
|
||||
#[deprecated(note = "Use to_cdap_config() — sidecar is replaced by native CDAP client")]
|
||||
pub fn to_sidecar_config(&self) -> crate::cdap_client::CdapConfig {
|
||||
self.to_cdap_config()
|
||||
}
|
||||
|
||||
/// Store credentials securely via OS keyring.
|
||||
pub fn store_token_secure(&self) -> Result<()> {
|
||||
if self.auth_token.is_empty() {
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
//! - `sysinfo_collect` — System information collection (hostname, OS, CPU, RAM, disk)
|
||||
//! - `commands` — Tauri IPC commands exposed to the frontend
|
||||
|
||||
pub mod autostart;
|
||||
pub mod bd_signal;
|
||||
pub mod cdap_client;
|
||||
pub mod chat_crypto;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod privileges;
|
||||
pub mod registration;
|
||||
pub mod sidecar;
|
||||
pub mod sysinfo_collect;
|
||||
|
||||
use log::info;
|
||||
@@ -25,9 +28,9 @@ use tauri::Manager;
|
||||
#[allow(dead_code)]
|
||||
struct TrayState(tauri::tray::TrayIcon<tauri::Wry>);
|
||||
|
||||
async fn resolve_sidecar_config_from_state(
|
||||
async fn resolve_cdap_config_from_state(
|
||||
app: &tauri::AppHandle,
|
||||
) -> Option<sidecar::SidecarConfig> {
|
||||
) -> Option<cdap_client::CdapConfig> {
|
||||
let mut config = {
|
||||
let state = app.try_state::<commands::AgentState>()?;
|
||||
let guard = state.config.lock().ok()?;
|
||||
@@ -45,7 +48,7 @@ async fn resolve_sidecar_config_from_state(
|
||||
}
|
||||
}
|
||||
|
||||
Some(config.to_sidecar_config())
|
||||
Some(config.to_cdap_config())
|
||||
}
|
||||
|
||||
/// Spawn a background task that sends `POST /api/heartbeat` every 12 seconds.
|
||||
@@ -104,6 +107,68 @@ fn start_heartbeat_task(app: &tauri::AppHandle) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Push a full `/api/sysinfo` payload once on app startup.
|
||||
///
|
||||
/// RustDesk-compatible sysinfo updates hostname, OS name, and version on the
|
||||
/// Go server peer record. Running this at every launch catches OS upgrades,
|
||||
/// kernel bumps, and hostname changes that occur between sessions — without
|
||||
/// requiring the user to re-run the setup wizard.
|
||||
fn push_sysinfo_refresh(app: &tauri::AppHandle) {
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Give the app a moment to finish initializing before hitting network.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let (address, device_id) = {
|
||||
let Some(state) = app_handle.try_state::<commands::AgentState>() else { return };
|
||||
let Ok(guard) = state.config.lock() else { return };
|
||||
if !guard.is_registered() {
|
||||
return;
|
||||
}
|
||||
(guard.server_address.clone(), guard.device_id.clone())
|
||||
};
|
||||
|
||||
let snap = sysinfo_collect::SystemSnapshot::collect();
|
||||
let payload = serde_json::json!({
|
||||
"id": device_id,
|
||||
"hostname": snap.hostname,
|
||||
"username": snap.username,
|
||||
"os": format!("{} {}", snap.os, snap.arch),
|
||||
"version": snap.os_version,
|
||||
"cpu": snap.cpu_name,
|
||||
"memory": format!("{} MB", snap.total_memory_mb.max(1)),
|
||||
});
|
||||
|
||||
let url = {
|
||||
let addr = address.trim();
|
||||
let with_scheme = if addr.starts_with("http://") || addr.starts_with("https://") {
|
||||
addr.to_string()
|
||||
} else {
|
||||
format!("http://{}", addr)
|
||||
};
|
||||
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
||||
let host = parsed.host_str().unwrap_or("localhost");
|
||||
let port = parsed.port().unwrap_or(21114);
|
||||
let scheme = parsed.scheme();
|
||||
format!("{}://{}:{}/api/sysinfo", scheme, host, port)
|
||||
} else {
|
||||
format!("http://{}:21114/api/sysinfo", addr)
|
||||
}
|
||||
};
|
||||
|
||||
match registration::build_http_client(10) {
|
||||
Ok(client) => match client.post(&url).json(&payload).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
log::info!("[sysinfo] Refreshed on {}", url);
|
||||
}
|
||||
Ok(resp) => log::warn!("[sysinfo] Server returned {} for {}", resp.status(), url),
|
||||
Err(e) => log::warn!("[sysinfo] Request failed: {}", e),
|
||||
},
|
||||
Err(e) => log::warn!("[sysinfo] Could not build HTTP client: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Entry point — called from main.rs.
|
||||
pub fn run() {
|
||||
// WebKitGTK Wayland workaround: prevent Gdk "Error 71 (Protocol error)
|
||||
@@ -136,14 +201,15 @@ pub fn run() {
|
||||
}
|
||||
let is_registered = settings.is_registered();
|
||||
let auto_start = settings.auto_start_sidecar && is_registered;
|
||||
let auto_start_pref = settings.autostart;
|
||||
info!(
|
||||
"Config loaded — registered: {}, server: {:?}",
|
||||
is_registered,
|
||||
settings.server_address
|
||||
);
|
||||
|
||||
let sidecar_manager = sidecar::SidecarManager::new();
|
||||
let sidecar_manager_clone = sidecar_manager.clone();
|
||||
let cdap_client = cdap_client::CdapClient::new();
|
||||
let cdap_client_clone = cdap_client.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
@@ -162,7 +228,7 @@ pub fn run() {
|
||||
.manage(commands::AgentState {
|
||||
config: Mutex::new(settings),
|
||||
chat_history: Mutex::new(Vec::new()),
|
||||
sidecar: sidecar_manager,
|
||||
cdap: cdap_client,
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Status & lifecycle
|
||||
@@ -232,24 +298,19 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start Go sidecar if device is registered and setting is on.
|
||||
// Auto-start native CDAP client if device is registered and setting is on.
|
||||
if auto_start {
|
||||
let app_handle = app.handle().clone();
|
||||
let sidecar_manager = sidecar_manager_clone.clone();
|
||||
let cdap = cdap_client_clone.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
info!("[sidecar] Auto-starting Go agent sidecar...");
|
||||
let Some(sidecar_cfg) = resolve_sidecar_config_from_state(&app_handle).await else {
|
||||
info!("[sidecar] Auto-start skipped: device not registered");
|
||||
info!("[cdap] Auto-starting native CDAP client...");
|
||||
let Some(cdap_cfg) = resolve_cdap_config_from_state(&app_handle).await else {
|
||||
info!("[cdap] Auto-start skipped: device not registered");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = sidecar_manager.start(&sidecar_cfg) {
|
||||
// Non-fatal: sidecar binary may not be installed yet.
|
||||
// User can start it manually from tray or settings.
|
||||
log::warn!("[sidecar] Auto-start failed: {}", e);
|
||||
} else {
|
||||
// Start stdout reader for consent-request events.
|
||||
sidecar_manager.start_stdout_reader(app_handle.clone());
|
||||
if let Err(e) = cdap.start(&cdap_cfg) {
|
||||
log::warn!("[cdap] Auto-start failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -258,6 +319,24 @@ pub fn run() {
|
||||
// visible as ONLINE even when the CDAP sidecar is not running.
|
||||
start_heartbeat_task(app.handle());
|
||||
|
||||
// Refresh sysinfo (hostname, OS, version, platform) on the server
|
||||
// every boot — catches OS upgrades, hostname changes, kernel bumps
|
||||
// that happen between launches.
|
||||
if is_registered {
|
||||
push_sysinfo_refresh(app.handle());
|
||||
}
|
||||
|
||||
// Start the bd-signal WS client — answers operator-initiated
|
||||
// introspection requests (services, processes, files, screenshot,
|
||||
// terminal). Idempotent: silently no-ops until registered.
|
||||
bd_signal::spawn(app.handle().clone());
|
||||
|
||||
// Mirror the persisted autostart preference to the OS (creates or
|
||||
// removes the .desktop / Run registry entry). Without this the
|
||||
// user's "Start on system boot" toggle has no effect — the plugin
|
||||
// only exposes the API; enabling it is our responsibility.
|
||||
autostart::sync_os_autostart(app.handle(), auto_start_pref);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -344,23 +423,20 @@ fn setup_tray(
|
||||
"chat" => show_window(app, "/chat"),
|
||||
"check_conn" => show_window(app, "/?action=reconnect"),
|
||||
"sidecar_toggle" => {
|
||||
// Restart the Go sidecar on demand (no admin required —
|
||||
// sidecar is a user-space process, operator-side gating is
|
||||
// already enforced by the server's RBAC).
|
||||
// Restart the native CDAP client on demand.
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let Some(sc_cfg) = resolve_sidecar_config_from_state(&app_handle).await else {
|
||||
info!("[tray] Sidecar toggle: device not registered");
|
||||
let Some(cdap_cfg) = resolve_cdap_config_from_state(&app_handle).await else {
|
||||
info!("[tray] CDAP restart: device not registered");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(state) = app_handle.try_state::<commands::AgentState>() {
|
||||
state.sidecar.stop();
|
||||
if let Err(e) = state.sidecar.start(&sc_cfg) {
|
||||
log::warn!("[tray] Sidecar restart failed: {}", e);
|
||||
state.cdap.stop();
|
||||
if let Err(e) = state.cdap.start(&cdap_cfg) {
|
||||
log::warn!("[tray] CDAP restart failed: {}", e);
|
||||
} else {
|
||||
state.sidecar.start_stdout_reader(app_handle.clone());
|
||||
info!("[tray] Sidecar restarted");
|
||||
info!("[tray] CDAP client restarted");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
//! Sidecar manager — runs `betterdesk-agent` (Go binary) as a managed child
|
||||
//! process inside the Tauri app.
|
||||
//!
|
||||
//! Architecture rationale
|
||||
//! ─────────────────────
|
||||
//! The Tauri app handles: device registration, config persistence, OS tray,
|
||||
//! user-visible UI, privilege gating, and user consent dialogs.
|
||||
//!
|
||||
//! The Go sidecar handles: CDAP WebSocket connection to the server, heartbeat,
|
||||
//! telemetry, terminal (PTY), file browser, clipboard sync, and screenshot
|
||||
//! capture. This avoids a 4-6 week Rust rewrite of already-working Go code.
|
||||
//!
|
||||
//! Lifecycle
|
||||
//! ─────────
|
||||
//! 1. `SidecarManager::start()` writes a Go-format JSON config to the app data
|
||||
//! dir and spawns `betterdesk-agent -config <path>`.
|
||||
//! 2. A monitor task (tokio::spawn) polls the child every 5 s. On exit it
|
||||
//! increments `restart_count`, applies exponential backoff (5 s × 2^n, max
|
||||
//! 5 min), then restarts.
|
||||
//! 3. `stop()` sends SIGTERM on Unix / TerminateProcess on Windows and waits up
|
||||
//! to 5 s for graceful shutdown before force-killing.
|
||||
//! 4. `drop(SidecarManager)` stops the child automatically.
|
||||
//!
|
||||
//! Binary discovery
|
||||
//! ────────────────
|
||||
//! The binary is searched in this order:
|
||||
//! 1. `$BETTERDESK_AGENT_BIN` env var (developer override).
|
||||
//! 2. Same directory as the Tauri executable
|
||||
//! (`<exe-dir>/betterdesk-agent[.exe]`).
|
||||
//! 3. App data dir (`<data>/betterdesk-agent[.exe]`).
|
||||
//! 4. System PATH (allows system-installed agent to be managed by Tauri).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
process::{Child, ChildStdin, Command, Stdio},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{async_runtime, Emitter};
|
||||
|
||||
// ── Public status ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Snapshot of the sidecar process state, returned to the frontend.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SidecarStatus {
|
||||
/// True when the Go agent process is alive.
|
||||
pub running: bool,
|
||||
/// PID of the child process, or 0 if not running.
|
||||
pub pid: u32,
|
||||
/// Number of automatic restarts since app launch.
|
||||
pub restart_count: u32,
|
||||
/// Human-readable state string for the UI.
|
||||
pub state: String,
|
||||
/// Path to the binary actually being used.
|
||||
pub binary_path: String,
|
||||
/// CDAP WebSocket URL the agent connects to.
|
||||
pub cdap_url: String,
|
||||
}
|
||||
|
||||
// ── Go agent JSON config ──────────────────────────────────────────────────
|
||||
|
||||
/// JSON config written to disk for the Go agent binary.
|
||||
/// Fields match `betterdesk-agent/agent/config.go`.
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct GoAgentConfig {
|
||||
server: String, // ws://host:21122/cdap
|
||||
auth_method: String, // api_key | device_token | user_password
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
api_key: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
device_token: String,
|
||||
device_id: String,
|
||||
device_name: String,
|
||||
device_type: String, // os_agent | desktop | custom
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tags: Vec<String>,
|
||||
|
||||
terminal: bool,
|
||||
file_browser: bool,
|
||||
clipboard: bool,
|
||||
screenshot: bool,
|
||||
|
||||
heartbeat_sec: u32,
|
||||
reconnect_sec: u32,
|
||||
max_reconnect: u32,
|
||||
log_level: String,
|
||||
data_dir: String,
|
||||
}
|
||||
|
||||
// ── Public config mirror from Tauri AgentConfig ───────────────────────────
|
||||
|
||||
/// Subset of `AgentConfig` needed to generate the Go agent config.
|
||||
/// Passed to `SidecarManager::start()`.
|
||||
pub struct SidecarConfig {
|
||||
pub server_address: String, // "host:21114" or "host"
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
pub api_key: String,
|
||||
pub auth_token: String, // optional server-issued device_token
|
||||
pub allow_terminal: bool,
|
||||
pub allow_file_browser: bool,
|
||||
pub allow_clipboard: bool,
|
||||
pub allow_screen_capture: bool,
|
||||
pub data_dir: PathBuf,
|
||||
pub cdap_port: u16,
|
||||
}
|
||||
|
||||
impl SidecarConfig {
|
||||
/// Build the WebSocket URL from the server address (strips API port, uses cdap_port).
|
||||
pub fn cdap_ws_url(&self) -> String {
|
||||
let addr = self.server_address.trim();
|
||||
let with_scheme = if addr.starts_with("http://") || addr.starts_with("https://") {
|
||||
addr.to_string()
|
||||
} else {
|
||||
format!("http://{}", addr)
|
||||
};
|
||||
|
||||
if let Ok(parsed) = url::Url::parse(&with_scheme) {
|
||||
let host = parsed.host_str().unwrap_or("localhost");
|
||||
let host_part = if host.contains(':') {
|
||||
format!("[{}]", host)
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
let ws_scheme = if parsed.scheme() == "https" { "wss" } else { "ws" };
|
||||
format!("{}://{}:{}/cdap", ws_scheme, host_part, self.cdap_port)
|
||||
} else {
|
||||
format!("ws://{}:{}/cdap", addr, self.cdap_port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_placeholder_device_token(token: &str) -> bool {
|
||||
token.starts_with("BD-TOKEN-")
|
||||
}
|
||||
|
||||
// ── SidecarManager ────────────────────────────────────────────────────────
|
||||
|
||||
/// Thread-safe handle to the Go agent sidecar process.
|
||||
///
|
||||
/// Clone is cheap — the Arc payload is shared.
|
||||
#[derive(Clone)]
|
||||
pub struct SidecarManager {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
child: Mutex<Option<Child>>,
|
||||
/// Writable stdin of the current child process (for consent responses).
|
||||
child_stdin: Mutex<Option<ChildStdin>>,
|
||||
running: AtomicBool,
|
||||
restart_count: AtomicU32,
|
||||
binary_path: Mutex<PathBuf>,
|
||||
cdap_url: Mutex<String>,
|
||||
config_path: Mutex<PathBuf>,
|
||||
/// When set to true the monitor loop will not restart.
|
||||
stop_requested: AtomicBool,
|
||||
}
|
||||
|
||||
impl SidecarManager {
|
||||
/// Create an idle manager (no child running yet).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
child: Mutex::new(None),
|
||||
child_stdin: Mutex::new(None),
|
||||
running: AtomicBool::new(false),
|
||||
restart_count: AtomicU32::new(0),
|
||||
binary_path: Mutex::new(PathBuf::new()),
|
||||
cdap_url: Mutex::new(String::new()),
|
||||
config_path: Mutex::new(PathBuf::new()),
|
||||
stop_requested: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Write Go config and spawn the sidecar. Starts the monitor task.
|
||||
/// Safe to call again after stop — creates a fresh process.
|
||||
pub fn start(&self, cfg: &SidecarConfig) -> Result<()> {
|
||||
let inner = &self.inner;
|
||||
|
||||
// Abort any previous stop state.
|
||||
inner.stop_requested.store(false, Ordering::SeqCst);
|
||||
|
||||
// Locate the Go binary.
|
||||
let binary = find_binary(&cfg.data_dir)?;
|
||||
info!("[sidecar] Using binary: {}", binary.display());
|
||||
*inner.binary_path.lock().unwrap() = binary.clone();
|
||||
|
||||
// Build + write Go agent config JSON.
|
||||
let config_path = cfg.data_dir.join("go-agent-config.json");
|
||||
write_go_config(&config_path, cfg)?;
|
||||
*inner.config_path.lock().unwrap() = config_path.clone();
|
||||
|
||||
let cdap_url = cfg.cdap_ws_url();
|
||||
*inner.cdap_url.lock().unwrap() = cdap_url.clone();
|
||||
|
||||
// Spawn the process.
|
||||
self.spawn_process(&binary, &config_path)?;
|
||||
|
||||
// Start monitor task (async).
|
||||
let manager = self.clone();
|
||||
let binary_c = binary.clone();
|
||||
let config_path_c = config_path.clone();
|
||||
async_runtime::spawn(async move {
|
||||
manager.monitor_loop(&binary_c, &config_path_c).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the sidecar, optionally waiting for graceful exit.
|
||||
pub fn stop(&self) {
|
||||
self.inner.stop_requested.store(true, Ordering::SeqCst);
|
||||
self.terminate_child();
|
||||
self.inner.running.store(false, Ordering::SeqCst);
|
||||
info!("[sidecar] Stopped.");
|
||||
}
|
||||
|
||||
/// True if the child process is alive.
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.inner.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Snapshot for the frontend.
|
||||
pub fn status(&self) -> SidecarStatus {
|
||||
let pid = {
|
||||
let guard = self.inner.child.lock().unwrap();
|
||||
guard.as_ref().map(|c| c.id()).unwrap_or(0)
|
||||
};
|
||||
let running = self.inner.running.load(Ordering::SeqCst);
|
||||
let restart_count = self.inner.restart_count.load(Ordering::SeqCst);
|
||||
let binary_path = self
|
||||
.inner
|
||||
.binary_path
|
||||
.lock()
|
||||
.unwrap()
|
||||
.display()
|
||||
.to_string();
|
||||
let cdap_url = self.inner.cdap_url.lock().unwrap().clone();
|
||||
|
||||
let state = if running {
|
||||
"running".to_string()
|
||||
} else if binary_path.is_empty() {
|
||||
"not_configured".to_string()
|
||||
} else {
|
||||
"stopped".to_string()
|
||||
};
|
||||
|
||||
SidecarStatus {
|
||||
running,
|
||||
pid,
|
||||
restart_count,
|
||||
state,
|
||||
binary_path,
|
||||
cdap_url,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn spawn_process(&self, binary: &PathBuf, config_path: &PathBuf) -> Result<()> {
|
||||
let mut child = Command::new(binary)
|
||||
.arg("-config")
|
||||
.arg(config_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn betterdesk-agent from {}", binary.display()))?;
|
||||
|
||||
// Take the stdin handle so we can write consent responses later.
|
||||
let child_stdin = child.stdin.take();
|
||||
let pid = child.id();
|
||||
*self.inner.child.lock().unwrap() = Some(child);
|
||||
*self.inner.child_stdin.lock().unwrap() = child_stdin;
|
||||
self.inner.running.store(true, Ordering::SeqCst);
|
||||
info!("[sidecar] Spawned betterdesk-agent (pid={})", pid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a consent response to the child's stdin.
|
||||
/// Called from `answer_consent` Tauri command.
|
||||
pub fn send_consent(&self, session_id: &str, granted: bool) {
|
||||
let mut guard = self.inner.child_stdin.lock().unwrap();
|
||||
if let Some(ref mut stdin) = *guard {
|
||||
let line = if granted {
|
||||
format!("CONSENT_GRANTED:{}\n", session_id)
|
||||
} else {
|
||||
format!("CONSENT_DENIED:{}\n", session_id)
|
||||
};
|
||||
if let Err(e) = stdin.write_all(line.as_bytes()) {
|
||||
warn!("[sidecar] Failed to write consent response: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a background thread to read stdout from the child and emit
|
||||
/// "consent-request" Tauri events when "CONSENT_REQUEST:{...}" is seen.
|
||||
pub fn start_stdout_reader(&self, app: tauri::AppHandle) {
|
||||
// Pull stdout from the child — do this after spawn_process().
|
||||
let stdout = {
|
||||
let mut guard = self.inner.child.lock().unwrap();
|
||||
guard.as_mut().and_then(|c| c.stdout.take())
|
||||
};
|
||||
let Some(stdout) = stdout else { return };
|
||||
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{BufRead, BufReader};
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(l) if l.starts_with("CONSENT_REQUEST:") => {
|
||||
let json_str = l.trim_start_matches("CONSENT_REQUEST:").to_string();
|
||||
if let Err(e) = app.emit("consent-request", json_str) {
|
||||
warn!("[sidecar] Failed to emit consent-request event: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(l) => {
|
||||
// Forward other stdout lines to the app log.
|
||||
debug!("[go-agent] {}", l);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn terminate_child(&self) {
|
||||
let mut guard = self.inner.child.lock().unwrap();
|
||||
if let Some(mut child) = guard.take() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = child.id() as i32;
|
||||
// SIGTERM first.
|
||||
unsafe { libc::kill(pid, libc::SIGTERM) };
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
// Wait up to 5 s for graceful shutdown.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
debug!("[sidecar] Child exited: {:?}", status);
|
||||
break;
|
||||
}
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
_ => {
|
||||
warn!("[sidecar] Force-killing child after 5 s");
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task — polls child every 5 s, restarts on exit.
|
||||
async fn monitor_loop(&self, binary: &PathBuf, config_path: &PathBuf) {
|
||||
const BASE_DELAY_SECS: u64 = 5;
|
||||
const MAX_DELAY_SECS: u64 = 300;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
if self.inner.stop_requested.load(Ordering::SeqCst) {
|
||||
debug!("[sidecar] Monitor: stop requested, exiting loop");
|
||||
return;
|
||||
}
|
||||
|
||||
let exited = {
|
||||
let mut guard = self.inner.child.lock().unwrap();
|
||||
if let Some(child) = guard.as_mut() {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
warn!("[sidecar] Process exited: {:?}", status);
|
||||
true
|
||||
}
|
||||
Ok(None) => false, // still running
|
||||
Err(e) => {
|
||||
error!("[sidecar] try_wait error: {}", e);
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if exited {
|
||||
self.inner.running.store(false, Ordering::SeqCst);
|
||||
|
||||
if self.inner.stop_requested.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let count = self.inner.restart_count.fetch_add(1, Ordering::SeqCst);
|
||||
let delay = (BASE_DELAY_SECS * (1u64 << count.min(6))).min(MAX_DELAY_SECS);
|
||||
warn!("[sidecar] Restarting in {}s (attempt #{})", delay, count + 1);
|
||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||
|
||||
if self.inner.stop_requested.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = self.spawn_process(binary, config_path) {
|
||||
error!("[sidecar] Restart failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Inner {
|
||||
fn drop(&mut self) {
|
||||
// Kill child when the Tauri app exits.
|
||||
if let Some(mut child) = self.child.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Binary discovery ──────────────────────────────────────────────────────
|
||||
|
||||
/// Find the `betterdesk-agent` binary.
|
||||
/// Search order: env var → exe dir → data dir → PATH.
|
||||
fn find_binary(data_dir: &PathBuf) -> Result<PathBuf> {
|
||||
let bin_name = if cfg!(windows) {
|
||||
"betterdesk-agent.exe"
|
||||
} else {
|
||||
"betterdesk-agent"
|
||||
};
|
||||
|
||||
// 1. Developer override.
|
||||
if let Ok(path) = std::env::var("BETTERDESK_AGENT_BIN") {
|
||||
let p = PathBuf::from(path);
|
||||
if p.is_file() {
|
||||
return Ok(p);
|
||||
}
|
||||
warn!("[sidecar] BETTERDESK_AGENT_BIN set but file not found: {}", p.display());
|
||||
}
|
||||
|
||||
// 2. Same directory as the Tauri executable.
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let candidate = exe.parent().unwrap_or(&exe).join(bin_name);
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. App data directory (downloaded/extracted binary).
|
||||
let candidate = data_dir.join(bin_name);
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
|
||||
// 4. System PATH.
|
||||
if let Ok(output) = Command::new(if cfg!(windows) { "where" } else { "which" })
|
||||
.arg(if cfg!(windows) {
|
||||
"betterdesk-agent.exe"
|
||||
} else {
|
||||
"betterdesk-agent"
|
||||
})
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path_str = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path_str.is_empty() {
|
||||
let p = PathBuf::from(path_str);
|
||||
if p.is_file() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"betterdesk-agent binary not found. Searched: \
|
||||
$BETTERDESK_AGENT_BIN, exe dir, {}, PATH. \
|
||||
Download from https://github.com/UNITRONIX/BetterDesk/releases \
|
||||
or install via the ALL-IN-ONE installer.",
|
||||
data_dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
// ── Config writer ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Write the Go-format JSON config file consumed by betterdesk-agent.
|
||||
fn write_go_config(path: &PathBuf, cfg: &SidecarConfig) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let auth_method;
|
||||
let api_key;
|
||||
let device_token;
|
||||
|
||||
if !cfg.api_key.is_empty() {
|
||||
auth_method = "api_key";
|
||||
api_key = cfg.api_key.clone();
|
||||
device_token = String::new();
|
||||
} else if !cfg.auth_token.is_empty() && !is_placeholder_device_token(&cfg.auth_token) {
|
||||
auth_method = "device_token";
|
||||
api_key = String::new();
|
||||
device_token = cfg.auth_token.clone();
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"CDAP sidecar requires a valid API key from Settings or a server-issued device token."
|
||||
));
|
||||
}
|
||||
|
||||
let go_cfg = GoAgentConfig {
|
||||
server: cfg.cdap_ws_url(),
|
||||
auth_method: auth_method.to_string(),
|
||||
api_key,
|
||||
device_token,
|
||||
device_id: cfg.device_id.clone(),
|
||||
device_name: cfg.device_name.clone(),
|
||||
device_type: "os_agent".to_string(),
|
||||
tags: vec!["tauri-agent".to_string()],
|
||||
terminal: cfg.allow_terminal,
|
||||
file_browser: cfg.allow_file_browser,
|
||||
clipboard: cfg.allow_clipboard,
|
||||
screenshot: cfg.allow_screen_capture,
|
||||
heartbeat_sec: 15,
|
||||
reconnect_sec: 5,
|
||||
max_reconnect: 300,
|
||||
log_level: "info".to_string(),
|
||||
data_dir: cfg.data_dir.to_string_lossy().to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&go_cfg)?;
|
||||
std::fs::write(path, json)
|
||||
.with_context(|| format!("write Go agent config to {}", path.display()))?;
|
||||
|
||||
info!("[sidecar] Config written to {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
@@ -28,18 +28,21 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' http: https: ws: wss: ipc: https://ipc.localhost; img-src 'self' data: asset: https://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; object-src 'none'; frame-ancestors 'none'"
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self' http: https: ws: wss: ipc: https://ipc.localhost; img-src 'self' data: asset: https://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; object-src 'none'; frame-ancestors 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["deb", "rpm", "appimage", "nsis", "msi"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/betterdesk-agent"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"displayLanguageSelector": true,
|
||||
@@ -51,6 +54,10 @@
|
||||
"Polish": "nsis/languages/pl.nsh"
|
||||
}
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"infoPlist": "./Info.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +1,348 @@
|
||||
import { Component, createSignal, onMount, onCleanup, Show } from "solid-js";
|
||||
import { Router, Route, useNavigate, useLocation } from "@solidjs/router";
|
||||
import { HashRouter as Router, Route } from "@solidjs/router";
|
||||
import StatusPanel from "./components/StatusPanel";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import ChatPanel from "./components/ChatPanel";
|
||||
import HelpRequest from "./components/HelpRequest";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import AdminRequired from "./components/AdminRequired";
|
||||
import ConsentDialog from "./components/ConsentDialog";
|
||||
import SudoAuthDialog from "./components/SudoAuthDialog";
|
||||
import { initI18n, t } from "./lib/i18n";
|
||||
import { frontendLog } from "./lib/logger";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
// Component that handles navigation events from the tray menu.
|
||||
// Must be inside <Router> to access useNavigate().
|
||||
const NavigationListener: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
// ── Navigation listener (tray menu events → router) ──────────────────────────
|
||||
const getCurrentHashRoute = (): string => {
|
||||
if (typeof window === "undefined") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const raw = window.location.hash.replace(/^#/, "") || "/";
|
||||
return raw.startsWith("/") ? raw : `/${raw}`;
|
||||
};
|
||||
|
||||
const getCurrentHashPath = (): string => getCurrentHashRoute().split("?")[0] || "/";
|
||||
|
||||
const navigateHashRoute = (route: string): void => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = route.startsWith("/") ? route : `/${route}`;
|
||||
if (getCurrentHashRoute() === normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.hash = normalized;
|
||||
};
|
||||
|
||||
const NavigationListener: Component = () => {
|
||||
onMount(() => {
|
||||
const unlistenPromise = listen<string>("navigate", (event) => {
|
||||
try {
|
||||
const route = event.payload;
|
||||
if (typeof route === "string" && route.startsWith("/")) {
|
||||
navigate(route);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed navigation payloads — safer to stay on current page.
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(async () => {
|
||||
try {
|
||||
const un = await unlistenPromise;
|
||||
un();
|
||||
} catch {
|
||||
// Cleanup is best-effort on unmount.
|
||||
const route = event.payload;
|
||||
if (typeof route === "string" && route.startsWith("/")) {
|
||||
navigateHashRoute(route);
|
||||
}
|
||||
});
|
||||
onCleanup(async () => (await unlistenPromise)());
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Bottom tab bar — provides in-app navigation for the small agent window.
|
||||
const BottomNav: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// ── Bottom navigation bar ─────────────────────────────────────────────────────
|
||||
// Visible tabs: Status | Chat | Help | Settings
|
||||
// The "Close" action is in the overflow menu (requires sudo auth for non-admins).
|
||||
interface BottomNavProps {
|
||||
isAdmin: boolean;
|
||||
onQuit: () => void;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
interface RegisteredShellProps {
|
||||
isAdmin: boolean;
|
||||
onQuit: () => void;
|
||||
}
|
||||
|
||||
const BottomNav: Component<BottomNavProps> = (props) => {
|
||||
const [showMenu, setShowMenu] = createSignal(false);
|
||||
const [currentPath, setCurrentPath] = createSignal(getCurrentHashPath());
|
||||
|
||||
const isActive = (path: string) =>
|
||||
path === "/" ? currentPath() === "/" : currentPath().startsWith(path);
|
||||
|
||||
// Close overflow menu on outside click
|
||||
onMount(() => {
|
||||
const handler = () => setShowMenu(false);
|
||||
const syncPath = () => setCurrentPath(getCurrentHashPath());
|
||||
|
||||
document.addEventListener("click", handler, { capture: true });
|
||||
window.addEventListener("hashchange", syncPath);
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("click", handler, { capture: true });
|
||||
window.removeEventListener("hashchange", syncPath);
|
||||
});
|
||||
});
|
||||
|
||||
const mainTabs = [
|
||||
{ path: "/", icon: "monitoring", label: () => t("sidebar.status") },
|
||||
{ path: "/chat", icon: "chat", label: () => t("sidebar.chat") },
|
||||
{ path: "/help", icon: "support_agent", label: () => t("sidebar.help") },
|
||||
{ path: "/settings", icon: "settings", label: () => t("sidebar.settings") },
|
||||
];
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/") return location.pathname === "/";
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav class="bottom-nav">
|
||||
{tabs.map((tab) => (
|
||||
{mainTabs.map((tab) => (
|
||||
<button
|
||||
class={`bottom-nav-item ${isActive(tab.path) ? "active" : ""}`}
|
||||
onClick={() => navigate(tab.path)}
|
||||
onClick={() => navigateHashRoute(tab.path)}
|
||||
>
|
||||
<span class="material-symbols-rounded">{tab.icon}</span>
|
||||
<span class="bottom-nav-label">{tab.label()}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Settings / overflow */}
|
||||
<div class="bottom-nav-overflow" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
class={`bottom-nav-item ${isActive("/settings") ? "active" : ""}`}
|
||||
onClick={() => setShowMenu((v) => !v)}
|
||||
>
|
||||
<span class="material-symbols-rounded">more_vert</span>
|
||||
<span class="bottom-nav-label">{t("sidebar.more")}</span>
|
||||
</button>
|
||||
|
||||
<Show when={showMenu()}>
|
||||
<div class="overflow-menu">
|
||||
{/* Settings — always visible, SudoAuthDialog gates controls inside */}
|
||||
<button
|
||||
class="overflow-menu-item"
|
||||
onClick={() => { navigateHashRoute("/settings"); setShowMenu(false); }}
|
||||
>
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
{t("sidebar.settings")}
|
||||
</button>
|
||||
|
||||
<hr class="overflow-menu-divider" />
|
||||
{/* Close — always visible; non-admin gets sudo dialog */}
|
||||
<button
|
||||
class="overflow-menu-item overflow-menu-item-danger"
|
||||
onClick={() => { props.onQuit(); setShowMenu(false); }}
|
||||
>
|
||||
<span class="material-symbols-rounded">power_settings_new</span>
|
||||
{t("app.close_agent")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const RegisteredShell: Component<RegisteredShellProps> = (props) => {
|
||||
return (
|
||||
<Router
|
||||
root={(routerProps) => (
|
||||
<div class="app-layout app-layout-tray">
|
||||
<NavigationListener />
|
||||
<ConsentDialog />
|
||||
<main class="app-main app-main-full">{routerProps.children}</main>
|
||||
<BottomNav isAdmin={props.isAdmin} onQuit={props.onQuit} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Route path="/" component={StatusPanel} />
|
||||
<Route path="/chat" component={ChatPanel} />
|
||||
<Route path="/help" component={HelpRequest} />
|
||||
<Route
|
||||
path="/settings"
|
||||
component={() => <SettingsPanel isAdmin={props.isAdmin} />}
|
||||
/>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Root App component ────────────────────────────────────────────────────────
|
||||
const App: Component = () => {
|
||||
const [ready, setReady] = createSignal(false);
|
||||
const [registered, setRegistered] = createSignal(false);
|
||||
const [isAdmin, setIsAdmin] = createSignal(false);
|
||||
const [bootStage, setBootStage] = createSignal("Starting BetterDesk Agent...");
|
||||
// Quit confirmation / sudo auth dialog state.
|
||||
const [showQuitDialog, setShowQuitDialog] = createSignal(false);
|
||||
|
||||
const markBootStage = (message: string, data?: unknown) => {
|
||||
setBootStage(message);
|
||||
frontendLog("debug", "app.boot", message, data);
|
||||
};
|
||||
|
||||
const withTimeout = <T,>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
fallbackValue: T,
|
||||
label: string,
|
||||
): Promise<T> => {
|
||||
return new Promise<T>((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
frontendLog("warn", "app.boot", `${label} timed out after ${timeoutMs}ms`);
|
||||
resolve(fallbackValue);
|
||||
}, timeoutMs);
|
||||
|
||||
promise
|
||||
.then((value) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
frontendLog("error", "app.boot", `${label} failed`, error);
|
||||
resolve(fallbackValue);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const checkRegistration = async (): Promise<boolean> => {
|
||||
frontendLog("debug", "app.boot", "Requesting cached agent registration state");
|
||||
try {
|
||||
// Hard 3-second timeout — the Tauri IPC round-trip should complete
|
||||
// in ~50 ms. If it hangs (webview/state-init issue, frozen config
|
||||
// lock, …) we still unblock the UI so the user can retry or
|
||||
// fall back to the setup wizard.
|
||||
const result = await Promise.race([
|
||||
invoke<{ registered: boolean }>("get_agent_status"),
|
||||
new Promise<{ registered: boolean } | null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000)
|
||||
),
|
||||
new Promise<null>((r) => setTimeout(() => r(null), 1000)),
|
||||
]);
|
||||
return !!(result && result.registered);
|
||||
} catch {
|
||||
if (result === null) {
|
||||
frontendLog("warn", "app.boot", "get_agent_status timed out after 1000ms");
|
||||
return false;
|
||||
}
|
||||
|
||||
const registeredState = !!result.registered;
|
||||
frontendLog("info", "app.boot", "Cached registration probe finished", {
|
||||
registered: registeredState,
|
||||
});
|
||||
return registeredState;
|
||||
} catch (error) {
|
||||
frontendLog("error", "app.boot", "get_agent_status failed", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const doQuit = async () => {
|
||||
frontendLog("info", "app.quit", "Quit confirmed by user");
|
||||
try {
|
||||
await invoke("quit_app");
|
||||
} catch (error) {
|
||||
frontendLog("error", "app.quit", "quit_app IPC failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await initI18n();
|
||||
frontendLog("info", "app.boot", "App mounted");
|
||||
|
||||
// Run both probes in parallel with a hard timeout so the spinner is
|
||||
// never stuck indefinitely.
|
||||
const [reg, admin] = await Promise.all([
|
||||
checkRegistration(),
|
||||
Promise.race([
|
||||
invoke<boolean>("is_os_admin").catch(() => false),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 2000)),
|
||||
]),
|
||||
]);
|
||||
const quitUnlistenPromise = listen<void>("request-quit", () => {
|
||||
frontendLog("info", "app.quit", "Quit requested from tray/window");
|
||||
setShowQuitDialog(true);
|
||||
});
|
||||
|
||||
void quitUnlistenPromise
|
||||
.then((unlisten) => {
|
||||
onCleanup(unlisten);
|
||||
})
|
||||
.catch((error) => {
|
||||
frontendLog("error", "app.boot", "Failed to register request-quit listener", error);
|
||||
});
|
||||
|
||||
try {
|
||||
markBootStage("Initialising bundled translations");
|
||||
await withTimeout(
|
||||
initI18n(),
|
||||
750,
|
||||
undefined,
|
||||
"initI18n",
|
||||
);
|
||||
|
||||
markBootStage("Checking agent state");
|
||||
const [reg, admin] = await Promise.all([
|
||||
checkRegistration(),
|
||||
withTimeout(
|
||||
invoke<boolean>("is_os_admin"),
|
||||
2000,
|
||||
false,
|
||||
"is_os_admin",
|
||||
),
|
||||
]);
|
||||
|
||||
setRegistered(reg);
|
||||
setIsAdmin(admin);
|
||||
frontendLog("info", "app.boot", "Boot state resolved", {
|
||||
registered: reg,
|
||||
isAdmin: admin,
|
||||
});
|
||||
} catch (error) {
|
||||
frontendLog("error", "app.boot", "Unexpected boot failure - continuing with safe defaults", error);
|
||||
}
|
||||
|
||||
setRegistered(reg);
|
||||
setIsAdmin(admin);
|
||||
setReady(true);
|
||||
frontendLog("info", "app.boot", "UI ready", {
|
||||
registered: registered(),
|
||||
isAdmin: isAdmin(),
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="app-root">
|
||||
{/* Quit confirmation / sudo auth dialog — rendered at root so it overlays everything */}
|
||||
<Show when={showQuitDialog()}>
|
||||
{isAdmin() ? (
|
||||
// Admin: simple confirmation without sudo.
|
||||
<div class="sudo-auth-overlay">
|
||||
<div class="sudo-auth-dialog">
|
||||
<div class="sudo-auth-icon">
|
||||
<span class="material-symbols-rounded">power_settings_new</span>
|
||||
</div>
|
||||
<h2 class="sudo-auth-title">{t("app.quit_title")}</h2>
|
||||
<p class="sudo-auth-subtitle">{t("app.close_confirm")}</p>
|
||||
<div class="sudo-auth-actions">
|
||||
<button class="sudo-auth-btn sudo-auth-btn-cancel" onClick={() => setShowQuitDialog(false)}>
|
||||
{t("auth.cancel")}
|
||||
</button>
|
||||
<button class="sudo-auth-btn sudo-auth-btn-submit" onClick={doQuit}>
|
||||
{t("app.close_agent")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Non-admin: require sudo password before quitting.
|
||||
<SudoAuthDialog
|
||||
title={t("app.quit_title")}
|
||||
subtitle={t("app.quit_sudo_hint")}
|
||||
onSuccess={doQuit}
|
||||
onCancel={() => setShowQuitDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={ready()}
|
||||
fallback={
|
||||
<div class="app-loading">
|
||||
<span class="material-symbols-rounded spin">sync</span>
|
||||
<span>{bootStage()}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={registered()}
|
||||
fallback={<SetupWizard onComplete={async () => {
|
||||
// Re-read persisted registration state from backend to ensure
|
||||
// the wizard does not reappear on next app launch.
|
||||
const ok = await checkRegistration();
|
||||
setRegistered(ok || true); // optimistic: show main even if backend hiccups
|
||||
}} />}
|
||||
fallback={
|
||||
<SetupWizard
|
||||
onComplete={async () => {
|
||||
const ok = await checkRegistration();
|
||||
if (ok) {
|
||||
navigateHashRoute("/");
|
||||
}
|
||||
setRegistered(ok);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="app-layout app-layout-tray">
|
||||
<Router>
|
||||
<NavigationListener />
|
||||
<main class="app-main app-main-full">
|
||||
<Route path="/" component={StatusPanel} />
|
||||
<Route path="/chat" component={ChatPanel} />
|
||||
<Route path="/help" component={HelpRequest} />
|
||||
<Route
|
||||
path="/settings"
|
||||
component={() =>
|
||||
isAdmin() ? <SettingsPanel /> : <AdminRequired />
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</Router>
|
||||
</div>
|
||||
<RegisteredShell isAdmin={isAdmin()} onQuit={() => setShowQuitDialog(true)} />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -156,3 +350,4 @@ const App: Component = () => {
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const ChatPanel: Component = () => {
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await invoke("send_chat_message", { text });
|
||||
await invoke("send_chat_message", { message: text });
|
||||
const msg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
from: "user",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Component, createSignal, onMount, onCleanup, Show } from "solid-js";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
interface ConsentRequest {
|
||||
session_id: string;
|
||||
operator: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConsentDialog — floating modal shown when the Go agent (require_consent=true)
|
||||
* emits a "consent-request" event via stdout → Tauri → frontend.
|
||||
*
|
||||
* The user has 30 seconds to Allow or Deny. Auto-deny fires on timeout.
|
||||
* The component is always mounted in App.tsx (floating layer), invisible
|
||||
* until a request arrives.
|
||||
*/
|
||||
const ConsentDialog: Component = () => {
|
||||
const [request, setRequest] = createSignal<ConsentRequest | null>(null);
|
||||
const [timeLeft, setTimeLeft] = createSignal(30);
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
let timerInterval: number | undefined;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerInterval !== undefined) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
clearTimer();
|
||||
setRequest(null);
|
||||
setTimeLeft(30);
|
||||
};
|
||||
|
||||
const answer = async (granted: boolean) => {
|
||||
const req = request();
|
||||
if (!req) return;
|
||||
try {
|
||||
await invoke("answer_consent", { sessionId: req.session_id, granted });
|
||||
} catch (e) {
|
||||
console.error("[consent] answer_consent error:", e);
|
||||
}
|
||||
dismiss();
|
||||
};
|
||||
|
||||
const startTimer = () => {
|
||||
clearTimer();
|
||||
setTimeLeft(30);
|
||||
timerInterval = window.setInterval(() => {
|
||||
setTimeLeft((t) => {
|
||||
if (t <= 1) {
|
||||
// Auto-deny on timeout.
|
||||
answer(false);
|
||||
return 0;
|
||||
}
|
||||
return t - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
unlisten = await listen<string>("consent-request", (event) => {
|
||||
try {
|
||||
const data: ConsentRequest = JSON.parse(event.payload);
|
||||
setRequest(data);
|
||||
startTimer();
|
||||
} catch {
|
||||
console.error("[consent] Invalid consent-request payload:", event.payload);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
clearTimer();
|
||||
unlisten?.();
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={request() !== null}>
|
||||
<div class="consent-overlay">
|
||||
<div class="consent-dialog">
|
||||
<div class="consent-icon">
|
||||
<span class="material-symbols-rounded">screen_share</span>
|
||||
</div>
|
||||
|
||||
<h2 class="consent-title">{t("consent.title")}</h2>
|
||||
|
||||
<p class="consent-operator">
|
||||
<strong>{request()!.operator}</strong>{" "}
|
||||
{t("consent.operator_suffix")}
|
||||
</p>
|
||||
|
||||
<p class="consent-hint">{t("consent.hint")}</p>
|
||||
|
||||
<div class="consent-timer-bar">
|
||||
<div
|
||||
class="consent-timer-fill"
|
||||
style={{ width: `${(timeLeft() / 30) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p class="consent-timer-label">
|
||||
{t("consent.auto_deny_in")} {timeLeft()}s
|
||||
</p>
|
||||
|
||||
<div class="consent-actions">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
onClick={() => answer(false)}
|
||||
>
|
||||
<span class="material-symbols-rounded">block</span>
|
||||
{t("consent.deny")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
onClick={() => answer(true)}
|
||||
>
|
||||
<span class="material-symbols-rounded">check_circle</span>
|
||||
{t("consent.allow")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsentDialog;
|
||||
@@ -1,24 +1,49 @@
|
||||
import { Component, createSignal, onMount, Show } from "solid-js";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { t, setLocale, getLocale, getAvailableLocales, getLocaleDisplayName } from "../lib/i18n";
|
||||
import SudoAuthDialog from "./SudoAuthDialog";
|
||||
|
||||
interface AgentSettings {
|
||||
server_address: string;
|
||||
allow_remote: boolean;
|
||||
api_key: string;
|
||||
cdap_port: number;
|
||||
allow_screen_capture: boolean;
|
||||
require_consent: boolean;
|
||||
allow_file_transfer: boolean;
|
||||
start_with_system: boolean;
|
||||
allow_terminal: boolean;
|
||||
allow_file_browser: boolean;
|
||||
allow_clipboard: boolean;
|
||||
auto_start_sidecar: boolean;
|
||||
autostart: boolean; // matches Rust AgentSettings.autostart
|
||||
start_minimized: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const SettingsPanel: Component = () => {
|
||||
interface SettingsPanelProps {
|
||||
/** Whether the current OS user has administrator / root privileges. */
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const SettingsPanel: Component<SettingsPanelProps> = (props) => {
|
||||
const hasSessionAuth = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
return window.sessionStorage.getItem("betterdesk-agent-settings-auth") === "1";
|
||||
};
|
||||
|
||||
// Non-admin users must authenticate via sudo before accessing settings.
|
||||
const [authed, setAuthed] = createSignal(props.isAdmin || hasSessionAuth());
|
||||
const [settings, setSettings] = createSignal<AgentSettings>({
|
||||
server_address: "",
|
||||
allow_remote: true,
|
||||
require_consent: false,
|
||||
allow_file_transfer: true,
|
||||
start_with_system: true,
|
||||
api_key: "",
|
||||
cdap_port: 21122,
|
||||
allow_screen_capture: true,
|
||||
require_consent: true,
|
||||
allow_terminal: true,
|
||||
allow_file_browser: true,
|
||||
allow_clipboard: true,
|
||||
auto_start_sidecar: true,
|
||||
autostart: true,
|
||||
start_minimized: true,
|
||||
language: "en",
|
||||
});
|
||||
@@ -74,7 +99,29 @@ const SettingsPanel: Component = () => {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Navigate back to the status panel (used by cancel button in auth dialog).
|
||||
const goBack = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = "/";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={authed()}
|
||||
fallback={
|
||||
<SudoAuthDialog
|
||||
subtitle={t("auth.settings_subtitle")}
|
||||
onCancel={goBack}
|
||||
onSuccess={() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.setItem("betterdesk-agent-settings-auth", "1");
|
||||
}
|
||||
setAuthed(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">{t("settings.title")}</h2>
|
||||
|
||||
@@ -103,20 +150,64 @@ const SettingsPanel: Component = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Privacy */}
|
||||
{/* CDAP Agent */}
|
||||
<section class="settings-section">
|
||||
<h3 class="settings-section-title">{t("settings.section_cdap")}</h3>
|
||||
|
||||
<div class="settings-row">
|
||||
<label class="form-label">{t("settings.api_key")}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-input"
|
||||
value={settings().api_key}
|
||||
placeholder={t("settings.api_key_placeholder")}
|
||||
onInput={(e) => updateSetting("api_key", e.currentTarget.value)}
|
||||
/>
|
||||
<div class="form-hint">{t("settings.api_key_hint")}</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<label class="form-label">{t("settings.cdap_port")}</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input form-input-sm"
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={settings().cdap_port}
|
||||
onInput={(e) => updateSetting("cdap_port", parseInt(e.currentTarget.value, 10) || 21122)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-label">{t("settings.auto_start_sidecar")}</div>
|
||||
<div class="settings-toggle-hint">{t("settings.auto_start_sidecar_hint")}</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings().auto_start_sidecar}
|
||||
onChange={(e) => updateSetting("auto_start_sidecar", e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Privacy / Capabilities */}
|
||||
<section class="settings-section">
|
||||
<h3 class="settings-section-title">{t("settings.section_privacy")}</h3>
|
||||
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-label">{t("settings.allow_remote")}</div>
|
||||
<div class="settings-toggle-hint">{t("settings.allow_remote_hint")}</div>
|
||||
<div class="settings-toggle-label">{t("settings.allow_screen_capture")}</div>
|
||||
<div class="settings-toggle-hint">{t("settings.allow_screen_capture_hint")}</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings().allow_remote}
|
||||
onChange={(e) => updateSetting("allow_remote", e.currentTarget.checked)}
|
||||
checked={settings().allow_screen_capture}
|
||||
onChange={(e) => updateSetting("allow_screen_capture", e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-slider" />
|
||||
</label>
|
||||
@@ -139,14 +230,44 @@ const SettingsPanel: Component = () => {
|
||||
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-label">{t("settings.allow_file_transfer")}</div>
|
||||
<div class="settings-toggle-hint">{t("settings.allow_file_transfer_hint")}</div>
|
||||
<div class="settings-toggle-label">{t("settings.allow_terminal")}</div>
|
||||
<div class="settings-toggle-hint">{t("settings.allow_terminal_hint")}</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings().allow_file_transfer}
|
||||
onChange={(e) => updateSetting("allow_file_transfer", e.currentTarget.checked)}
|
||||
checked={settings().allow_terminal}
|
||||
onChange={(e) => updateSetting("allow_terminal", e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-label">{t("settings.allow_file_browser")}</div>
|
||||
<div class="settings-toggle-hint">{t("settings.allow_file_browser_hint")}</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings().allow_file_browser}
|
||||
onChange={(e) => updateSetting("allow_file_browser", e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle-row">
|
||||
<div>
|
||||
<div class="settings-toggle-label">{t("settings.allow_clipboard")}</div>
|
||||
<div class="settings-toggle-hint">{t("settings.allow_clipboard_hint")}</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings().allow_clipboard}
|
||||
onChange={(e) => updateSetting("allow_clipboard", e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-slider" />
|
||||
</label>
|
||||
@@ -177,8 +298,8 @@ const SettingsPanel: Component = () => {
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings().start_with_system}
|
||||
onChange={(e) => updateSetting("start_with_system", e.currentTarget.checked)}
|
||||
checked={settings().autostart}
|
||||
onChange={(e) => updateSetting("autostart", e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="toggle-slider" />
|
||||
</label>
|
||||
@@ -218,6 +339,7 @@ const SettingsPanel: Component = () => {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, createSignal, onMount, onCleanup } from "solid-js";
|
||||
import { Component, Show, createSignal, onMount, onCleanup } from "solid-js";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { t } from "../lib/i18n";
|
||||
import { frontendLog } from "../lib/logger";
|
||||
|
||||
interface AgentStatus {
|
||||
registered: boolean;
|
||||
@@ -14,11 +15,24 @@ interface AgentStatus {
|
||||
last_sync: string;
|
||||
}
|
||||
|
||||
interface SidecarStatus {
|
||||
running: boolean;
|
||||
pid: number;
|
||||
restart_count: number;
|
||||
state: string;
|
||||
binary_path: string;
|
||||
cdap_url: string;
|
||||
}
|
||||
|
||||
const StatusPanel: Component = () => {
|
||||
const [status, setStatus] = createSignal<AgentStatus | null>(null);
|
||||
const [sidecar, setSidecar] = createSignal<SidecarStatus | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [copyFeedback, setCopyFeedback] = createSignal(false);
|
||||
const [diagFeedback, setDiagFeedback] = createSignal<"" | "ok" | "error">("");
|
||||
const [sidecarAction, setSidecarAction] = createSignal<"" | "busy">("");
|
||||
const [sidecarError, setSidecarError] = createSignal("");
|
||||
let initialSnapshotLogged = false;
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
@@ -26,13 +40,33 @@ const StatusPanel: Component = () => {
|
||||
try {
|
||||
const s = await invoke<AgentStatus>("get_agent_status");
|
||||
setStatus(s);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
frontendLog("warn", "status", "get_agent_status failed", error);
|
||||
// Keep last known status
|
||||
}
|
||||
try {
|
||||
const sc = await invoke<SidecarStatus>("get_sidecar_status");
|
||||
setSidecar(sc);
|
||||
} catch (error) {
|
||||
frontendLog("warn", "status", "get_sidecar_status failed", error);
|
||||
// Not critical
|
||||
}
|
||||
|
||||
if (!initialSnapshotLogged && (status() || sidecar())) {
|
||||
frontendLog("info", "status", "Initial status snapshot loaded", {
|
||||
registered: status()?.registered ?? false,
|
||||
connected: status()?.connected ?? false,
|
||||
sidecarState: sidecar()?.state ?? "unknown",
|
||||
sidecarRunning: sidecar()?.running ?? false,
|
||||
});
|
||||
initialSnapshotLogged = true;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
frontendLog("debug", "status", "Status panel mounted");
|
||||
fetchStatus();
|
||||
pollInterval = setInterval(fetchStatus, 5000);
|
||||
});
|
||||
@@ -51,17 +85,63 @@ const StatusPanel: Component = () => {
|
||||
|
||||
const reconnect = async () => {
|
||||
try {
|
||||
frontendLog("info", "status", "Manual reconnect requested");
|
||||
await invoke("reconnect_agent");
|
||||
await fetchStatus();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
frontendLog("error", "status", "reconnect_agent failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
const startSidecar = async () => {
|
||||
setSidecarAction("busy");
|
||||
setSidecarError("");
|
||||
try {
|
||||
frontendLog("info", "status.sidecar", "Starting CDAP sidecar");
|
||||
await invoke("start_sidecar");
|
||||
setTimeout(fetchStatus, 1000);
|
||||
} catch (error) {
|
||||
frontendLog("error", "status.sidecar", "start_sidecar failed", error);
|
||||
setSidecarError(String(error));
|
||||
}
|
||||
setSidecarAction("");
|
||||
};
|
||||
|
||||
const stopSidecar = async () => {
|
||||
setSidecarAction("busy");
|
||||
setSidecarError("");
|
||||
try {
|
||||
frontendLog("info", "status.sidecar", "Stopping CDAP sidecar");
|
||||
await invoke("stop_sidecar");
|
||||
setTimeout(fetchStatus, 500);
|
||||
} catch (error) {
|
||||
frontendLog("error", "status.sidecar", "stop_sidecar failed", error);
|
||||
}
|
||||
setSidecarAction("");
|
||||
};
|
||||
|
||||
const restartSidecar = async () => {
|
||||
setSidecarAction("busy");
|
||||
setSidecarError("");
|
||||
try {
|
||||
frontendLog("info", "status.sidecar", "Restarting CDAP sidecar");
|
||||
await invoke("restart_sidecar");
|
||||
setTimeout(fetchStatus, 1000);
|
||||
} catch (error) {
|
||||
frontendLog("error", "status.sidecar", "restart_sidecar failed", error);
|
||||
setSidecarError(String(error));
|
||||
}
|
||||
setSidecarAction("");
|
||||
};
|
||||
|
||||
const sendDiagnostics = async () => {
|
||||
try {
|
||||
frontendLog("info", "status", "Diagnostics upload requested");
|
||||
await invoke("send_diagnostics");
|
||||
setDiagFeedback("ok");
|
||||
setTimeout(() => setDiagFeedback(""), 3000);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
frontendLog("error", "status", "send_diagnostics failed", error);
|
||||
setDiagFeedback("error");
|
||||
setTimeout(() => setDiagFeedback(""), 3000);
|
||||
}
|
||||
@@ -144,6 +224,85 @@ const StatusPanel: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── CDAP Sidecar Status ── */}
|
||||
<div class="section-header">
|
||||
<span class="material-symbols-rounded">settings_suggest</span>
|
||||
{t("status.sidecar_title")}
|
||||
</div>
|
||||
<div class="sidecar-card">
|
||||
<div class="sidecar-status-row">
|
||||
<span class={`status-dot ${sidecar()?.running ? "dot-green" : "dot-red"}`} />
|
||||
<span class="sidecar-state">
|
||||
{sidecar()?.running
|
||||
? t("status.sidecar_running")
|
||||
: sidecar()?.state === "not_configured"
|
||||
? t("status.sidecar_not_configured")
|
||||
: t("status.sidecar_stopped")}
|
||||
</span>
|
||||
{sidecar()?.pid ? (
|
||||
<span class="sidecar-pid">PID {sidecar()!.pid}</span>
|
||||
) : null}
|
||||
{(sidecar()?.restart_count ?? 0) > 0 ? (
|
||||
<span class="sidecar-restarts" title={t("status.sidecar_restarts_hint")}>
|
||||
<span class="material-symbols-rounded" style="font-size:14px">refresh</span>
|
||||
{sidecar()!.restart_count}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sidecar()?.cdap_url && (
|
||||
<div class="sidecar-detail">
|
||||
<span class="material-symbols-rounded">hub</span>
|
||||
<code>{sidecar()!.cdap_url}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidecar()?.binary_path && (
|
||||
<div class="sidecar-detail sidecar-path">
|
||||
<span class="material-symbols-rounded">terminal</span>
|
||||
<span title={sidecar()!.binary_path}>
|
||||
{sidecar()!.binary_path.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="sidecar-actions">
|
||||
{!sidecar()?.running ? (
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onClick={startSidecar}
|
||||
disabled={sidecarAction() === "busy"}
|
||||
>
|
||||
<span class="material-symbols-rounded">play_arrow</span>
|
||||
{t("status.sidecar_start")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onClick={restartSidecar}
|
||||
disabled={sidecarAction() === "busy"}
|
||||
>
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
{t("status.sidecar_restart")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
onClick={stopSidecar}
|
||||
disabled={sidecarAction() === "busy"}
|
||||
>
|
||||
<span class="material-symbols-rounded">stop</span>
|
||||
{t("status.sidecar_stop")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Show when={sidecarError()}>
|
||||
<div class="form-error">{sidecarError()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="status-actions">
|
||||
{!status()?.connected && (
|
||||
<button class="btn btn-primary" onClick={reconnect}>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Component, createSignal, onMount } from "solid-js";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
interface SudoAuthDialogProps {
|
||||
/** Called when the user successfully authenticates (or is already root). */
|
||||
onSuccess: () => void;
|
||||
/** Called when the user dismisses the dialog without authenticating. */
|
||||
onCancel?: () => void;
|
||||
/** Optional title override. Defaults to `auth.title`. */
|
||||
title?: string;
|
||||
/** Optional subtitle override. Defaults to `auth.subtitle`. */
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const SudoAuthDialog: Component<SudoAuthDialogProps> = (props) => {
|
||||
const [password, setPassword] = createSignal("");
|
||||
const [verifying, setVerifying] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
// Focus password field immediately so the user can start typing.
|
||||
setTimeout(() => inputRef?.focus(), 50);
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
const pw = password();
|
||||
if (!pw) {
|
||||
setError(t("auth.enter_password_error"));
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifying(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const ok = await invoke<boolean>("authenticate_sudo", { password: pw });
|
||||
if (ok) {
|
||||
props.onSuccess();
|
||||
} else {
|
||||
setError(t("auth.wrong_password"));
|
||||
setPassword("");
|
||||
setTimeout(() => inputRef?.focus(), 50);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(t("auth.sudo_error"));
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") submit();
|
||||
if (e.key === "Escape") props.onCancel?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="sudo-auth-overlay">
|
||||
<div class="sudo-auth-dialog">
|
||||
<div class="sudo-auth-icon">
|
||||
<span class="material-symbols-rounded">lock</span>
|
||||
</div>
|
||||
|
||||
<h2 class="sudo-auth-title">
|
||||
{props.title ?? t("auth.title")}
|
||||
</h2>
|
||||
<p class="sudo-auth-subtitle">
|
||||
{props.subtitle ?? t("auth.subtitle")}
|
||||
</p>
|
||||
|
||||
<div class="sudo-auth-field">
|
||||
<label class="sudo-auth-label">{t("auth.password")}</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
class={`sudo-auth-input ${error() ? "sudo-auth-input-error" : ""}`}
|
||||
placeholder={t("auth.password_placeholder")}
|
||||
value={password()}
|
||||
onInput={(e) => { setPassword(e.currentTarget.value); setError(null); }}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={verifying()}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
{error() && (
|
||||
<p class="sudo-auth-error">
|
||||
<span class="material-symbols-rounded">error</span>
|
||||
{error()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="sudo-auth-actions">
|
||||
{props.onCancel && (
|
||||
<button
|
||||
class="sudo-auth-btn sudo-auth-btn-cancel"
|
||||
onClick={props.onCancel}
|
||||
disabled={verifying()}
|
||||
>
|
||||
{t("auth.cancel")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class="sudo-auth-btn sudo-auth-btn-submit"
|
||||
onClick={submit}
|
||||
disabled={verifying()}
|
||||
>
|
||||
{verifying() ? (
|
||||
<>
|
||||
<span class="material-symbols-rounded spin" style="font-size:1rem">sync</span>
|
||||
{t("auth.verifying")}
|
||||
</>
|
||||
) : (
|
||||
t("auth.submit")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SudoAuthDialog;
|
||||
@@ -144,7 +144,7 @@
|
||||
"api_key_hint": "Create an API key in the web console under Server → API Keys.",
|
||||
"cdap_port": "CDAP port",
|
||||
"auto_start_sidecar": "Auto-start CDAP agent",
|
||||
"auto_start_sidecar_hint": "Start the Go CDAP agent automatically with the app",
|
||||
"auto_start_sidecar_hint": "Start the CDAP connection automatically when the app launches",
|
||||
"section_privacy": "Privacy & Capabilities",
|
||||
"allow_screen_capture": "Allow screen capture",
|
||||
"allow_screen_capture_hint": "Operators can view and control this device’s screen",
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
"api_key_hint": "Utwórz klucz API w konsoli webowej: Serwer → Klucze API.",
|
||||
"cdap_port": "Port CDAP",
|
||||
"auto_start_sidecar": "Automatyczne uruchamianie agenta CDAP",
|
||||
"auto_start_sidecar_hint": "Uruchamia agenta Go CDAP razem z aplikacją",
|
||||
"auto_start_sidecar_hint": "Uruchamia połączenie CDAP automatycznie podczas startu aplikacji",
|
||||
"section_privacy": "Prywatność i uprawnienia",
|
||||
"allow_screen_capture": "Zezwól na przechwytywanie ekranu",
|
||||
"allow_screen_capture_hint": "Operatorzy mogą wyświetlać i sterować ekranem urządzenia",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import App from "./App";
|
||||
import { frontendLog, installFrontendErrorLogging } from "./lib/logger";
|
||||
import "./styles/global.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
installFrontendErrorLogging();
|
||||
frontendLog("info", "app.main", "Rendering BetterDesk Agent root");
|
||||
|
||||
render(() => <App />, root!);
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
// WebKitGTK (Linux) does not return CORS headers for the custom tauri://
|
||||
// protocol, so any resource loaded with the `crossorigin` attribute fails
|
||||
// silently — CSS is never applied, JS never executes, leaving a blank window.
|
||||
// Vite adds `crossorigin` automatically on ES-module builds; strip it here.
|
||||
function removeCrossoriginPlugin(): Plugin {
|
||||
return {
|
||||
name: "remove-crossorigin",
|
||||
transformIndexHtml(html: string) {
|
||||
return html.replace(/\s+crossorigin(?:="[^"]*")?/g, "");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solidPlugin()],
|
||||
plugins: [solidPlugin(), removeCrossoriginPlugin()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1421,
|
||||
@@ -13,5 +27,8 @@ export default defineConfig({
|
||||
target: "esnext",
|
||||
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
// Disable module-preload polyfill injection — it emits extra
|
||||
// <link rel="modulepreload" crossorigin> tags that also fail on WebKitGTK.
|
||||
modulePreload: false,
|
||||
},
|
||||
});
|
||||
|
||||
+105
-59
@@ -1,12 +1,15 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -38,8 +41,15 @@ type Agent struct {
|
||||
sessionID string
|
||||
|
||||
// Session managers
|
||||
terminals sync.Map // session_id → *TerminalSession
|
||||
fileHandlers sync.Map // session_id → context.CancelFunc
|
||||
terminals sync.Map // session_id → *TerminalSession
|
||||
fileHandlers sync.Map // session_id → context.CancelFunc
|
||||
desktopStreams sync.Map // session_id → *DesktopStreamer
|
||||
|
||||
// Consent system: when require_consent=true, handleDesktopStart prints
|
||||
// CONSENT_REQUEST to stdout and waits on a channel stored here.
|
||||
// The Tauri wrapper reads stdout, shows a dialog, then writes
|
||||
// CONSENT_GRANTED / CONSENT_DENIED to stdin.
|
||||
consentWaiters sync.Map // session_id → chan bool
|
||||
|
||||
// System modules
|
||||
sysCollector *SystemCollector
|
||||
@@ -73,6 +83,13 @@ func (a *Agent) Run() error {
|
||||
delay := time.Duration(a.cfg.ReconnectSec) * time.Second
|
||||
maxDelay := time.Duration(a.cfg.MaxReconnect) * time.Second
|
||||
|
||||
// Start stdin reader for consent responses when running as a Tauri sidecar.
|
||||
// The Tauri wrapper writes "CONSENT_GRANTED:<session_id>" or
|
||||
// "CONSENT_DENIED:<session_id>" to our stdin after showing the UI dialog.
|
||||
if a.cfg.RequireConsent {
|
||||
go a.stdinConsentReader()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
@@ -316,12 +333,16 @@ func (a *Agent) dispatch(msg *Message) {
|
||||
// ── Clipboard ──
|
||||
case "clipboard_set":
|
||||
a.handleClipboardSet(msg)
|
||||
case "clipboard_get":
|
||||
a.handleClipboardGet(msg)
|
||||
|
||||
// ── Desktop (basic screenshot mode) ──
|
||||
// ── Desktop (Screenshot + Streaming) ──
|
||||
case "desktop_start":
|
||||
a.handleDesktopStart(msg)
|
||||
case "desktop_stop":
|
||||
a.handleDesktopStop(msg)
|
||||
case "desktop_input":
|
||||
// No input injection in os_agent mode
|
||||
a.handleDesktopInput(msg)
|
||||
|
||||
// ── Video / Audio (not supported in os_agent) ──
|
||||
case "video_start", "audio_start", "audio_input":
|
||||
@@ -330,7 +351,9 @@ func (a *Agent) dispatch(msg *Message) {
|
||||
// ── Codec / Media Control ──
|
||||
case "codec_offer":
|
||||
a.handleCodecOffer(msg)
|
||||
case "monitor_select", "keyframe_request", "key_exchange", "quality_adjust":
|
||||
case "monitor_select":
|
||||
a.handleMonitorSelect(msg)
|
||||
case "keyframe_request", "key_exchange", "quality_adjust":
|
||||
// Acknowledged — no real-time media to adjust
|
||||
|
||||
// ── Errors ──
|
||||
@@ -628,65 +651,29 @@ func (a *Agent) handleClipboardSet(msg *Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desktop (Screenshot Mode) ────────────────────────────────────────
|
||||
|
||||
func (a *Agent) handleDesktopStart(msg *Message) {
|
||||
if !a.cfg.Screenshot {
|
||||
return
|
||||
}
|
||||
// handleClipboardGet responds with the current clipboard text contents.
|
||||
// Capability gated by cfg.Clipboard; no-op (with short error response) when
|
||||
// disabled so the operator UI can show a meaningful state.
|
||||
func (a *Agent) handleClipboardGet(msg *Message) {
|
||||
var p struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
FPS int `json:"fps"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &p); err != nil {
|
||||
_ = json.Unmarshal(msg.Payload, &p)
|
||||
|
||||
resp := map[string]any{
|
||||
"request_id": p.RequestID,
|
||||
"format": "text",
|
||||
}
|
||||
|
||||
if !a.cfg.Clipboard {
|
||||
resp["error"] = "clipboard capability disabled"
|
||||
resp["data"] = ""
|
||||
a.sendMessage("clipboard_data", resp)
|
||||
return
|
||||
}
|
||||
|
||||
// In os_agent mode, send a single screenshot frame
|
||||
go func() {
|
||||
data, err := CaptureScreenshot()
|
||||
if err != nil {
|
||||
log.Printf("[agent] Screenshot capture failed: %v", err)
|
||||
return
|
||||
}
|
||||
a.sendMessage("desktop_frame", map[string]any{
|
||||
"session_id": p.SessionID,
|
||||
"format": "jpeg",
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"data": base64.StdEncoding.EncodeToString(data),
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *Agent) captureAndSendScreenshot() (any, error) {
|
||||
data, err := CaptureScreenshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"format": "jpeg",
|
||||
"size": len(data),
|
||||
"data": base64.StdEncoding.EncodeToString(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Codec Offer ──────────────────────────────────────────────────────
|
||||
|
||||
func (a *Agent) handleCodecOffer(msg *Message) {
|
||||
var p struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
json.Unmarshal(msg.Payload, &p)
|
||||
a.sendMessage("codec_answer", map[string]any{
|
||||
"session_id": p.SessionID,
|
||||
"video_codec": "jpeg",
|
||||
"audio_codec": "",
|
||||
})
|
||||
resp["data"] = a.clipboard.Get()
|
||||
a.sendMessage("clipboard_data", resp)
|
||||
}
|
||||
|
||||
// ── Session Cleanup ──────────────────────────────────────────────────
|
||||
@@ -704,6 +691,11 @@ func (a *Agent) cleanupSessions() {
|
||||
a.fileHandlers.Delete(key)
|
||||
return true
|
||||
})
|
||||
a.desktopStreams.Range(func(key, value any) bool {
|
||||
value.(*DesktopStreamer).Stop()
|
||||
a.desktopStreams.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire I/O ─────────────────────────────────────────────────────────
|
||||
@@ -733,6 +725,22 @@ func (a *Agent) sendMessage(msgType string, payload any) error {
|
||||
return a.conn.Write(ctx, websocket.MessageText, raw)
|
||||
}
|
||||
|
||||
// sendBinary writes a raw binary WebSocket frame on the agent's CDAP
|
||||
// connection. Used by high-throughput media paths (e.g. desktop JPEG
|
||||
// frames) where the per-message JSON+base64 cost is the bottleneck.
|
||||
// Callers must encode any framing they need (e.g. a session ID prefix)
|
||||
// directly inside the data buffer.
|
||||
func (a *Agent) sendBinary(data []byte) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.conn == nil {
|
||||
return fmt.Errorf("no connection")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return a.conn.Write(ctx, websocket.MessageBinary, data)
|
||||
}
|
||||
|
||||
func (a *Agent) readMessage() (*Message, error) {
|
||||
_, data, err := a.conn.Read(a.ctx)
|
||||
if err != nil {
|
||||
@@ -744,3 +752,41 @@ func (a *Agent) readMessage() (*Message, error) {
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// ── Consent stdin reader ──────────────────────────────────────────────────
|
||||
//
|
||||
// When running as a Tauri sidecar (require_consent=true), the Tauri wrapper
|
||||
// shows a native dialog and writes the response back on stdin:
|
||||
//
|
||||
// CONSENT_GRANTED:<session_id>
|
||||
// CONSENT_DENIED:<session_id>
|
||||
//
|
||||
// This goroutine reads these lines and signals the waiting handleDesktopStart.
|
||||
func (a *Agent) stdinConsentReader() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
var granted bool
|
||||
var sessionID string
|
||||
switch {
|
||||
case strings.HasPrefix(line, "CONSENT_GRANTED:"):
|
||||
granted = true
|
||||
sessionID = strings.TrimPrefix(line, "CONSENT_GRANTED:")
|
||||
case strings.HasPrefix(line, "CONSENT_DENIED:"):
|
||||
granted = false
|
||||
sessionID = strings.TrimPrefix(line, "CONSENT_DENIED:")
|
||||
default:
|
||||
continue
|
||||
}
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
continue
|
||||
}
|
||||
if ch, ok := a.consentWaiters.Load(sessionID); ok {
|
||||
select {
|
||||
case ch.(chan bool) <- granted:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ type Config struct {
|
||||
FileBrowser bool `json:"file_browser"`
|
||||
Clipboard bool `json:"clipboard"`
|
||||
Screenshot bool `json:"screenshot"`
|
||||
// RequireConsent: when true the agent prints CONSENT_REQUEST to stdout
|
||||
// and waits for CONSENT_GRANTED / CONSENT_DENIED from stdin before
|
||||
// starting a desktop session. The Tauri wrapper shows a dialog to the user.
|
||||
RequireConsent bool `json:"require_consent"`
|
||||
|
||||
FileRoot string `json:"file_root,omitempty"` // root dir for file browser (default: /)
|
||||
HeartbeatSec int `json:"heartbeat_sec"` // default 15
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MonitorInfo describes a single display attached to the agent's machine.
|
||||
// JSON shape mirrors betterdesk-server/cdap/media_control.go MonitorInfo.
|
||||
type MonitorInfo struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
// CaptureStrategy describes a single capture pipeline together with a
|
||||
// human-readable name used in logs. Platform-specific implementations
|
||||
// return an ordered list of strategies; the first one that produces frames
|
||||
// wins, the rest are tried as fallbacks.
|
||||
//
|
||||
// If FullCommand is non-empty the streamer runs that exact command and
|
||||
// expects MJPEG bytes on stdout. Otherwise it spawns ffmpeg with Args as
|
||||
// input flags and appends the standard mjpeg→stdout encoder tail.
|
||||
type CaptureStrategy struct {
|
||||
Name string
|
||||
Args []string
|
||||
FullCommand []string
|
||||
}
|
||||
|
||||
// ── Desktop Streamer ─────────────────────────────────────────────────────
|
||||
|
||||
// DesktopStreamer tracks a single active desktop streaming session.
|
||||
type DesktopStreamer struct {
|
||||
sessionID string
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
done chan struct{}
|
||||
frames atomic.Int64 // total frames sent on this session
|
||||
}
|
||||
|
||||
func newDesktopStreamer(sessionID string, cancel context.CancelFunc) *DesktopStreamer {
|
||||
return &DesktopStreamer{
|
||||
sessionID: sessionID,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// recordFrame is called from each capture path after a frame is sent so the
|
||||
// watchdog can tell whether the pipeline is producing output.
|
||||
func (d *DesktopStreamer) recordFrame() { d.frames.Add(1) }
|
||||
|
||||
// Stop signals the streamer to stop and waits for the goroutine to exit.
|
||||
func (d *DesktopStreamer) Stop() {
|
||||
d.once.Do(func() { d.cancel() })
|
||||
<-d.done
|
||||
}
|
||||
|
||||
// ── Handler: desktop_start ───────────────────────────────────────────────
|
||||
|
||||
// handleDesktopStart starts a continuous screenshot streaming session.
|
||||
// Streams at the requested FPS using ffmpeg if available, otherwise
|
||||
// falls back to periodic single screenshots via CaptureScreenshot().
|
||||
func (a *Agent) handleDesktopStart(msg *Message) {
|
||||
if !a.cfg.Screenshot {
|
||||
_ = a.sendMessage("error", map[string]any{
|
||||
"code": 403,
|
||||
"message": "desktop capture is disabled on this device",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Platform-specific permission pre-flight (macOS Screen Recording check).
|
||||
// checkScreenRecordingPermission is a no-op on non-darwin platforms.
|
||||
if err := checkScreenRecordingPermission(); err != nil {
|
||||
_ = a.sendMessage("error", map[string]any{
|
||||
"code": 403,
|
||||
"message": err.Error(),
|
||||
})
|
||||
log.Printf("[desktop] %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var p struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Quality int `json:"quality"`
|
||||
FPS int `json:"fps"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &p); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if p.SessionID == "" {
|
||||
p.SessionID = "default"
|
||||
}
|
||||
if p.FPS <= 0 || p.FPS > 60 {
|
||||
p.FPS = 15
|
||||
}
|
||||
if p.Quality <= 0 || p.Quality > 100 {
|
||||
p.Quality = 60
|
||||
}
|
||||
if p.OperatorName == "" {
|
||||
p.OperatorName = "operator"
|
||||
}
|
||||
|
||||
// ── Consent gate ─────────────────────────────────────────────────────────
|
||||
// When require_consent=true, print a request to stdout and wait up to 30s
|
||||
// for the Tauri wrapper to respond with CONSENT_GRANTED/DENIED on stdin.
|
||||
if a.cfg.RequireConsent {
|
||||
ch := make(chan bool, 1)
|
||||
a.consentWaiters.Store(p.SessionID, ch)
|
||||
|
||||
// Print to stdout — the Tauri sidecar.rs stdout reader picks this up
|
||||
// and emits a "consent-request" event to the SolidJS frontend.
|
||||
fmt.Fprintf(os.Stdout, "CONSENT_REQUEST:{\"session_id\":%q,\"operator\":%q}\n",
|
||||
p.SessionID, p.OperatorName)
|
||||
|
||||
// Block until response or 30-second timeout.
|
||||
var granted bool
|
||||
timer := time.NewTimer(30 * time.Second)
|
||||
select {
|
||||
case granted = <-ch:
|
||||
case <-timer.C:
|
||||
granted = false
|
||||
case <-a.ctx.Done():
|
||||
a.consentWaiters.Delete(p.SessionID)
|
||||
timer.Stop()
|
||||
return
|
||||
}
|
||||
timer.Stop()
|
||||
a.consentWaiters.Delete(p.SessionID)
|
||||
|
||||
if !granted {
|
||||
_ = a.sendMessage("desktop_consent_denied", map[string]any{
|
||||
"session_id": p.SessionID,
|
||||
})
|
||||
log.Printf("[desktop] Consent denied for session %s", p.SessionID)
|
||||
return
|
||||
}
|
||||
log.Printf("[desktop] Consent granted for session %s", p.SessionID)
|
||||
}
|
||||
|
||||
// Stop any existing session for this session ID.
|
||||
if old, loaded := a.desktopStreams.LoadAndDelete(p.SessionID); loaded {
|
||||
old.(*DesktopStreamer).Stop()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(a.ctx)
|
||||
streamer := newDesktopStreamer(p.SessionID, cancel)
|
||||
a.desktopStreams.Store(p.SessionID, streamer)
|
||||
|
||||
// Send the monitor list as soon as the session is accepted so the
|
||||
// operator's toolbar can populate its dropdown before any frames
|
||||
// arrive. Errors here are non-fatal — single-monitor placeholder is
|
||||
// emitted by enumerateMonitors() on platforms without a backend.
|
||||
monitors := enumerateMonitors()
|
||||
_ = a.sendMessage("monitor_list", map[string]any{
|
||||
"session_id": p.SessionID,
|
||||
"monitors": monitors,
|
||||
"active": 0,
|
||||
})
|
||||
|
||||
// Watchdog: if the capture pipeline does not produce a single frame
|
||||
// within the grace period, send a clear error to the operator instead
|
||||
// of leaving them looking at a black canvas.
|
||||
go a.runDesktopWatchdog(ctx, streamer)
|
||||
|
||||
go func() {
|
||||
defer close(streamer.done)
|
||||
defer a.desktopStreams.Delete(p.SessionID)
|
||||
a.streamDesktop(ctx, streamer, p.FPS, p.Quality)
|
||||
}()
|
||||
}
|
||||
|
||||
// runDesktopWatchdog emits an `error` message after 8 seconds if no frame
|
||||
// has been recorded yet. This converts the silent "black screen" failure
|
||||
// mode into an actionable diagnostic.
|
||||
func (a *Agent) runDesktopWatchdog(ctx context.Context, s *DesktopStreamer) {
|
||||
const grace = 8 * time.Second
|
||||
timer := time.NewTimer(grace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
if s.frames.Load() > 0 {
|
||||
return
|
||||
}
|
||||
hint := desktopCaptureHint()
|
||||
log.Printf("[desktop] no frames produced after %s for session %s — %s", grace, s.sessionID, hint)
|
||||
_ = a.sendMessage("error", map[string]any{
|
||||
"session_id": s.sessionID,
|
||||
"code": 500,
|
||||
"message": "Desktop capture started but produced no frames. " + hint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleDesktopStop terminates a streaming session by ID.
|
||||
func (a *Agent) handleDesktopStop(msg *Message) {
|
||||
var p struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &p); err != nil || p.SessionID == "" {
|
||||
p.SessionID = "default"
|
||||
}
|
||||
|
||||
if sess, loaded := a.desktopStreams.LoadAndDelete(p.SessionID); loaded {
|
||||
sess.(*DesktopStreamer).Stop()
|
||||
log.Printf("[desktop] Stopped session %s", p.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMonitorSelect updates the active monitor index for a streaming
|
||||
// session and re-emits the monitor list so the operator's UI reflects the
|
||||
// new selection. Region-aware capture switching (cropping ffmpeg's input
|
||||
// to the chosen monitor) is wired in a follow-up — for now any selected
|
||||
// monitor still streams the whole virtual desktop, but the toolbar's
|
||||
// active state is correct so the dropdown is usable.
|
||||
func (a *Agent) handleMonitorSelect(msg *Message) {
|
||||
var p struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &p); err != nil {
|
||||
return
|
||||
}
|
||||
if p.SessionID == "" {
|
||||
p.SessionID = "default"
|
||||
}
|
||||
if _, ok := a.desktopStreams.Load(p.SessionID); !ok {
|
||||
return
|
||||
}
|
||||
monitors := enumerateMonitors()
|
||||
active := p.Index
|
||||
if active < 0 || active >= len(monitors) {
|
||||
active = 0
|
||||
}
|
||||
_ = a.sendMessage("monitor_list", map[string]any{
|
||||
"session_id": p.SessionID,
|
||||
"monitors": monitors,
|
||||
"active": active,
|
||||
})
|
||||
log.Printf("[desktop] Active monitor for session %s set to %d (%s) — full virtual desktop still streamed; per-monitor capture coming in a follow-up.",
|
||||
p.SessionID, active, monitors[active].Name)
|
||||
}
|
||||
|
||||
// captureAndSendScreenshot captures a single screenshot and returns a payload
|
||||
// suitable for a widget command response.
|
||||
func (a *Agent) captureAndSendScreenshot() (any, error) {
|
||||
data, err := CaptureScreenshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"format": "jpeg",
|
||||
"size": len(data),
|
||||
"data": base64.StdEncoding.EncodeToString(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Codec Offer ──────────────────────────────────────────────────────────
|
||||
|
||||
// handleCodecOffer responds with the agent's actual encoding capabilities.
|
||||
// os_agent supports JPEG only (screenshot-based). Audio is never supported.
|
||||
func (a *Agent) handleCodecOffer(msg *Message) {
|
||||
var p struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
_ = json.Unmarshal(msg.Payload, &p)
|
||||
|
||||
videoCodec := ""
|
||||
if a.cfg.Screenshot {
|
||||
videoCodec = "jpeg"
|
||||
}
|
||||
|
||||
_ = a.sendMessage("codec_answer", map[string]any{
|
||||
"session_id": p.SessionID,
|
||||
"video_codec": videoCodec,
|
||||
"audio_codec": "",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Streaming logic ──────────────────────────────────────────────────────
|
||||
|
||||
// streamDesktop tries ffmpeg first, falls back to periodic screenshots.
|
||||
func (a *Agent) streamDesktop(ctx context.Context, s *DesktopStreamer, fps, quality int) {
|
||||
if streamWithFFmpeg(ctx, a, s, fps, quality) {
|
||||
return
|
||||
}
|
||||
streamFallback(ctx, a, s, fps, quality)
|
||||
}
|
||||
|
||||
// streamWithFFmpeg launches ffmpeg to capture the screen and streams JPEG
|
||||
// frames to the CDAP server. Returns true if ffmpeg was available and ran.
|
||||
func streamWithFFmpeg(ctx context.Context, a *Agent, s *DesktopStreamer, fps, quality int) bool {
|
||||
ffmpegPath, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// ffmpeg MJPEG quality scale: 2 (best) – 31 (worst), mapped from 0–100.
|
||||
mquality := 31 - (quality * 29 / 100)
|
||||
if mquality < 2 {
|
||||
mquality = 2
|
||||
}
|
||||
|
||||
// captureFFmpegStrategies is platform-specific and returns an ORDERED list
|
||||
// of candidate input pipelines. We try each in turn and stop at the first
|
||||
// one that produces frames — this lets us prefer kmsgrab/pipewire on
|
||||
// Wayland (where x11grab captures only the empty XWayland root) while
|
||||
// still falling back to x11grab on classic X11.
|
||||
strategies := captureFFmpegStrategies(fps)
|
||||
if len(strategies) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, strat := range strategies {
|
||||
var cmd *exec.Cmd
|
||||
if len(strat.FullCommand) > 0 {
|
||||
// Custom binary (e.g. gst-launch-1.0). Substitute %QUALITY% with
|
||||
// the requested 0–100 JPEG quality so callers don't need to know
|
||||
// the value at strategy-construction time.
|
||||
full := make([]string, len(strat.FullCommand))
|
||||
for i, a := range strat.FullCommand {
|
||||
full[i] = strings.ReplaceAll(a, "%QUALITY%", fmt.Sprintf("%d", quality))
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, full[0], full[1:]...)
|
||||
} else {
|
||||
args := append([]string{"-hide_banner", "-loglevel", "error"}, strat.Args...)
|
||||
args = append(args,
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", fmt.Sprintf("%d", mquality),
|
||||
"-f", "image2pipe",
|
||||
"-",
|
||||
)
|
||||
cmd = exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
}
|
||||
stderr := &bytes.Buffer{}
|
||||
cmd.Stderr = stderr
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("[desktop] ffmpeg %s failed to start: %v", strat.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[desktop] ffmpeg streaming via %s: fps=%d quality=%d", strat.Name, fps, quality)
|
||||
|
||||
frames := 0
|
||||
metaSent := false
|
||||
readJPEGFrames(ctx, stdout, func(frame []byte) {
|
||||
frames++
|
||||
if !metaSent {
|
||||
w, h := jpegDimensions(frame)
|
||||
_ = a.sendMessage("desktop_meta", map[string]any{
|
||||
"session_id": s.sessionID,
|
||||
"format": "jpeg",
|
||||
"width": w,
|
||||
"height": h,
|
||||
"binary": true,
|
||||
})
|
||||
metaSent = true
|
||||
}
|
||||
if err := sendDesktopBinaryFrame(a, s.sessionID, frame); err == nil {
|
||||
s.recordFrame()
|
||||
}
|
||||
})
|
||||
|
||||
_ = cmd.Wait()
|
||||
|
||||
// If we got at least one frame, the strategy worked — we're done
|
||||
// (ctx was cancelled by the session ending normally).
|
||||
if frames > 0 || ctx.Err() != nil {
|
||||
log.Printf("[desktop] ffmpeg stream ended for session %s (%s, %d frames)", s.sessionID, strat.Name, frames)
|
||||
return true
|
||||
}
|
||||
|
||||
// No frames produced — likely the capture method is unavailable.
|
||||
// Log stderr (truncated) and try the next strategy.
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if len(msg) > 400 {
|
||||
msg = msg[:400] + "…"
|
||||
}
|
||||
log.Printf("[desktop] %s produced no frames, trying next strategy. stderr: %s", strat.Name, msg)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// readJPEGFrames reads concatenated JPEG frames from r and calls onFrame for
|
||||
// each complete frame delimited by FF D8 … FF D9.
|
||||
func readJPEGFrames(ctx context.Context, r io.Reader, onFrame func([]byte)) {
|
||||
const bufSize = 256 * 1024
|
||||
buf := make([]byte, 0, bufSize)
|
||||
tmp := make([]byte, 32768)
|
||||
|
||||
jpegSOI := []byte{0xFF, 0xD8}
|
||||
jpegEOI := []byte{0xFF, 0xD9}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, readErr := r.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
|
||||
// Extract all complete JPEG frames present in the buffer.
|
||||
for {
|
||||
start := bytes.Index(buf, jpegSOI)
|
||||
if start < 0 {
|
||||
buf = buf[:0]
|
||||
break
|
||||
}
|
||||
end := bytes.Index(buf[start+2:], jpegEOI)
|
||||
if end < 0 {
|
||||
// Incomplete frame — keep data in buffer.
|
||||
if start > 0 {
|
||||
buf = buf[start:]
|
||||
}
|
||||
break
|
||||
}
|
||||
end = start + 2 + end + 2 // include FF D9 bytes
|
||||
|
||||
frame := make([]byte, end-start)
|
||||
copy(frame, buf[start:end])
|
||||
onFrame(frame)
|
||||
buf = buf[end:]
|
||||
}
|
||||
|
||||
if readErr == io.EOF {
|
||||
return
|
||||
}
|
||||
if readErr != nil {
|
||||
log.Printf("[desktop] ffmpeg read error: %v", readErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against unbounded buffer growth on malformed stream.
|
||||
if len(buf) > 8*1024*1024 {
|
||||
log.Printf("[desktop] buffer overflow — resetting")
|
||||
buf = buf[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamFallback periodically captures a screenshot and sends it as a frame.
|
||||
func streamFallback(ctx context.Context, a *Agent, s *DesktopStreamer, fps, _ int) {
|
||||
if fps <= 0 {
|
||||
fps = 5
|
||||
}
|
||||
interval := time.Duration(1000/fps) * time.Millisecond
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Printf("[desktop] Using screenshot fallback: fps=%d interval=%v", fps, interval)
|
||||
|
||||
metaSent := false
|
||||
failures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
data, err := CaptureScreenshot()
|
||||
if err != nil {
|
||||
failures++
|
||||
if failures == 1 || failures%20 == 0 {
|
||||
log.Printf("[desktop] Screenshot failed (%d times): %v", failures, err)
|
||||
}
|
||||
if failures == 5 {
|
||||
_ = a.sendMessage("error", map[string]any{
|
||||
"session_id": s.sessionID,
|
||||
"code": 500,
|
||||
"message": "Screenshot fallback failing repeatedly: " + err.Error() + ". " + desktopCaptureHint(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
if !metaSent {
|
||||
w, h := jpegDimensions(data)
|
||||
_ = a.sendMessage("desktop_meta", map[string]any{
|
||||
"session_id": s.sessionID,
|
||||
"format": "jpeg",
|
||||
"width": w,
|
||||
"height": h,
|
||||
"binary": true,
|
||||
})
|
||||
metaSent = true
|
||||
}
|
||||
if err := sendDesktopBinaryFrame(a, s.sessionID, data); err == nil {
|
||||
s.recordFrame()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frameHeaderSize must match betterdesk-server/cdap/desktop.go's
|
||||
// frameHeaderSize. The agent zero-pads the session ID to this size and
|
||||
// prepends it to every binary JPEG frame.
|
||||
const frameHeaderSize = 64
|
||||
|
||||
// sendDesktopBinaryFrame writes a single JPEG frame as a binary WebSocket
|
||||
// message: [64 bytes session ID, NUL-padded][raw JPEG bytes].
|
||||
// This avoids the ~33% base64 overhead and the JSON marshal/parse cost
|
||||
// for every frame, which is the difference between 1–3 fps and 30+ fps
|
||||
// on a typical helpdesk workload.
|
||||
func sendDesktopBinaryFrame(a *Agent, sessionID string, jpeg []byte) error {
|
||||
if len(sessionID) > frameHeaderSize {
|
||||
sessionID = sessionID[:frameHeaderSize]
|
||||
}
|
||||
buf := make([]byte, frameHeaderSize+len(jpeg))
|
||||
copy(buf[:frameHeaderSize], []byte(sessionID))
|
||||
// Remaining bytes of the header are already zero from make().
|
||||
copy(buf[frameHeaderSize:], jpeg)
|
||||
return a.sendBinary(buf)
|
||||
}
|
||||
|
||||
// jpegDimensions parses width and height from the first SOFn marker in a
|
||||
// JPEG byte slice. Returns (0, 0) if the data isn't a parseable JPEG.
|
||||
func jpegDimensions(data []byte) (int, int) {
|
||||
if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 {
|
||||
return 0, 0
|
||||
}
|
||||
i := 2
|
||||
for i+8 < len(data) {
|
||||
if data[i] != 0xFF {
|
||||
return 0, 0
|
||||
}
|
||||
marker := data[i+1]
|
||||
// SOI / EOI / restart markers have no length field.
|
||||
if marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7) {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
segLen := int(data[i+2])<<8 | int(data[i+3])
|
||||
// SOF0–SOF15 except DHT (0xC4), DAC (0xCC), JPG (0xC8).
|
||||
if marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
|
||||
if i+9 < len(data) {
|
||||
h := int(data[i+5])<<8 | int(data[i+6])
|
||||
w := int(data[i+7])<<8 | int(data[i+8])
|
||||
return w, h
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
i += 2 + segLen
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build darwin
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// captureDevice returns the ffmpeg input format for screen capture on macOS.
|
||||
func captureDevice() string {
|
||||
return "avfoundation"
|
||||
}
|
||||
|
||||
// captureInput returns the ffmpeg AVFoundation screen capture source.
|
||||
// "Capture screen 0" selects the primary display.
|
||||
func captureInput() string {
|
||||
return "Capture screen 0"
|
||||
}
|
||||
|
||||
// captureFFmpegInputArgs returns ffmpeg input arguments for macOS screen capture.
|
||||
func captureFFmpegInputArgs(fps int) []string {
|
||||
return []string{
|
||||
"-f", "avfoundation",
|
||||
"-framerate", fmt.Sprintf("%d", fps),
|
||||
"-i", "Capture screen 0",
|
||||
}
|
||||
}
|
||||
|
||||
// captureFFmpegStrategies returns the (single) macOS capture strategy.
|
||||
func captureFFmpegStrategies(fps int) []CaptureStrategy {
|
||||
return []CaptureStrategy{{
|
||||
Name: "avfoundation",
|
||||
Args: captureFFmpegInputArgs(fps),
|
||||
}}
|
||||
}
|
||||
|
||||
// checkScreenRecordingPermission runs a quick no-op screencapture to detect
|
||||
// whether the Screen Recording permission has been granted on macOS 10.15+.
|
||||
// Returns a non-nil error with instructions if the permission is missing.
|
||||
func checkScreenRecordingPermission() error {
|
||||
// screencapture -x suppresses the shutter sound.
|
||||
// Writing to /dev/null is effectively a no-op that still triggers the
|
||||
// permission check. An exit code ≠1 means the permission was denied.
|
||||
cmd := exec.Command("screencapture", "-x", "-t", "png", "/dev/null")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"screen recording permission denied — open System Settings > Privacy & Security > Screen Recording and enable BetterDesk Agent (exit: %v)",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
// isWaylandSession returns true when running under a Wayland compositor.
|
||||
func isWaylandSession() bool {
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasX11Display returns true when an X11 display is available.
|
||||
// This includes XWayland sessions running inside Wayland compositors.
|
||||
func hasX11Display() bool {
|
||||
return os.Getenv("DISPLAY") != ""
|
||||
}
|
||||
|
||||
// x11Display returns the X11 DISPLAY value, defaulting to ":0".
|
||||
func x11Display() string {
|
||||
if v := os.Getenv("DISPLAY"); v != "" {
|
||||
return v
|
||||
}
|
||||
return ":0"
|
||||
}
|
||||
|
||||
// captureDevice returns the ffmpeg input format (used only by screenshot fallback).
|
||||
func captureDevice() string {
|
||||
if isWaylandSession() && !hasX11Display() {
|
||||
return "pipewire"
|
||||
}
|
||||
return "x11grab"
|
||||
}
|
||||
|
||||
// captureInput returns the ffmpeg input source (used only by screenshot fallback).
|
||||
func captureInput() string {
|
||||
if isWaylandSession() && !hasX11Display() {
|
||||
return "0"
|
||||
}
|
||||
return x11Display()
|
||||
}
|
||||
|
||||
// captureFFmpegInputArgs is kept for backwards compatibility with code paths
|
||||
// that want a single best-guess input. Streaming uses captureFFmpegStrategies.
|
||||
func captureFFmpegInputArgs(fps int) []string {
|
||||
if isWaylandSession() && !hasX11Display() {
|
||||
return []string{
|
||||
"-f", "pipewire",
|
||||
"-i", "0",
|
||||
"-vf", fmt.Sprintf("fps=%d", fps),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"-f", "x11grab",
|
||||
"-framerate", fmt.Sprintf("%d", fps),
|
||||
"-i", x11Display(),
|
||||
}
|
||||
}
|
||||
|
||||
// captureFFmpegStrategies returns an ordered list of ffmpeg capture pipelines
|
||||
// for the current Linux session. The streamer tries them in order until one
|
||||
// produces frames.
|
||||
//
|
||||
// Order on Wayland (KDE / GNOME / sway):
|
||||
// 1. xdg-desktop-portal ScreenCast → pipewire (NATIVE, captures everything)
|
||||
// 2. kmsgrab (DRM, requires CAP_SYS_ADMIN or root)
|
||||
// 3. x11grab on :0 (XWayland — usually shows only X11 windows or a blank
|
||||
// root, but better than nothing as a last resort)
|
||||
//
|
||||
// Order on X11:
|
||||
// 1. x11grab on $DISPLAY
|
||||
//
|
||||
// Order on bare TTY:
|
||||
// 1. kmsgrab
|
||||
func captureFFmpegStrategies(fps int) []CaptureStrategy {
|
||||
var out []CaptureStrategy
|
||||
|
||||
if isWaylandSession() {
|
||||
// 1. Native Wayland via xdg-desktop-portal → PipeWire.
|
||||
// We open the screencast portal here, get a PipeWire node ID, and
|
||||
// hand it to ffmpeg's pipewire demuxer. This is the same path KDE,
|
||||
// GNOME and OBS use; works on Wayland regardless of compositor.
|
||||
if node, restoreToken, err := openScreenCastPortal(); err == nil {
|
||||
// Prefer gst-launch-1.0 — its pipewiresrc plugin is shipped with
|
||||
// every standard Wayland install (gstreamer1-plugins-good +
|
||||
// gstreamer1-plugin-pipewire). ffmpeg's `-f pipewire` demuxer is
|
||||
// rarely compiled in (Fedora/Nobara/Debian don't ship it) so we
|
||||
// avoid that path entirely on Wayland.
|
||||
if gst, err := exec.LookPath("gst-launch-1.0"); err == nil {
|
||||
out = append(out, CaptureStrategy{
|
||||
Name: fmt.Sprintf("gst-pipewire(node=%d)", node),
|
||||
FullCommand: []string{
|
||||
gst, "-q",
|
||||
"pipewiresrc", fmt.Sprintf("path=%d", node), "do-timestamp=true",
|
||||
"!", "videoconvert",
|
||||
"!", "videorate",
|
||||
"!", fmt.Sprintf("video/x-raw,framerate=%d/1", fps),
|
||||
"!", "jpegenc", "quality=%QUALITY%",
|
||||
"!", "fdsink", "fd=1",
|
||||
},
|
||||
})
|
||||
}
|
||||
// ffmpeg pipewire (only works if ffmpeg was built --enable-libpipewire).
|
||||
out = append(out, CaptureStrategy{
|
||||
Name: fmt.Sprintf("ffmpeg-pipewire(node=%d)", node),
|
||||
Args: []string{
|
||||
"-f", "pipewire",
|
||||
"-i", fmt.Sprintf("%d", node),
|
||||
"-vf", fmt.Sprintf("fps=%d", fps),
|
||||
},
|
||||
})
|
||||
// The portal returns a restore token we could persist to skip
|
||||
// the consent prompt next time. For now we just log it; persisting
|
||||
// it across sessions requires user opt-in.
|
||||
if restoreToken != "" {
|
||||
_ = restoreToken
|
||||
}
|
||||
}
|
||||
|
||||
// 2. KMS direct capture (requires permissions; usually root).
|
||||
if hasKMSAccess() {
|
||||
out = append(out, CaptureStrategy{
|
||||
Name: "kmsgrab",
|
||||
Args: []string{
|
||||
"-f", "kmsgrab",
|
||||
"-framerate", fmt.Sprintf("%d", fps),
|
||||
"-i", "-",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 3. XWayland fallback (rarely useful but cheap to try).
|
||||
if hasX11Display() {
|
||||
out = append(out, CaptureStrategy{
|
||||
Name: "x11grab(XWayland)",
|
||||
Args: []string{
|
||||
"-f", "x11grab",
|
||||
"-framerate", fmt.Sprintf("%d", fps),
|
||||
"-i", x11Display(),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Pure X11 session.
|
||||
if hasX11Display() {
|
||||
out = append(out, CaptureStrategy{
|
||||
Name: "x11grab",
|
||||
Args: []string{
|
||||
"-f", "x11grab",
|
||||
"-framerate", fmt.Sprintf("%d", fps),
|
||||
"-i", x11Display(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Bare TTY / kiosk: kmsgrab is the only option.
|
||||
if hasKMSAccess() {
|
||||
out = append(out, CaptureStrategy{
|
||||
Name: "kmsgrab",
|
||||
Args: []string{
|
||||
"-f", "kmsgrab",
|
||||
"-framerate", fmt.Sprintf("%d", fps),
|
||||
"-i", "-",
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasKMSAccess returns true when ffmpeg's kmsgrab device is likely usable
|
||||
// (i.e. /dev/dri/card0 exists and is readable). It does NOT verify CAP_SYS_ADMIN
|
||||
// because requesting that capability requires running ffmpeg first.
|
||||
func hasKMSAccess() bool {
|
||||
for _, path := range []string{"/dev/dri/card0", "/dev/dri/card1"} {
|
||||
if f, err := os.Open(path); err == nil {
|
||||
_ = f.Close()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── xdg-desktop-portal ScreenCast ────────────────────────────────────────
|
||||
//
|
||||
// The portal flow is:
|
||||
// 1. CreateSession → returns a session handle
|
||||
// 2. SelectSources → declares we want a monitor and persistence mode
|
||||
// 3. Start → user is prompted; on success returns PipeWire streams
|
||||
// 4. OpenPipeWireRemote → returns an FD we can hand to ffmpeg
|
||||
//
|
||||
// Each portal call returns a request handle; the actual response arrives
|
||||
// asynchronously on a Response signal. We block on a per-request channel
|
||||
// with a short timeout so the streamer fails fast when the portal is missing
|
||||
// or the user denies consent.
|
||||
|
||||
// portalCallTimeout caps the total round-trip for a single portal call.
|
||||
const portalCallTimeout = 12 * time.Second
|
||||
|
||||
// openScreenCastPortal opens an xdg-desktop-portal ScreenCast session,
|
||||
// negotiates a single monitor stream, and returns the PipeWire node ID for
|
||||
// ffmpeg's `-f pipewire -i <node>` input plus an optional restore token.
|
||||
//
|
||||
// Returns an error if the portal is unreachable, the user denies the prompt,
|
||||
// or any step in the handshake times out. The caller is expected to fall
|
||||
// back to another capture strategy in that case.
|
||||
func openScreenCastPortal() (uint32, string, error) {
|
||||
conn, err := dbus.SessionBus()
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("dbus session: %w", err)
|
||||
}
|
||||
|
||||
portal := conn.Object(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
dbus.ObjectPath("/org/freedesktop/portal/desktop"),
|
||||
)
|
||||
|
||||
// 1. CreateSession ----------------------------------------------------
|
||||
sessionHandleToken := newPortalToken()
|
||||
createReqToken := newPortalToken()
|
||||
createReqPath := requestPath(conn, createReqToken)
|
||||
|
||||
createCh := subscribePortalResponse(conn, createReqPath)
|
||||
defer unsubscribePortalResponse(conn, createReqPath)
|
||||
|
||||
createOpts := map[string]dbus.Variant{
|
||||
"handle_token": dbus.MakeVariant(createReqToken),
|
||||
"session_handle_token": dbus.MakeVariant(sessionHandleToken),
|
||||
}
|
||||
|
||||
var createReply dbus.ObjectPath
|
||||
if err := portal.Call(
|
||||
"org.freedesktop.portal.ScreenCast.CreateSession", 0, createOpts,
|
||||
).Store(&createReply); err != nil {
|
||||
return 0, "", fmt.Errorf("CreateSession: %w", err)
|
||||
}
|
||||
|
||||
createResp, err := waitPortalResponse(createCh, portalCallTimeout)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("CreateSession response: %w", err)
|
||||
}
|
||||
sessionHandleVar, ok := createResp["session_handle"]
|
||||
if !ok {
|
||||
return 0, "", fmt.Errorf("CreateSession: no session_handle")
|
||||
}
|
||||
sessionHandle, ok := sessionHandleVar.Value().(string)
|
||||
if !ok || sessionHandle == "" {
|
||||
return 0, "", fmt.Errorf("CreateSession: bad session_handle type")
|
||||
}
|
||||
sessionPath := dbus.ObjectPath(sessionHandle)
|
||||
|
||||
// 2. SelectSources ----------------------------------------------------
|
||||
selectReqToken := newPortalToken()
|
||||
selectReqPath := requestPath(conn, selectReqToken)
|
||||
|
||||
selectCh := subscribePortalResponse(conn, selectReqPath)
|
||||
defer unsubscribePortalResponse(conn, selectReqPath)
|
||||
|
||||
// types: 1 = MONITOR, 2 = WINDOW, 4 = VIRTUAL (bitmask)
|
||||
// cursor_mode: 1 = HIDDEN, 2 = EMBEDDED, 4 = METADATA
|
||||
// persist_mode: 0 = no, 1 = transient (until logout), 2 = permanent
|
||||
selectOpts := map[string]dbus.Variant{
|
||||
"handle_token": dbus.MakeVariant(selectReqToken),
|
||||
"types": dbus.MakeVariant(uint32(1)),
|
||||
"multiple": dbus.MakeVariant(false),
|
||||
"cursor_mode": dbus.MakeVariant(uint32(2)),
|
||||
"persist_mode": dbus.MakeVariant(uint32(2)),
|
||||
}
|
||||
|
||||
if err := portal.Call(
|
||||
"org.freedesktop.portal.ScreenCast.SelectSources", 0,
|
||||
sessionPath, selectOpts,
|
||||
).Store(&createReply); err != nil {
|
||||
return 0, "", fmt.Errorf("SelectSources: %w", err)
|
||||
}
|
||||
if _, err := waitPortalResponse(selectCh, portalCallTimeout); err != nil {
|
||||
return 0, "", fmt.Errorf("SelectSources response: %w", err)
|
||||
}
|
||||
|
||||
// 3. Start ------------------------------------------------------------
|
||||
startReqToken := newPortalToken()
|
||||
startReqPath := requestPath(conn, startReqToken)
|
||||
|
||||
startCh := subscribePortalResponse(conn, startReqPath)
|
||||
defer unsubscribePortalResponse(conn, startReqPath)
|
||||
|
||||
startOpts := map[string]dbus.Variant{
|
||||
"handle_token": dbus.MakeVariant(startReqToken),
|
||||
}
|
||||
|
||||
if err := portal.Call(
|
||||
"org.freedesktop.portal.ScreenCast.Start", 0,
|
||||
sessionPath, "", startOpts,
|
||||
).Store(&createReply); err != nil {
|
||||
return 0, "", fmt.Errorf("Start: %w", err)
|
||||
}
|
||||
startResp, err := waitPortalResponse(startCh, portalCallTimeout)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("Start response: %w", err)
|
||||
}
|
||||
|
||||
streamsVar, ok := startResp["streams"]
|
||||
if !ok {
|
||||
return 0, "", fmt.Errorf("Start: no streams in response")
|
||||
}
|
||||
streams, ok := streamsVar.Value().([][]interface{})
|
||||
if !ok {
|
||||
// Some portal versions wrap streams as []interface{} of structs.
|
||||
alt, _ := streamsVar.Value().([]interface{})
|
||||
for _, s := range alt {
|
||||
if pair, ok := s.([]interface{}); ok && len(pair) >= 1 {
|
||||
if node, ok := pair[0].(uint32); ok {
|
||||
rt, _ := startResp["restore_token"].Value().(string)
|
||||
return node, rt, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Some bindings unmarshal as []struct{ uint32; map[string]variant }
|
||||
if raw, err := json.Marshal(streamsVar.Value()); err == nil {
|
||||
return 0, "", fmt.Errorf("Start: unsupported streams shape (%s)", raw)
|
||||
}
|
||||
return 0, "", fmt.Errorf("Start: unsupported streams shape")
|
||||
}
|
||||
if len(streams) == 0 {
|
||||
return 0, "", fmt.Errorf("Start: empty streams")
|
||||
}
|
||||
first := streams[0]
|
||||
if len(first) < 1 {
|
||||
return 0, "", fmt.Errorf("Start: bad stream tuple")
|
||||
}
|
||||
node, ok := first[0].(uint32)
|
||||
if !ok {
|
||||
return 0, "", fmt.Errorf("Start: bad node type")
|
||||
}
|
||||
|
||||
restoreToken := ""
|
||||
if rt, ok := startResp["restore_token"]; ok {
|
||||
if s, ok := rt.Value().(string); ok {
|
||||
restoreToken = s
|
||||
}
|
||||
}
|
||||
|
||||
return node, restoreToken, nil
|
||||
}
|
||||
|
||||
// newPortalToken returns a unique per-call handle token.
|
||||
func newPortalToken() string {
|
||||
return fmt.Sprintf("bd_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// requestPath computes the org.freedesktop.portal.Request object path
|
||||
// the portal will emit a Response signal on for a given token.
|
||||
func requestPath(conn *dbus.Conn, token string) dbus.ObjectPath {
|
||||
// The bus name is sender-specific; portal mangles it according to the spec.
|
||||
sender := strings.ReplaceAll(strings.TrimPrefix(conn.Names()[0], ":"), ".", "_")
|
||||
return dbus.ObjectPath(fmt.Sprintf(
|
||||
"/org/freedesktop/portal/desktop/request/%s/%s", sender, token,
|
||||
))
|
||||
}
|
||||
|
||||
// subscribePortalResponse adds a match rule and returns a channel that
|
||||
// receives the Response signal payload for the given request path.
|
||||
func subscribePortalResponse(conn *dbus.Conn, path dbus.ObjectPath) chan map[string]dbus.Variant {
|
||||
ch := make(chan map[string]dbus.Variant, 1)
|
||||
|
||||
rule := fmt.Sprintf(
|
||||
"type='signal',interface='org.freedesktop.portal.Request',member='Response',path='%s'",
|
||||
path,
|
||||
)
|
||||
if call := conn.BusObject().Call(
|
||||
"org.freedesktop.DBus.AddMatch", 0, rule,
|
||||
); call.Err != nil {
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
sigCh := make(chan *dbus.Signal, 4)
|
||||
conn.Signal(sigCh)
|
||||
|
||||
go func() {
|
||||
defer conn.RemoveSignal(sigCh)
|
||||
for sig := range sigCh {
|
||||
if sig.Path != path {
|
||||
continue
|
||||
}
|
||||
if sig.Name != "org.freedesktop.portal.Request.Response" {
|
||||
continue
|
||||
}
|
||||
if len(sig.Body) < 2 {
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
results, _ := sig.Body[1].(map[string]dbus.Variant)
|
||||
ch <- results
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// unsubscribePortalResponse removes the match rule installed by subscribePortalResponse.
|
||||
func unsubscribePortalResponse(conn *dbus.Conn, path dbus.ObjectPath) {
|
||||
rule := fmt.Sprintf(
|
||||
"type='signal',interface='org.freedesktop.portal.Request',member='Response',path='%s'",
|
||||
path,
|
||||
)
|
||||
_ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, rule).Err
|
||||
}
|
||||
|
||||
// waitPortalResponse blocks on ch up to timeout and returns the response map.
|
||||
func waitPortalResponse(ch chan map[string]dbus.Variant, timeout time.Duration) (map[string]dbus.Variant, error) {
|
||||
select {
|
||||
case resp, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("portal channel closed")
|
||||
}
|
||||
return resp, nil
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("timeout after %v", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// (kept to avoid unused-import errors when the build tags evolve)
|
||||
var _ = context.Background
|
||||
var _ = exec.Command
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !darwin
|
||||
|
||||
package agent
|
||||
|
||||
// checkScreenRecordingPermission is a no-op on non-macOS platforms.
|
||||
// On macOS this function verifies that the Screen Recording permission is
|
||||
// granted before starting a capture session (see desktop_darwin.go).
|
||||
func checkScreenRecordingPermission() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
// captureDevice returns the ffmpeg input format for screen capture on Windows.
|
||||
func captureDevice() string {
|
||||
return "gdigrab"
|
||||
}
|
||||
|
||||
// captureInput returns the ffmpeg input source (the primary desktop).
|
||||
func captureInput() string {
|
||||
return "desktop"
|
||||
}
|
||||
|
||||
// captureFFmpegInputArgs returns ffmpeg input arguments for Windows screen capture.
|
||||
func captureFFmpegInputArgs(fps int) []string {
|
||||
return []string{
|
||||
"-f", "gdigrab",
|
||||
"-framerate", fmt.Sprintf("%d", fps),
|
||||
"-i", "desktop",
|
||||
}
|
||||
}
|
||||
|
||||
// captureFFmpegStrategies returns the (single) Windows capture strategy.
|
||||
func captureFFmpegStrategies(fps int) []CaptureStrategy {
|
||||
return []CaptureStrategy{{
|
||||
Name: "gdigrab",
|
||||
Args: captureFFmpegInputArgs(fps),
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ── CDAP desktop_input payload ────────────────────────────────────────────
|
||||
|
||||
// InputEvent represents a single keyboard or mouse event from the operator.
|
||||
// The payload mirrors the CDAP desktop_input message schema.
|
||||
type InputEvent struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Type string `json:"type"` // mouse_move, mouse_click, mouse_scroll, key_press, key_release, text
|
||||
// Mouse fields
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Button int `json:"button"` // 1=left, 2=right, 3=middle
|
||||
DeltaX int `json:"delta_x"`
|
||||
DeltaY int `json:"delta_y"`
|
||||
// Keyboard fields
|
||||
Key string `json:"key"` // key name, e.g. "Return", "a", "ctrl"
|
||||
Text string `json:"text"` // text to type (for "text" event type)
|
||||
Modifiers []string `json:"modifiers"` // ["ctrl", "shift", "alt", "super"]
|
||||
Pressed bool `json:"pressed"` // true=key down, false=key up
|
||||
}
|
||||
|
||||
// handleDesktopInput dispatches a desktop input event to the platform-specific
|
||||
// injection implementation.
|
||||
func (a *Agent) handleDesktopInput(msg *Message) {
|
||||
if !a.cfg.Screenshot {
|
||||
// Input injection requires screen capture permission as a proxy gate.
|
||||
return
|
||||
}
|
||||
|
||||
var evt InputEvent
|
||||
if err := json.Unmarshal(msg.Payload, &evt); err != nil {
|
||||
log.Printf("[input] Parse error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := injectInput(&evt); err != nil {
|
||||
log.Printf("[input] Injection failed (%s): %v", evt.Type, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//go:build darwin
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// injectInput injects keyboard/mouse events on macOS.
|
||||
// Uses cliclick (https://github.com/BlueM/cliclick) for mouse events and
|
||||
// osascript for keyboard events. cliclick must be installed:
|
||||
//
|
||||
// brew install cliclick
|
||||
func injectInput(evt *InputEvent) error {
|
||||
switch evt.Type {
|
||||
case "mouse_move":
|
||||
return cliclick(fmt.Sprintf("m:%d,%d", evt.X, evt.Y))
|
||||
|
||||
case "mouse_click":
|
||||
if err := cliclick(fmt.Sprintf("m:%d,%d", evt.X, evt.Y)); err != nil {
|
||||
return err
|
||||
}
|
||||
return cliclick(darwinClickAction(evt.Button, false))
|
||||
|
||||
case "mouse_down":
|
||||
return cliclick(darwinClickAction(evt.Button, false))
|
||||
|
||||
case "mouse_up":
|
||||
return cliclick(darwinClickAction(evt.Button, true))
|
||||
|
||||
case "mouse_scroll":
|
||||
// cliclick does not support scroll; use osascript
|
||||
if evt.DeltaY != 0 {
|
||||
dir := "down"
|
||||
if evt.DeltaY < 0 {
|
||||
dir = "up"
|
||||
}
|
||||
return osascript(fmt.Sprintf(`tell application "System Events" to scroll %s %d`, dir, abs(evt.DeltaY)))
|
||||
}
|
||||
return nil
|
||||
|
||||
case "key_tap":
|
||||
key, mods := darwinKey(evt.Key, evt.Modifiers)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
script := buildKeyScript(key, mods, true, true)
|
||||
return osascript(script)
|
||||
|
||||
case "key_press":
|
||||
key, mods := darwinKey(evt.Key, evt.Modifiers)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
return osascript(buildKeyScript(key, mods, true, false))
|
||||
|
||||
case "key_release":
|
||||
// osascript doesn't support key-up — ignore
|
||||
return nil
|
||||
|
||||
case "text":
|
||||
if evt.Text == "" {
|
||||
return nil
|
||||
}
|
||||
escaped := strings.ReplaceAll(evt.Text, `"`, `\"`)
|
||||
return osascript(fmt.Sprintf(`tell application "System Events" to keystroke "%s"`, escaped))
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown input type: %s", evt.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func cliclick(args ...string) error {
|
||||
path, err := exec.LookPath("cliclick")
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"cliclick not found — mouse injection requires cliclick: brew install cliclick\n" +
|
||||
"Also enable Accessibility permission in System Settings > Privacy & Security > Accessibility",
|
||||
)
|
||||
}
|
||||
out, err := exec.Command(path, args...).CombinedOutput()
|
||||
if err != nil {
|
||||
// Detect Accessibility permission denial (common on macOS 14+).
|
||||
if strings.Contains(string(out), "permission") || strings.Contains(string(out), "not allowed") {
|
||||
return fmt.Errorf(
|
||||
"cliclick access denied — enable Accessibility permission in System Settings > Privacy & Security > Accessibility for BetterDesk Agent",
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("cliclick: %w (output: %s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func osascript(script string) error {
|
||||
return exec.Command("osascript", "-e", script).Run()
|
||||
}
|
||||
|
||||
func darwinClickAction(button int, up bool) string {
|
||||
switch button {
|
||||
case 1:
|
||||
if up {
|
||||
return "ku:."
|
||||
}
|
||||
return "kd:."
|
||||
case 2:
|
||||
if up {
|
||||
return "rc:."
|
||||
}
|
||||
return "rc:."
|
||||
default:
|
||||
if up {
|
||||
return "ku:."
|
||||
}
|
||||
return "kd:."
|
||||
}
|
||||
}
|
||||
|
||||
var darwinKeyMap = map[string]string{
|
||||
"Return": "return", "Enter": "return",
|
||||
"Backspace": "delete",
|
||||
"Delete": "forward delete", "Del": "forward delete",
|
||||
"Escape": "escape", "Esc": "escape",
|
||||
"Tab": "tab",
|
||||
"Space": "space",
|
||||
"ArrowUp": "up arrow", "Up": "up arrow",
|
||||
"ArrowDown": "down arrow", "Down": "down arrow",
|
||||
"ArrowLeft": "left arrow", "Left": "left arrow",
|
||||
"ArrowRight": "right arrow", "Right": "right arrow",
|
||||
"Home": "home",
|
||||
"End": "end",
|
||||
"PageUp": "page up",
|
||||
"PageDown": "page down",
|
||||
"F1": "F1", "F2": "F2", "F3": "F3", "F4": "F4",
|
||||
"F5": "F5", "F6": "F6", "F7": "F7", "F8": "F8",
|
||||
"F9": "F9", "F10": "F10", "F11": "F11", "F12": "F12",
|
||||
}
|
||||
|
||||
func darwinKey(key string, modifiers []string) (string, []string) {
|
||||
k, ok := darwinKeyMap[key]
|
||||
if !ok {
|
||||
if len(key) == 1 {
|
||||
k = key
|
||||
} else {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
var mods []string
|
||||
for _, mod := range modifiers {
|
||||
switch strings.ToLower(mod) {
|
||||
case "ctrl", "control":
|
||||
mods = append(mods, "control down")
|
||||
case "alt":
|
||||
mods = append(mods, "option down")
|
||||
case "shift":
|
||||
mods = append(mods, "shift down")
|
||||
case "super", "meta", "cmd":
|
||||
mods = append(mods, "command down")
|
||||
}
|
||||
}
|
||||
return k, mods
|
||||
}
|
||||
|
||||
func buildKeyScript(key string, mods []string, press, release bool) string {
|
||||
using := ""
|
||||
if len(mods) > 0 {
|
||||
using = " using {" + strings.Join(mods, ", ") + "}"
|
||||
}
|
||||
|
||||
if len(key) == 1 {
|
||||
return fmt.Sprintf(`tell application "System Events" to keystroke "%s"%s`, key, using)
|
||||
}
|
||||
return fmt.Sprintf(`tell application "System Events" to key code (get key code of "%s")%s`, key, using)
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// injectInput injects a keyboard or mouse event on Linux.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. X11 or XWayland session (DISPLAY set) → use xdotool
|
||||
// 2. Pure Wayland without XWayland (DISPLAY not set) → use ydotool
|
||||
// (requires ydotoold daemon to be running)
|
||||
//
|
||||
// Most Wayland compositors (GNOME, KDE, sway) run XWayland, so path 1
|
||||
// is taken even on Wayland desktops in most real-world setups.
|
||||
func injectInput(evt *InputEvent) error {
|
||||
if hasX11Display() {
|
||||
return injectInputX11(evt)
|
||||
}
|
||||
return injectInputWayland(evt)
|
||||
}
|
||||
|
||||
// ── X11 / XWayland path (xdotool) ────────────────────────────────────────
|
||||
|
||||
func injectInputX11(evt *InputEvent) error {
|
||||
switch evt.Type {
|
||||
case "mouse_move":
|
||||
return xdotool("mousemove", "--sync",
|
||||
fmt.Sprintf("%d", evt.X), fmt.Sprintf("%d", evt.Y))
|
||||
|
||||
case "mouse_click":
|
||||
if err := xdotool("mousemove", "--sync",
|
||||
fmt.Sprintf("%d", evt.X), fmt.Sprintf("%d", evt.Y)); err != nil {
|
||||
return err
|
||||
}
|
||||
return xdotool("click", fmt.Sprintf("%d", linuxMouseButton(evt.Button)))
|
||||
|
||||
case "mouse_down":
|
||||
return xdotool("mousedown", fmt.Sprintf("%d", linuxMouseButton(evt.Button)))
|
||||
|
||||
case "mouse_up":
|
||||
return xdotool("mouseup", fmt.Sprintf("%d", linuxMouseButton(evt.Button)))
|
||||
|
||||
case "mouse_scroll":
|
||||
if evt.DeltaY < 0 {
|
||||
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(evt.DeltaY)), "4")
|
||||
} else if evt.DeltaY > 0 {
|
||||
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(evt.DeltaY)), "5")
|
||||
}
|
||||
if evt.DeltaX < 0 {
|
||||
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(evt.DeltaX)), "6")
|
||||
} else if evt.DeltaX > 0 {
|
||||
return xdotool("click", "--repeat", fmt.Sprintf("%d", abs(evt.DeltaX)), "7")
|
||||
}
|
||||
return nil
|
||||
|
||||
case "key_press":
|
||||
return xdotool("keydown", buildXdotoolKeyCombo(evt.Key, evt.Modifiers))
|
||||
|
||||
case "key_release":
|
||||
return xdotool("keyup", buildXdotoolKeyCombo(evt.Key, evt.Modifiers))
|
||||
|
||||
case "key_tap":
|
||||
return xdotool("key", buildXdotoolKeyCombo(evt.Key, evt.Modifiers))
|
||||
|
||||
case "text":
|
||||
if evt.Text == "" {
|
||||
return nil
|
||||
}
|
||||
return xdotool("type", "--clearmodifiers", "--", evt.Text)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown input type: %s", evt.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// xdotool runs xdotool with the given arguments.
|
||||
func xdotool(args ...string) error {
|
||||
path, err := exec.LookPath("xdotool")
|
||||
if err != nil {
|
||||
return fmt.Errorf("xdotool not found — install it with: sudo apt install xdotool")
|
||||
}
|
||||
return exec.Command(path, args...).Run()
|
||||
}
|
||||
|
||||
// ── Pure-Wayland path (ydotool) ──────────────────────────────────────────
|
||||
|
||||
// injectInputWayland uses ydotool for input injection on pure-Wayland sessions.
|
||||
//
|
||||
// Requirements:
|
||||
// - ydotool installed: sudo apt install ydotool (or build from source)
|
||||
// - ydotoold daemon running: sudo ydotoold &
|
||||
//
|
||||
// ydotool 1.x command syntax is used here. On Debian/Ubuntu the package may be
|
||||
// older (0.x); if commands fail, upgrade or use XWayland instead.
|
||||
func injectInputWayland(evt *InputEvent) error {
|
||||
switch evt.Type {
|
||||
case "mouse_move":
|
||||
return ydotool("mousemove",
|
||||
"-x", fmt.Sprintf("%d", evt.X),
|
||||
"-y", fmt.Sprintf("%d", evt.Y))
|
||||
|
||||
case "mouse_click":
|
||||
if err := ydotool("mousemove",
|
||||
"-x", fmt.Sprintf("%d", evt.X),
|
||||
"-y", fmt.Sprintf("%d", evt.Y)); err != nil {
|
||||
return err
|
||||
}
|
||||
return ydotoolClick(evt.Button)
|
||||
|
||||
case "mouse_down":
|
||||
return ydotoolMouseDown(evt.Button)
|
||||
|
||||
case "mouse_up":
|
||||
return ydotoolMouseUp(evt.Button)
|
||||
|
||||
case "mouse_scroll":
|
||||
// ydotool scroll: positive DeltaY = scroll down
|
||||
if evt.DeltaY != 0 {
|
||||
// ydotool scroll button clicks: 4=wheel-up, 5=wheel-down
|
||||
btn := "5"
|
||||
repeat := evt.DeltaY
|
||||
if evt.DeltaY < 0 {
|
||||
btn = "4"
|
||||
repeat = -repeat
|
||||
}
|
||||
for i := 0; i < repeat; i++ {
|
||||
if err := ydotoolClick(parseScrollButton(btn)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case "key_press":
|
||||
return ydotool("key", "--key-delay", "0",
|
||||
buildYdotoolKey(evt.Key, evt.Modifiers))
|
||||
|
||||
case "key_release":
|
||||
// ydotool does not support individual key-up; send a no-op
|
||||
return nil
|
||||
|
||||
case "key_tap":
|
||||
return ydotool("key", "--key-delay", "12",
|
||||
buildYdotoolKey(evt.Key, evt.Modifiers))
|
||||
|
||||
case "text":
|
||||
if evt.Text == "" {
|
||||
return nil
|
||||
}
|
||||
// wtype is more reliable than ydotool type on many compositors.
|
||||
if path, err := exec.LookPath("wtype"); err == nil {
|
||||
return exec.Command(path, "-d", "0", evt.Text).Run()
|
||||
}
|
||||
return ydotool("type", "--key-delay", "12", "--", evt.Text)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown input type: %s", evt.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// ydotool runs ydotool with the given arguments.
|
||||
func ydotool(args ...string) error {
|
||||
path, err := exec.LookPath("ydotool")
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"ydotool not found — install it and start ydotoold: sudo apt install ydotool && sudo ydotoold &",
|
||||
)
|
||||
}
|
||||
return exec.Command(path, args...).Run()
|
||||
}
|
||||
|
||||
// ydotoolClick sends a full button click (down + up) for a CDAP button number.
|
||||
// CDAP: 1=left, 2=right, 3=middle. ydotool uses the same numbering as xdotool.
|
||||
func ydotoolClick(button int) error {
|
||||
return ydotool("click", fmt.Sprintf("%d", linuxMouseButton(button)))
|
||||
}
|
||||
|
||||
// ydotoolMouseDown presses a mouse button.
|
||||
func ydotoolMouseDown(button int) error {
|
||||
// ydotool does not have separate mousedown/mouseup in all versions;
|
||||
// fall back to a click as a best-effort.
|
||||
return ydotoolClick(button)
|
||||
}
|
||||
|
||||
// ydotoolMouseUp releases a mouse button (best-effort no-op for ydotool).
|
||||
func ydotoolMouseUp(_ int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseScrollButton converts a scroll button string ("4" or "5") to int.
|
||||
func parseScrollButton(btn string) int {
|
||||
if btn == "4" {
|
||||
return 4
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
// buildYdotoolKey constructs a ydotool key combo string using XKB key names.
|
||||
// ydotool accepts the same modifier+key syntax as xdotool.
|
||||
func buildYdotoolKey(key string, modifiers []string) string {
|
||||
return buildXdotoolKeyCombo(key, modifiers)
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// linuxMouseButton maps CDAP button numbers to Linux tool button numbers.
|
||||
// CDAP: 1=left, 2=right, 3=middle. xdotool/ydotool: 1=left, 2=middle, 3=right.
|
||||
func linuxMouseButton(btn int) int {
|
||||
switch btn {
|
||||
case 1:
|
||||
return 1 // left
|
||||
case 2:
|
||||
return 3 // right
|
||||
case 3:
|
||||
return 2 // middle
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// buildXdotoolKeyCombo constructs an xdotool/ydotool key combination string.
|
||||
// Example: key="Return", modifiers=["ctrl"] → "ctrl+Return".
|
||||
func buildXdotoolKeyCombo(key string, modifiers []string) string {
|
||||
var parts []string
|
||||
for _, mod := range modifiers {
|
||||
parts = append(parts, normalizeModifier(mod))
|
||||
}
|
||||
if key != "" {
|
||||
parts = append(parts, xdotoolKeyName(key))
|
||||
}
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
// normalizeModifier maps CDAP modifier names to xdotool/ydotool names.
|
||||
func normalizeModifier(mod string) string {
|
||||
switch strings.ToLower(mod) {
|
||||
case "ctrl", "control":
|
||||
return "ctrl"
|
||||
case "alt":
|
||||
return "alt"
|
||||
case "shift":
|
||||
return "shift"
|
||||
case "super", "meta", "win", "cmd":
|
||||
return "super"
|
||||
default:
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
// xdotoolKeyName maps CDAP key names to xdotool/ydotool key symbol names.
|
||||
func xdotoolKeyName(key string) string {
|
||||
switch key {
|
||||
case "Return", "Enter":
|
||||
return "Return"
|
||||
case "Backspace":
|
||||
return "BackSpace"
|
||||
case "Delete", "Del":
|
||||
return "Delete"
|
||||
case "Escape", "Esc":
|
||||
return "Escape"
|
||||
case "Tab":
|
||||
return "Tab"
|
||||
case "Space", " ":
|
||||
return "space"
|
||||
case "ArrowUp", "Up":
|
||||
return "Up"
|
||||
case "ArrowDown", "Down":
|
||||
return "Down"
|
||||
case "ArrowLeft", "Left":
|
||||
return "Left"
|
||||
case "ArrowRight", "Right":
|
||||
return "Right"
|
||||
case "Home":
|
||||
return "Home"
|
||||
case "End":
|
||||
return "End"
|
||||
case "PageUp":
|
||||
return "Prior"
|
||||
case "PageDown":
|
||||
return "Next"
|
||||
case "F1":
|
||||
return "F1"
|
||||
case "F2":
|
||||
return "F2"
|
||||
case "F3":
|
||||
return "F3"
|
||||
case "F4":
|
||||
return "F4"
|
||||
case "F5":
|
||||
return "F5"
|
||||
case "F6":
|
||||
return "F6"
|
||||
case "F7":
|
||||
return "F7"
|
||||
case "F8":
|
||||
return "F8"
|
||||
case "F9":
|
||||
return "F9"
|
||||
case "F10":
|
||||
return "F10"
|
||||
case "F11":
|
||||
return "F11"
|
||||
case "F12":
|
||||
return "F12"
|
||||
case "PrintScreen":
|
||||
return "Print"
|
||||
case "Insert":
|
||||
return "Insert"
|
||||
case "CapsLock":
|
||||
return "Caps_Lock"
|
||||
case "NumLock":
|
||||
return "Num_Lock"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
procSendInput = user32.NewProc("SendInput")
|
||||
procSetCursorPos = user32.NewProc("SetCursorPos")
|
||||
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
|
||||
)
|
||||
|
||||
const (
|
||||
inputMouse = 0
|
||||
inputKeyboard = 1
|
||||
|
||||
mouseeventfMove = 0x0001
|
||||
mouseeventfLeftdown = 0x0002
|
||||
mouseeventfLeftup = 0x0004
|
||||
mouseeventfRightdown = 0x0008
|
||||
mouseeventfRightup = 0x0010
|
||||
mouseeventfMiddledown = 0x0020
|
||||
mouseeventfMiddleup = 0x0040
|
||||
mouseeventfWheel = 0x0800
|
||||
mouseeventfHwheel = 0x1000
|
||||
mouseeventfAbsolute = 0x8000
|
||||
mouseeventfVirtualdesk = 0x4000
|
||||
|
||||
keyeventfExtendedkey = 0x0001
|
||||
keyeventfKeyup = 0x0002
|
||||
|
||||
smCxvirtualscreen = 78
|
||||
smCyvirtualscreen = 79
|
||||
)
|
||||
|
||||
// mouseInput is the Windows INPUT structure for mouse events.
|
||||
type mouseInput struct {
|
||||
inputType uint32
|
||||
mi struct {
|
||||
dx int32
|
||||
dy int32
|
||||
mouseData uint32
|
||||
dwFlags uint32
|
||||
time uint32
|
||||
dwExtraInfo uintptr
|
||||
}
|
||||
_pad [8]byte // align to 28 bytes (union size)
|
||||
}
|
||||
|
||||
// keyboardInput is the Windows INPUT structure for keyboard events.
|
||||
type keyboardInput struct {
|
||||
inputType uint32
|
||||
ki struct {
|
||||
wVk uint16
|
||||
wScan uint16
|
||||
dwFlags uint32
|
||||
time uint32
|
||||
dwExtraInfo uintptr
|
||||
}
|
||||
_pad [8]byte
|
||||
}
|
||||
|
||||
func injectInput(evt *InputEvent) error {
|
||||
switch evt.Type {
|
||||
case "mouse_move":
|
||||
return sendMouseMove(evt.X, evt.Y)
|
||||
|
||||
case "mouse_click":
|
||||
if err := sendMouseMove(evt.X, evt.Y); err != nil {
|
||||
return err
|
||||
}
|
||||
return sendMouseClick(evt.Button, false)
|
||||
|
||||
case "mouse_down":
|
||||
return sendMouseClick(evt.Button, false)
|
||||
|
||||
case "mouse_up":
|
||||
return sendMouseClick(evt.Button, true)
|
||||
|
||||
case "mouse_scroll":
|
||||
if evt.DeltaY != 0 {
|
||||
return sendMouseWheel(evt.DeltaY * 120)
|
||||
}
|
||||
if evt.DeltaX != 0 {
|
||||
return sendMouseHWheel(evt.DeltaX * 120)
|
||||
}
|
||||
return nil
|
||||
|
||||
case "key_press":
|
||||
vk, ext := cdapKeyToVK(evt.Key)
|
||||
if vk == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, mod := range evt.Modifiers {
|
||||
modVK := modifierVK(mod)
|
||||
if modVK != 0 {
|
||||
if err := sendKey(modVK, false, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return sendKey(vk, ext, false)
|
||||
|
||||
case "key_release":
|
||||
vk, ext := cdapKeyToVK(evt.Key)
|
||||
if vk == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := sendKey(vk, ext, true); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, mod := range evt.Modifiers {
|
||||
modVK := modifierVK(mod)
|
||||
if modVK != 0 {
|
||||
if err := sendKey(modVK, false, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case "key_tap":
|
||||
vk, ext := cdapKeyToVK(evt.Key)
|
||||
if vk == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, mod := range evt.Modifiers {
|
||||
modVK := modifierVK(mod)
|
||||
if modVK != 0 {
|
||||
if err := sendKey(modVK, false, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sendKey(vk, ext, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sendKey(vk, ext, true); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := len(evt.Modifiers) - 1; i >= 0; i-- {
|
||||
modVK := modifierVK(evt.Modifiers[i])
|
||||
if modVK != 0 {
|
||||
if err := sendKey(modVK, false, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case "text":
|
||||
return sendText(evt.Text)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown input type: %s", evt.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func sendMouseMove(x, y int) error {
|
||||
sw, _, _ := procGetSystemMetrics.Call(uintptr(smCxvirtualscreen))
|
||||
sh, _, _ := procGetSystemMetrics.Call(uintptr(smCyvirtualscreen))
|
||||
if sw == 0 {
|
||||
sw = 1920
|
||||
}
|
||||
if sh == 0 {
|
||||
sh = 1080
|
||||
}
|
||||
|
||||
// Normalise to 0–65535 range (MOUSEEVENTF_ABSOLUTE)
|
||||
dx := int32(x * 65535 / int(sw))
|
||||
dy := int32(y * 65535 / int(sh))
|
||||
|
||||
inp := mouseInput{inputType: inputMouse}
|
||||
inp.mi.dx = dx
|
||||
inp.mi.dy = dy
|
||||
inp.mi.dwFlags = mouseeventfMove | mouseeventfAbsolute | mouseeventfVirtualdesk
|
||||
|
||||
return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp))
|
||||
}
|
||||
|
||||
func sendMouseClick(button int, up bool) error {
|
||||
inp := mouseInput{inputType: inputMouse}
|
||||
switch button {
|
||||
case 1: // left
|
||||
if up {
|
||||
inp.mi.dwFlags = mouseeventfLeftup
|
||||
} else {
|
||||
inp.mi.dwFlags = mouseeventfLeftdown
|
||||
}
|
||||
case 2: // right
|
||||
if up {
|
||||
inp.mi.dwFlags = mouseeventfRightup
|
||||
} else {
|
||||
inp.mi.dwFlags = mouseeventfRightdown
|
||||
}
|
||||
case 3: // middle
|
||||
if up {
|
||||
inp.mi.dwFlags = mouseeventfMiddleup
|
||||
} else {
|
||||
inp.mi.dwFlags = mouseeventfMiddledown
|
||||
}
|
||||
}
|
||||
return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp))
|
||||
}
|
||||
|
||||
func sendMouseWheel(delta int) error {
|
||||
inp := mouseInput{inputType: inputMouse}
|
||||
inp.mi.dwFlags = mouseeventfWheel
|
||||
inp.mi.mouseData = uint32(int32(delta))
|
||||
return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp))
|
||||
}
|
||||
|
||||
func sendMouseHWheel(delta int) error {
|
||||
inp := mouseInput{inputType: inputMouse}
|
||||
inp.mi.dwFlags = mouseeventfHwheel
|
||||
inp.mi.mouseData = uint32(int32(delta))
|
||||
return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp))
|
||||
}
|
||||
|
||||
func sendKey(vk uint16, extended bool, keyUp bool) error {
|
||||
inp := keyboardInput{inputType: inputKeyboard}
|
||||
inp.ki.wVk = vk
|
||||
if extended {
|
||||
inp.ki.dwFlags |= keyeventfExtendedkey
|
||||
}
|
||||
if keyUp {
|
||||
inp.ki.dwFlags |= keyeventfKeyup
|
||||
}
|
||||
return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp))
|
||||
}
|
||||
|
||||
func sendText(text string) error {
|
||||
for _, r := range text {
|
||||
inp := keyboardInput{inputType: inputKeyboard}
|
||||
inp.ki.wVk = 0
|
||||
inp.ki.wScan = uint16(r)
|
||||
inp.ki.dwFlags = 0x0004 // KEYEVENTF_UNICODE
|
||||
if err := callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)); err != nil {
|
||||
return err
|
||||
}
|
||||
inp.ki.dwFlags = 0x0004 | keyeventfKeyup
|
||||
if err := callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callSendInput(inp unsafe.Pointer, size uintptr) error {
|
||||
ret, _, err := procSendInput.Call(1, uintptr(inp), size)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("SendInput failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Virtual key codes
|
||||
var cdapKeyVKMap = map[string]uint16{
|
||||
"Return": 0x0D, "Enter": 0x0D,
|
||||
"Backspace": 0x08,
|
||||
"Delete": 0x2E, "Del": 0x2E,
|
||||
"Escape": 0x1B, "Esc": 0x1B,
|
||||
"Tab": 0x09,
|
||||
"Space": 0x20, " ": 0x20,
|
||||
"ArrowUp": 0x26, "Up": 0x26,
|
||||
"ArrowDown": 0x28, "Down": 0x28,
|
||||
"ArrowLeft": 0x25, "Left": 0x25,
|
||||
"ArrowRight": 0x27, "Right": 0x27,
|
||||
"Home": 0x24, "End": 0x23,
|
||||
"PageUp": 0x21, "PageDown": 0x22,
|
||||
"Insert": 0x2D,
|
||||
"F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73,
|
||||
"F5": 0x74, "F6": 0x75, "F7": 0x76, "F8": 0x77,
|
||||
"F9": 0x78, "F10": 0x79, "F11": 0x7A, "F12": 0x7B,
|
||||
"PrintScreen": 0x2C,
|
||||
"CapsLock": 0x14, "NumLock": 0x90, "ScrollLock": 0x91,
|
||||
}
|
||||
|
||||
// Extended keys (require KEYEVENTF_EXTENDEDKEY)
|
||||
var extendedKeys = map[uint16]bool{
|
||||
0x26: true, 0x28: true, 0x25: true, 0x27: true, // arrows
|
||||
0x24: true, 0x23: true, 0x21: true, 0x22: true, // home/end/pgup/pgdn
|
||||
0x2D: true, 0x2E: true, // insert/delete
|
||||
}
|
||||
|
||||
func cdapKeyToVK(key string) (uint16, bool) {
|
||||
if vk, ok := cdapKeyVKMap[key]; ok {
|
||||
return vk, extendedKeys[vk]
|
||||
}
|
||||
// Single printable character: use VkKeyScanA
|
||||
if len(key) == 1 {
|
||||
// Use the ASCII value directly for letters/digits
|
||||
ch := key[0]
|
||||
if ch >= 'a' && ch <= 'z' {
|
||||
return uint16(ch - 32), false // uppercase VK
|
||||
}
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
return uint16(ch), false
|
||||
}
|
||||
if ch >= '0' && ch <= '9' {
|
||||
return uint16(ch), false
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func modifierVK(mod string) uint16 {
|
||||
switch strings.ToLower(mod) {
|
||||
case "ctrl", "control":
|
||||
return 0x11 // VK_CONTROL
|
||||
case "alt":
|
||||
return 0x12 // VK_MENU
|
||||
case "shift":
|
||||
return 0x10 // VK_SHIFT
|
||||
case "super", "meta", "win", "cmd":
|
||||
return 0x5B // VK_LWIN
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,28 @@ import (
|
||||
func BuildManifest(cfg *Config, sys *SystemCollector, version string) map[string]any {
|
||||
info := sys.GetInfo()
|
||||
|
||||
// Capabilities based on config
|
||||
caps := []string{"telemetry", "commands"}
|
||||
if cfg.Terminal {
|
||||
caps = append(caps, "remote_desktop") // terminal is part of remote_desktop capability
|
||||
// Capabilities based on config. Server-side allowedCapabilities (see
|
||||
// betterdesk-server/cdap/manifest.go) accepts: telemetry, commands,
|
||||
// alerts, logs, remote_desktop, video_stream, audio, clipboard,
|
||||
// file_transfer, input_control. Anything else is rejected with
|
||||
// "unknown capability". Both terminal and screenshot map to
|
||||
// remote_desktop — deduplicate via a set.
|
||||
capsSet := map[string]bool{"telemetry": true, "commands": true}
|
||||
if cfg.Terminal || cfg.Screenshot {
|
||||
capsSet["remote_desktop"] = true
|
||||
}
|
||||
if cfg.Screenshot {
|
||||
capsSet["input_control"] = true
|
||||
}
|
||||
if cfg.FileBrowser {
|
||||
caps = append(caps, "file_transfer")
|
||||
capsSet["file_transfer"] = true
|
||||
}
|
||||
if cfg.Clipboard {
|
||||
caps = append(caps, "clipboard")
|
||||
capsSet["clipboard"] = true
|
||||
}
|
||||
caps := make([]string, 0, len(capsSet))
|
||||
for c := range capsSet {
|
||||
caps = append(caps, c)
|
||||
}
|
||||
|
||||
// Build widgets
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build darwin
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// enumerateMonitors uses `system_profiler SPDisplaysDataType` to list the
|
||||
// displays attached to a Mac. The fields we extract are robust against
|
||||
// Apple's continual format churn — only Resolution and the human display
|
||||
// name are required.
|
||||
func enumerateMonitors() []MonitorInfo {
|
||||
out, err := exec.Command("system_profiler", "SPDisplaysDataType").Output()
|
||||
if err != nil {
|
||||
return []MonitorInfo{{Index: 0, Name: "Display", Primary: true}}
|
||||
}
|
||||
|
||||
var mons []MonitorInfo
|
||||
resRe := regexp.MustCompile(`Resolution:\s+(\d+)\s*x\s*(\d+)`)
|
||||
mainRe := regexp.MustCompile(`Main Display:\s+Yes`)
|
||||
|
||||
// Split on blank lines between displays.
|
||||
blocks := strings.Split(string(out), "\n\n")
|
||||
idx := 0
|
||||
for _, blk := range blocks {
|
||||
if !strings.Contains(blk, "Resolution:") {
|
||||
continue
|
||||
}
|
||||
// First non-blank, non-indented colon line is the display name.
|
||||
var name string
|
||||
for _, line := range strings.Split(blk, "\n") {
|
||||
t := strings.TrimRight(line, " :\t")
|
||||
if t == "" || strings.HasPrefix(line, " ") {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimSuffix(t, ":")
|
||||
break
|
||||
}
|
||||
w, h := 0, 0
|
||||
if m := resRe.FindStringSubmatch(blk); m != nil {
|
||||
w, _ = strconv.Atoi(m[1])
|
||||
h, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
mons = append(mons, MonitorInfo{
|
||||
Index: idx,
|
||||
Name: name,
|
||||
Width: w,
|
||||
Height: h,
|
||||
Primary: mainRe.MatchString(blk),
|
||||
})
|
||||
idx++
|
||||
}
|
||||
if len(mons) == 0 {
|
||||
return []MonitorInfo{{Index: 0, Name: "Display", Primary: true}}
|
||||
}
|
||||
return mons
|
||||
}
|
||||
|
||||
// desktopCaptureHint returns guidance for fixing screen capture on macOS.
|
||||
func desktopCaptureHint() string {
|
||||
return "Grant 'Screen Recording' permission to BetterDesk in System Settings → Privacy & Security → Screen Recording, then restart the agent."
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// enumerateMonitors returns the list of displays attached to this machine.
|
||||
// It tries the most common Linux backends in order: xrandr (X11/XWayland),
|
||||
// wlr-randr (wlroots compositors such as Sway/Hyprland), swaymsg (Sway),
|
||||
// and finally a single-virtual-monitor fallback.
|
||||
//
|
||||
// The returned indexes are dense from 0; the agent uses them when handling
|
||||
// `monitor_select` to choose the capture region.
|
||||
func enumerateMonitors() []MonitorInfo {
|
||||
if mons := monitorsXrandr(); len(mons) > 0 {
|
||||
return mons
|
||||
}
|
||||
if mons := monitorsWlrRandr(); len(mons) > 0 {
|
||||
return mons
|
||||
}
|
||||
if mons := monitorsSwaymsg(); len(mons) > 0 {
|
||||
return mons
|
||||
}
|
||||
return []MonitorInfo{{Index: 0, Name: "Display", Width: 0, Height: 0, Primary: true}}
|
||||
}
|
||||
|
||||
// monitorsXrandr parses `xrandr --listmonitors` output of the form:
|
||||
//
|
||||
// Monitors: 2
|
||||
// 0: +*HDMI-1 1920/477x1080/268+0+0 HDMI-1
|
||||
// 1: DP-1 2560/600x1440/340+1920+0 DP-1
|
||||
//
|
||||
// The leading `*` marks the primary monitor.
|
||||
func monitorsXrandr() []MonitorInfo {
|
||||
if _, err := exec.LookPath("xrandr"); err != nil {
|
||||
return nil
|
||||
}
|
||||
out, err := exec.Command("xrandr", "--listmonitors").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match: <num>: <flags><name> <w>/<wmm>x<h>/<hmm>+<x>+<y> <output>
|
||||
re := regexp.MustCompile(`^\s*(\d+):\s+([+*]+)?(\S+)\s+(\d+)/\d+x(\d+)/\d+\+(\-?\d+)\+(\-?\d+)\s+(\S+)`)
|
||||
var out2 []MonitorInfo
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
m := re.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
idx, _ := strconv.Atoi(m[1])
|
||||
flags := m[2]
|
||||
w, _ := strconv.Atoi(m[4])
|
||||
h, _ := strconv.Atoi(m[5])
|
||||
x, _ := strconv.Atoi(m[6])
|
||||
y, _ := strconv.Atoi(m[7])
|
||||
out2 = append(out2, MonitorInfo{
|
||||
Index: idx,
|
||||
Name: m[8],
|
||||
Width: w,
|
||||
Height: h,
|
||||
X: x,
|
||||
Y: y,
|
||||
Primary: strings.Contains(flags, "*"),
|
||||
})
|
||||
}
|
||||
return out2
|
||||
}
|
||||
|
||||
// monitorsWlrRandr parses `wlr-randr` output. Each output starts with the
|
||||
// name on its own line; we read the "current" mode and the position from
|
||||
// the indented properties.
|
||||
func monitorsWlrRandr() []MonitorInfo {
|
||||
if _, err := exec.LookPath("wlr-randr"); err != nil {
|
||||
return nil
|
||||
}
|
||||
out, err := exec.Command("wlr-randr").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
mons []MonitorInfo
|
||||
current MonitorInfo
|
||||
have bool
|
||||
)
|
||||
flush := func() {
|
||||
if have {
|
||||
current.Index = len(mons)
|
||||
mons = append(mons, current)
|
||||
}
|
||||
current = MonitorInfo{}
|
||||
have = false
|
||||
}
|
||||
modeRe := regexp.MustCompile(`(\d+)x(\d+)\s+px.*current`)
|
||||
posRe := regexp.MustCompile(`Position:\s+(\-?\d+),(\-?\d+)`)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||
flush()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
current.Name = fields[0]
|
||||
have = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m := modeRe.FindStringSubmatch(line); m != nil {
|
||||
current.Width, _ = strconv.Atoi(m[1])
|
||||
current.Height, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
if m := posRe.FindStringSubmatch(line); m != nil {
|
||||
current.X, _ = strconv.Atoi(m[1])
|
||||
current.Y, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(mons) > 0 {
|
||||
mons[0].Primary = true
|
||||
}
|
||||
return mons
|
||||
}
|
||||
|
||||
// monitorsSwaymsg parses `swaymsg -t get_outputs` (best-effort). We avoid
|
||||
// pulling in a JSON dependency tree by relying on tiny field captures.
|
||||
func monitorsSwaymsg() []MonitorInfo {
|
||||
if _, err := exec.LookPath("swaymsg"); err != nil {
|
||||
return nil
|
||||
}
|
||||
out, err := exec.Command("swaymsg", "-t", "get_outputs", "--raw").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
nameRe := regexp.MustCompile(`"name"\s*:\s*"([^"]+)"`)
|
||||
rectRe := regexp.MustCompile(`"rect"\s*:\s*\{\s*"x"\s*:\s*(\-?\d+)\s*,\s*"y"\s*:\s*(\-?\d+)\s*,\s*"width"\s*:\s*(\d+)\s*,\s*"height"\s*:\s*(\d+)`)
|
||||
primRe := regexp.MustCompile(`"primary"\s*:\s*(true|false)`)
|
||||
|
||||
names := nameRe.FindAllStringSubmatch(string(out), -1)
|
||||
rects := rectRe.FindAllStringSubmatch(string(out), -1)
|
||||
prims := primRe.FindAllStringSubmatch(string(out), -1)
|
||||
if len(names) == 0 || len(names) != len(rects) {
|
||||
return nil
|
||||
}
|
||||
mons := make([]MonitorInfo, 0, len(names))
|
||||
for i := range names {
|
||||
x, _ := strconv.Atoi(rects[i][1])
|
||||
y, _ := strconv.Atoi(rects[i][2])
|
||||
w, _ := strconv.Atoi(rects[i][3])
|
||||
h, _ := strconv.Atoi(rects[i][4])
|
||||
primary := false
|
||||
if i < len(prims) {
|
||||
primary = prims[i][1] == "true"
|
||||
}
|
||||
mons = append(mons, MonitorInfo{
|
||||
Index: i,
|
||||
Name: names[i][1],
|
||||
Width: w,
|
||||
Height: h,
|
||||
X: x,
|
||||
Y: y,
|
||||
Primary: primary,
|
||||
})
|
||||
}
|
||||
return mons
|
||||
}
|
||||
|
||||
// desktopCaptureHint returns a short, user-actionable string describing what
|
||||
// to install to make screen capture work on this Linux machine.
|
||||
func desktopCaptureHint() string {
|
||||
if isWaylandSession() {
|
||||
return "Install gst-plugins-good and pipewire (Wayland) or grant the screen-capture portal permission, e.g. 'sudo dnf install gstreamer1-plugins-good gstreamer1-plugin-pipewire' on Fedora/Nobara."
|
||||
}
|
||||
return "Install ffmpeg or scrot/grim/imagemagick (e.g. 'sudo apt install ffmpeg' or 'sudo dnf install ffmpeg') and ensure $DISPLAY is set."
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// enumerateMonitors lists displays via PowerShell's WMI bridge. The Win32
|
||||
// device-context APIs exist in syscall but pulling them in for what is a
|
||||
// once-per-session enumeration costs more than the PowerShell hop.
|
||||
func enumerateMonitors() []MonitorInfo {
|
||||
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command",
|
||||
`Get-WmiObject -Namespace root\wmi -Class WmiMonitorBasicDisplayParams |
|
||||
ForEach-Object { [pscustomobject]@{ Name = $_.InstanceName } } |
|
||||
ConvertTo-Json -Compress`).Output()
|
||||
if err == nil {
|
||||
s := strings.TrimSpace(string(out))
|
||||
if s != "" {
|
||||
var raw []struct{ Name string }
|
||||
if err := json.Unmarshal([]byte(s), &raw); err == nil {
|
||||
mons := make([]MonitorInfo, 0, len(raw))
|
||||
for i, r := range raw {
|
||||
mons = append(mons, MonitorInfo{
|
||||
Index: i,
|
||||
Name: r.Name,
|
||||
Primary: i == 0,
|
||||
})
|
||||
}
|
||||
if len(mons) > 0 {
|
||||
return mons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []MonitorInfo{{Index: 0, Name: "Display", Primary: true}}
|
||||
}
|
||||
|
||||
// desktopCaptureHint returns guidance for fixing screen capture on Windows.
|
||||
func desktopCaptureHint() string {
|
||||
return "Ensure ffmpeg.exe is on PATH and that the agent process has permission to capture the active desktop session. On RDP/locked sessions screen capture is blocked by Windows."
|
||||
}
|
||||
@@ -4,31 +4,69 @@ package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// captureScreenshotPlatform captures a screenshot on Linux/macOS using
|
||||
// available command-line tools. Returns JPEG bytes.
|
||||
//
|
||||
// Linux tool priority:
|
||||
// 1. Wayland: grim → wayshot → gnome-screenshot (portal-based, modern)
|
||||
// 2. X11/XWayland: scrot → import (ImageMagick) → gnome-screenshot
|
||||
//
|
||||
// macOS:
|
||||
// 1. screencapture (built-in, requires Screen Recording permission on 10.15+)
|
||||
func captureScreenshotPlatform() ([]byte, error) {
|
||||
// macOS: screencapture
|
||||
// macOS: screencapture is always the right tool; try it first when on darwin.
|
||||
if path, err := exec.LookPath("screencapture"); err == nil {
|
||||
// -x = no shutter sound, -t jpg = JPEG format, - = write to stdout
|
||||
cmd := exec.Command(path, "-x", "-t", "jpg", "-")
|
||||
out, err := cmd.Output()
|
||||
if err == nil && len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Linux: import (ImageMagick)
|
||||
if path, err := exec.LookPath("import"); err == nil {
|
||||
cmd := exec.Command(path, "-window", "root", "jpeg:-")
|
||||
out, err := cmd.Output()
|
||||
if err == nil && len(out) > 0 {
|
||||
return out, nil
|
||||
// Non-nil error usually means Screen Recording permission was denied.
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"screencapture failed (Screen Recording permission may be denied — open System Settings > Privacy & Security > Screen Recording): %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Linux: scrot
|
||||
// Linux: Wayland-native tools first when a Wayland session is active.
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" && os.Getenv("DISPLAY") == "" {
|
||||
// grim: wlroots / sway / labwc
|
||||
if path, err := exec.LookPath("grim"); err == nil {
|
||||
cmd := exec.Command(path, "-")
|
||||
out, err := cmd.Output()
|
||||
if err == nil && len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// wayshot: another lightweight wlroots screenshotter
|
||||
if path, err := exec.LookPath("wayshot"); err == nil {
|
||||
cmd := exec.Command(path, "--stdout")
|
||||
out, err := cmd.Output()
|
||||
if err == nil && len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GNOME / KDE Wayland (XDG Desktop Portal)
|
||||
if path, err := exec.LookPath("gnome-screenshot"); err == nil {
|
||||
cmd := exec.Command(path, "-f", "/dev/stdout")
|
||||
out, err := cmd.Output()
|
||||
if err == nil && len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Linux X11 / XWayland path:
|
||||
// scrot writes to stdout when given "-" as filename.
|
||||
if path, err := exec.LookPath("scrot"); err == nil {
|
||||
cmd := exec.Command(path, "-o", "-", "--quality", "80")
|
||||
out, err := cmd.Output()
|
||||
@@ -37,7 +75,16 @@ func captureScreenshotPlatform() ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Linux: gnome-screenshot
|
||||
// ImageMagick import
|
||||
if path, err := exec.LookPath("import"); err == nil {
|
||||
cmd := exec.Command(path, "-window", "root", "jpeg:-")
|
||||
out, err := cmd.Output()
|
||||
if err == nil && len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: gnome-screenshot on X11
|
||||
if path, err := exec.LookPath("gnome-screenshot"); err == nil {
|
||||
cmd := exec.Command(path, "-f", "/dev/stdout")
|
||||
out, err := cmd.Output()
|
||||
@@ -46,5 +93,5 @@ func captureScreenshotPlatform() ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no screenshot tool available (install scrot, ImageMagick, or gnome-screenshot)")
|
||||
return nil, fmt.Errorf("no screenshot tool available — install one of: scrot, grim (Wayland), ImageMagick")
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ require (
|
||||
|
||||
require (
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -35,6 +37,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -128,6 +128,7 @@ type EnrollmentRequest struct {
|
||||
type EnrollmentResponse struct {
|
||||
Status string `json:"status"` // approved, pending, rejected
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceToken string `json:"device_token,omitempty"`
|
||||
ServerTime int64 `json:"server_time"`
|
||||
SyncMode string `json:"sync_mode,omitempty"` // silent, standard, turbo
|
||||
DisplayName string `json:"display_name,omitempty"` // Operator-assigned name
|
||||
@@ -214,6 +215,11 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
|
||||
// Auto-approve: create peer immediately
|
||||
s.createPeerFromEnrollment(&req, clientIP)
|
||||
resp := s.buildEnrollmentResponse("approved", req.DeviceID, "standard", "")
|
||||
if token, err := s.issueEnrollmentDeviceToken(req.DeviceID); err == nil {
|
||||
resp.DeviceToken = token
|
||||
} else {
|
||||
log.Printf("[API] Failed to auto-issue enrollment device token for %s: %v", req.DeviceID, err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
|
||||
@@ -234,6 +240,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.db.IncrementTokenUse(tok.TokenHash)
|
||||
s.createPeerFromEnrollment(&req, clientIP)
|
||||
resp := s.buildEnrollmentResponse("approved", req.DeviceID, "standard", "")
|
||||
resp.DeviceToken = req.Token
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
|
||||
@@ -289,6 +296,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.db.IncrementTokenUse(tok.TokenHash)
|
||||
s.createPeerFromEnrollment(&req, clientIP)
|
||||
resp := s.buildEnrollmentResponse("approved", req.DeviceID, "standard", "")
|
||||
resp.DeviceToken = req.Token
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
|
||||
@@ -579,6 +587,31 @@ func (s *Server) buildEnrollmentResponse(status, deviceID, syncMode, displayName
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *Server) issueEnrollmentDeviceToken(deviceID string) (string, error) {
|
||||
plainToken, err := generateSecureToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := &db.DeviceToken{
|
||||
Token: plainToken,
|
||||
TokenHash: hashToken(plainToken),
|
||||
Name: "Auto-" + deviceID,
|
||||
PeerID: deviceID,
|
||||
Status: db.TokenStatusActive,
|
||||
MaxUses: 0,
|
||||
UseCount: 0,
|
||||
CreatedBy: "system",
|
||||
Note: "Auto-issued during device enrollment",
|
||||
}
|
||||
|
||||
if err := s.db.CreateDeviceToken(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return plainToken, nil
|
||||
}
|
||||
|
||||
func (s *Server) createPeerFromEnrollment(req *EnrollmentRequest, clientIP string) {
|
||||
devType := req.DeviceType
|
||||
if devType == "" {
|
||||
|
||||
@@ -3,10 +3,12 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -17,8 +19,136 @@ import (
|
||||
// commandCounter generates unique command IDs for CDAP commands.
|
||||
var commandCounter atomic.Int64
|
||||
|
||||
// cdapDeviceIDRegexp validates CDAP device ID format: "CDAP-" + 6-16 hex chars, or standard peer IDs.
|
||||
var cdapDeviceIDRegexp = regexp.MustCompile(`^(CDAP-[A-Fa-f0-9]{6,16}|[A-Za-z0-9_-]{6,16})$`)
|
||||
// cdapDeviceIDRegexp validates CDAP device ID format:
|
||||
// - "CDAP-" + 6-16 hex chars (e.g. CDAP-1A2B3C4D)
|
||||
// - "BD-" + 16-32 hex chars (e.g. BD-37109D6FA4527A4C7A272E7D749C9D36)
|
||||
// - standard peer IDs: 1-64 alphanumeric/dash/underscore chars
|
||||
var cdapDeviceIDRegexp = regexp.MustCompile(`^(CDAP-[A-Fa-f0-9]{6,16}|BD-[A-Fa-f0-9]{16,32}|[A-Za-z0-9_-]{1,64})$`)
|
||||
|
||||
type desktopWSMessage struct {
|
||||
Type string `json:"type"`
|
||||
InputType string `json:"input_type,omitempty"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
Button int `json:"button,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Modifiers json.RawMessage `json:"modifiers,omitempty"`
|
||||
Down *bool `json:"down,omitempty"`
|
||||
DeltaX int `json:"delta_x,omitempty"`
|
||||
DeltaY int `json:"delta_y,omitempty"`
|
||||
DeltaXCamel int `json:"deltaX,omitempty"`
|
||||
DeltaYCamel int `json:"deltaY,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func buildDesktopInputPayload(msg *desktopWSMessage) *cdap.DesktopInputPayload {
|
||||
inputType := strings.ToLower(strings.TrimSpace(msg.InputType))
|
||||
switch inputType {
|
||||
case "mouse":
|
||||
mouseType := msg.Button & 7
|
||||
payload := &cdap.DesktopInputPayload{
|
||||
X: msg.X,
|
||||
Y: msg.Y,
|
||||
Button: decodeDesktopMouseButton(msg.Button >> 3),
|
||||
DeltaX: pickDesktopDelta(msg.DeltaX, msg.DeltaXCamel),
|
||||
DeltaY: pickDesktopDelta(msg.DeltaY, msg.DeltaYCamel),
|
||||
}
|
||||
switch mouseType {
|
||||
case 1:
|
||||
payload.Type = "mouse_down"
|
||||
case 2:
|
||||
payload.Type = "mouse_up"
|
||||
case 3:
|
||||
payload.Type = "mouse_scroll"
|
||||
default:
|
||||
payload.Type = "mouse_move"
|
||||
}
|
||||
return payload
|
||||
case "keyboard":
|
||||
pressed := msg.Down == nil || *msg.Down
|
||||
payload := &cdap.DesktopInputPayload{
|
||||
Key: msg.Key,
|
||||
Code: msg.Code,
|
||||
Modifiers: decodeDesktopModifiers(msg.Modifiers),
|
||||
Pressed: pressed,
|
||||
}
|
||||
if pressed {
|
||||
payload.Type = "key_press"
|
||||
} else {
|
||||
payload.Type = "key_release"
|
||||
}
|
||||
return payload
|
||||
case "text":
|
||||
if strings.TrimSpace(msg.Text) == "" {
|
||||
return nil
|
||||
}
|
||||
return &cdap.DesktopInputPayload{
|
||||
Type: "text",
|
||||
Text: msg.Text,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func pickDesktopDelta(primary, secondary int) int {
|
||||
if primary != 0 {
|
||||
return primary
|
||||
}
|
||||
return secondary
|
||||
}
|
||||
|
||||
func decodeDesktopMouseButton(buttonBits int) int {
|
||||
switch buttonBits {
|
||||
case 1:
|
||||
return 1
|
||||
case 2:
|
||||
return 2
|
||||
case 4:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func decodeDesktopModifiers(raw json.RawMessage) []string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(raw, &flags); err == nil {
|
||||
modifiers := make([]string, 0, 4)
|
||||
if flags["ctrl"] || flags["control"] {
|
||||
modifiers = append(modifiers, "ctrl")
|
||||
}
|
||||
if flags["alt"] {
|
||||
modifiers = append(modifiers, "alt")
|
||||
}
|
||||
if flags["shift"] {
|
||||
modifiers = append(modifiers, "shift")
|
||||
}
|
||||
if flags["meta"] || flags["super"] {
|
||||
modifiers = append(modifiers, "super")
|
||||
}
|
||||
return modifiers
|
||||
}
|
||||
|
||||
var modifiers []string
|
||||
if err := json.Unmarshal(raw, &modifiers); err == nil {
|
||||
return modifiers
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCDAPDeviceInfo returns full device info for a connected CDAP device.
|
||||
// GET /api/cdap/devices/{id}
|
||||
@@ -588,49 +718,40 @@ func (s *Server) handleCDAPDesktop(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dead-man switch (Phase 3.5): every browser read has a hard deadline.
|
||||
// The web client emits `presence_ping` every 15s; missing two pings in a
|
||||
// row terminates the session so a crashed/frozen operator browser does
|
||||
// not leave the agent capturing forever. The deadline resets on every
|
||||
// successful read, including pings.
|
||||
const desktopBrowserPresenceTimeout = 30 * time.Second
|
||||
|
||||
for {
|
||||
_, msgData, err := wsConn.Read(ctx)
|
||||
readCtx, readCancel := context.WithTimeout(ctx, desktopBrowserPresenceTimeout)
|
||||
_, msgData, err := wsConn.Read(readCtx)
|
||||
readCancel()
|
||||
if err != nil {
|
||||
s.cdapGw.EndDesktopSession(ctx, session.ID, "browser disconnected")
|
||||
reason := "browser disconnected"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
reason = "browser presence timeout"
|
||||
}
|
||||
s.cdapGw.EndDesktopSession(ctx, session.ID, reason)
|
||||
return
|
||||
}
|
||||
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
InputType string `json:"input_type,omitempty"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
Button int `json:"button,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Modifiers int `json:"modifiers,omitempty"`
|
||||
DeltaX int `json:"delta_x,omitempty"`
|
||||
DeltaY int `json:"delta_y,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
var msg desktopWSMessage
|
||||
if err := json.Unmarshal(msgData, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
msg.Raw = json.RawMessage(msgData)
|
||||
|
||||
switch msg.Type {
|
||||
case "presence_ping":
|
||||
// Read deadline already reset above — nothing else to do.
|
||||
continue
|
||||
case "input":
|
||||
input := &cdap.DesktopInputPayload{
|
||||
InputType: msg.InputType,
|
||||
X: msg.X,
|
||||
Y: msg.Y,
|
||||
Button: msg.Button,
|
||||
Key: msg.Key,
|
||||
Code: msg.Code,
|
||||
Modifiers: msg.Modifiers,
|
||||
DeltaX: msg.DeltaX,
|
||||
DeltaY: msg.DeltaY,
|
||||
input := buildDesktopInputPayload(&msg)
|
||||
if input == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.cdapGw.RelayDesktopInput(ctx, session.ID, input); err != nil {
|
||||
s.cdapGw.EndDesktopSession(ctx, session.ID, "relay input failed")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cdap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -51,17 +52,21 @@ type DesktopFramePayload struct {
|
||||
}
|
||||
|
||||
// DesktopInputPayload is sent from the browser to the device.
|
||||
// It matches the Go agent's InputEvent schema so the server can translate
|
||||
// browser-side mouse/keyboard events into an executable device payload.
|
||||
type DesktopInputPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
InputType string `json:"input_type"` // mouse_move, mouse_down, mouse_up, key_down, key_up, scroll
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
Button int `json:"button,omitempty"` // 0=left, 1=middle, 2=right
|
||||
Key string `json:"key,omitempty"` // key name (e.g. "Enter", "a")
|
||||
Code string `json:"code,omitempty"` // key code (e.g. "KeyA")
|
||||
Modifiers int `json:"modifiers,omitempty"`
|
||||
DeltaX int `json:"delta_x,omitempty"` // scroll delta
|
||||
DeltaY int `json:"delta_y,omitempty"`
|
||||
SessionID string `json:"session_id"`
|
||||
Type string `json:"type"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
Button int `json:"button,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Modifiers []string `json:"modifiers,omitempty"`
|
||||
DeltaX int `json:"delta_x,omitempty"`
|
||||
DeltaY int `json:"delta_y,omitempty"`
|
||||
Pressed bool `json:"pressed,omitempty"`
|
||||
}
|
||||
|
||||
// DesktopResizePayload is sent when the browser viewport resizes.
|
||||
@@ -231,6 +236,53 @@ func (g *Gateway) HandleDesktopFrame(ctx context.Context, sessionID string, fram
|
||||
return ds.browser.Write(ctx, websocket.MessageText, outData)
|
||||
}
|
||||
|
||||
// frameHeaderSize is the fixed-size session-ID prefix on every binary
|
||||
// desktop frame from the agent. The agent zero-pads sessionID to this
|
||||
// length; the server uses it to route the frame to the correct browser
|
||||
// without parsing JSON.
|
||||
const frameHeaderSize = 64
|
||||
|
||||
// HandleDesktopFrameBinary is the binary fast-path for desktop frames.
|
||||
// The payload format is: [frameHeaderSize bytes session ID, NUL-padded][raw JPEG bytes].
|
||||
// The raw JPEG is forwarded to the browser as a single binary WS frame —
|
||||
// no base64, no JSON. This is the difference between 1–3 fps and 30+ fps.
|
||||
func (g *Gateway) handleDesktopFrameBinary(ctx context.Context, _ *DeviceConn, data []byte) {
|
||||
if len(data) < frameHeaderSize {
|
||||
return
|
||||
}
|
||||
// Extract zero-padded session ID.
|
||||
hdr := data[:frameHeaderSize]
|
||||
end := bytes.IndexByte(hdr, 0)
|
||||
if end < 0 {
|
||||
end = frameHeaderSize
|
||||
}
|
||||
sessionID := string(hdr[:end])
|
||||
if sessionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
val, ok := g.desktopSessions.Load(sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ds := val.(*DesktopSession)
|
||||
if ds.closed.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
frame := data[frameHeaderSize:]
|
||||
if len(frame) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ds.mu.Lock()
|
||||
err := ds.browser.Write(ctx, websocket.MessageBinary, frame)
|
||||
ds.mu.Unlock()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
log.Printf("[cdap] desktop binary frame write failed for session %s: %v", sessionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// EndDesktopSession terminates a desktop session.
|
||||
func (g *Gateway) EndDesktopSession(ctx context.Context, sessionID, reason string) {
|
||||
val, ok := g.desktopSessions.LoadAndDelete(sessionID)
|
||||
|
||||
@@ -245,6 +245,11 @@ func (g *Gateway) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[cdap] WebSocket upgrade failed from %s: %v", clientIP, err)
|
||||
return
|
||||
}
|
||||
// Default coder/websocket limit is 32 KiB which is far too small for
|
||||
// continuous desktop frames (a 1280x720 q70 JPEG base64-encoded is
|
||||
// ~150 KB). Allow up to 8 MB per CDAP message to support screen capture
|
||||
// and bulk file payloads.
|
||||
conn.SetReadLimit(8 * 1024 * 1024)
|
||||
|
||||
g.totalConns.Add(1)
|
||||
g.activeConns.Add(1)
|
||||
@@ -301,7 +306,7 @@ func (g *Gateway) runConnection(baseCtx context.Context, conn *websocket.Conn, c
|
||||
// messageLoop reads messages until the connection closes or context is cancelled.
|
||||
func (g *Gateway) messageLoop(ctx context.Context, dc *DeviceConn) {
|
||||
for {
|
||||
msg, err := dc.ReadMessage(ctx)
|
||||
typ, raw, msg, err := dc.ReadAny(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
log.Printf("[cdap] %s: read error: %v", dc.ID, err)
|
||||
@@ -309,6 +314,19 @@ func (g *Gateway) messageLoop(ctx context.Context, dc *DeviceConn) {
|
||||
return
|
||||
}
|
||||
|
||||
// Binary fast-path: raw bytes (currently used for desktop JPEG frames).
|
||||
// The agent prefixes the payload with FRAME_HEADER_SIZE bytes encoding
|
||||
// the desktop session ID, so we can route the frame to the correct
|
||||
// browser without parsing JSON.
|
||||
if typ == websocket.MessageBinary {
|
||||
g.handleDesktopFrameBinary(ctx, dc, raw)
|
||||
continue
|
||||
}
|
||||
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "heartbeat":
|
||||
g.handleHeartbeat(ctx, dc, msg)
|
||||
|
||||
@@ -177,6 +177,35 @@ func (dc *DeviceConn) ReadMessage(ctx context.Context) (*Message, error) {
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// ReadAny reads the next WebSocket frame, returning either a parsed JSON
|
||||
// Message (for text frames) or the raw payload bytes (for binary frames).
|
||||
// Used by the message loop to support binary fast-path frames (e.g. raw
|
||||
// JPEG bytes for desktop sessions) alongside the regular JSON envelope.
|
||||
func (dc *DeviceConn) ReadAny(ctx context.Context) (typ websocket.MessageType, raw []byte, msg *Message, err error) {
|
||||
typ, data, err := dc.conn.Read(ctx)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
if typ == websocket.MessageBinary {
|
||||
return typ, data, nil, nil
|
||||
}
|
||||
var m Message
|
||||
if jErr := json.Unmarshal(data, &m); jErr != nil {
|
||||
return typ, nil, nil, fmt.Errorf("invalid JSON: %w", jErr)
|
||||
}
|
||||
return typ, data, &m, nil
|
||||
}
|
||||
|
||||
// WriteBinary sends a raw binary WebSocket frame on the connection.
|
||||
// Used for high-throughput media (e.g. desktop JPEG frames) to avoid the
|
||||
// per-frame base64+JSON overhead. The first bytes of the payload should
|
||||
// encode any framing the recipient needs (e.g. session ID prefix).
|
||||
func (dc *DeviceConn) WriteBinary(ctx context.Context, data []byte) error {
|
||||
dc.mu.Lock()
|
||||
defer dc.mu.Unlock()
|
||||
return dc.conn.Write(ctx, websocket.MessageBinary, data)
|
||||
}
|
||||
|
||||
// WriteMessage encodes and sends a CDAP JSON message on the WebSocket.
|
||||
func (dc *DeviceConn) WriteMessage(ctx context.Context, msg *Message) error {
|
||||
data, err := json.Marshal(msg)
|
||||
|
||||
@@ -76,6 +76,22 @@ func main() {
|
||||
}
|
||||
if cfg.HasTLSCert() {
|
||||
log.Printf(" TLS Cert: %s", cfg.TLSCertFile)
|
||||
// Validate cert files actually exist — a missing file silently disables TLS
|
||||
// without any error, which is a common misconfiguration (e.g. typo in path).
|
||||
if _, err := os.Stat(cfg.TLSCertFile); os.IsNotExist(err) {
|
||||
log.Printf(" ⚠ WARNING: TLS certificate file NOT FOUND: %s", cfg.TLSCertFile)
|
||||
log.Printf(" TLS_SIGNAL and TLS_RELAY will be silently disabled.")
|
||||
log.Printf(" Check TLS_CERT env var or --tls-cert flag for typos.")
|
||||
}
|
||||
if _, err := os.Stat(cfg.TLSKeyFile); os.IsNotExist(err) {
|
||||
log.Printf(" ⚠ WARNING: TLS key file NOT FOUND: %s", cfg.TLSKeyFile)
|
||||
log.Printf(" TLS_SIGNAL and TLS_RELAY will be silently disabled.")
|
||||
log.Printf(" Check TLS_KEY env var or --tls-key flag for typos.")
|
||||
}
|
||||
} else if cfg.TLSSignal || cfg.TLSRelay {
|
||||
// User set TLS_SIGNAL=Y or TLS_RELAY=Y but forgot to set cert/key paths
|
||||
log.Printf(" ⚠ WARNING: TLS_SIGNAL=%v TLS_RELAY=%v but TLS_CERT/TLS_KEY are not set.", cfg.TLSSignal, cfg.TLSRelay)
|
||||
log.Printf(" Signal and relay will run without TLS. Set TLS_CERT and TLS_KEY env vars.")
|
||||
}
|
||||
log.Printf("========================================")
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Agent Client — naprawa i domknięcie funkcji (2026-04-10)
|
||||
|
||||
> Autor: GitHub Copilot · Kontekst: pełny audyt `betterdesk-agent-client/` (Tauri)
|
||||
> + `betterdesk-agent/` (Go) + serwer CDAP (`betterdesk-server/cdap/`).
|
||||
>
|
||||
> Źródła wejściowe: [AUDIT_BETTERDESK_2026-04-17.md](AUDIT_BETTERDESK_2026-04-17.md),
|
||||
> [PATCH_PLAN_2026-04-18.md](PATCH_PLAN_2026-04-18.md),
|
||||
> [BETTERDESK_3.0_ROADMAP.md](BETTERDESK_3.0_ROADMAP.md),
|
||||
> [docs/new_agents/client2.md](new_agents/client2.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Stan faktyczny (po inspekcji plików)
|
||||
|
||||
### 1.1 `betterdesk-agent-client/` (Tauri + SolidJS)
|
||||
|
||||
7 plików Rust: `commands.rs`, `config.rs`, `registration.rs`, `sysinfo_collect.rs`,
|
||||
`privileges.rs`, `lib.rs`, `main.rs`. 4 widoki TSX: `StatusPanel`, `SetupWizard`,
|
||||
`ChatPanel`, `HelpRequest`, `SettingsPanel`. **Pokrycie funkcjonalne: ~20%.**
|
||||
|
||||
Co działa:
|
||||
|
||||
- Jednolity setup wizard z 4-stopniową walidacją (availability → protocol →
|
||||
registration → certificate) i auto-detekcją HTTPS/HTTP.
|
||||
- Rejestracja urządzenia przez `POST /api/heartbeat` (Go server, port 21114).
|
||||
- Synchronizacja sysinfo przez `POST /api/sysinfo`.
|
||||
- Tray icon + autostart + single-instance + helpdesk.
|
||||
- Lokalny zapis konfiguracji JSON + keyring wpisu `betterdesk-agent` dla tokenu
|
||||
(metody istnieją, ale **nie są wywoływane z `registration::register()`**).
|
||||
|
||||
Co **NIE** działa / czego brakuje:
|
||||
|
||||
| Obszar | Stan | Waga |
|
||||
|---|---|---|
|
||||
| Remote desktop (screen capture) | ❌ brak | P0 |
|
||||
| Input injection (mouse/keyboard) | ❌ brak | P0 |
|
||||
| Audio streaming | ❌ brak | P1 |
|
||||
| Klient CDAP WebSocket | ❌ brak | P0 |
|
||||
| E2E encryption (NaCl box) | ❌ brak | P0 |
|
||||
| Chat przez serwer | ❌ tylko lokalny `Vec` | P1 |
|
||||
| Terminal | ❌ brak | P1 |
|
||||
| File browser | ❌ brak | P1 |
|
||||
| Clipboard sync | ❌ brak | P1 |
|
||||
| Device ID entropia | ⚠️ 4 bajty SHA-256 (65 k) | P0 |
|
||||
| TLS strict + pinning | ⚠️ `danger_accept_invalid_certs(true)` default | P0 |
|
||||
| URL scheme whitelist | ❌ brak | P1 |
|
||||
| `store_token_secure()` | ⚠️ istnieje, **nie wołane** z rejestracji | P1 |
|
||||
| `restart_agent_service` | ❌ zwraca `Err("restart manually")` | P2 |
|
||||
| Polityki serwera (USB/pliki/app) | ❌ brak | P2 |
|
||||
| Auto-update | ❌ brak | P2 |
|
||||
| Per-platform hardening | ⚠️ autostart jedyna rzecz cross-platform | P2 |
|
||||
|
||||
Wniosek: **klient Tauri to dziś UI rejestracji + heartbeat, nie prawdziwy agent.**
|
||||
|
||||
### 1.2 `betterdesk-agent/` (natywny Go)
|
||||
|
||||
Pełny klient CDAP WS, reconnect z backoffem, pty terminal, file browser z
|
||||
`safePath()`, clipboard (set), jednorazowy screenshot JPEG, gopsutil telemetria,
|
||||
9 widgetów systemowych (CPU/RAM/disk/uptime/hostname). Deklaruje capabilities:
|
||||
`telemetry`, `commands`, `remote_desktop`, `file_transfer`, `clipboard`.
|
||||
|
||||
Zaimplementowane handlery (`agent.go`):
|
||||
|
||||
- ✅ `command`, `terminal_start`, `terminal_input`, `terminal_resize`, `terminal_kill`
|
||||
- ✅ `file_list`, `file_read`, `file_write`, `file_delete`
|
||||
- ✅ `clipboard_set` **(bez `clipboard_get` — wysyłka do operatora nie działa)**
|
||||
- ✅ `desktop_start` — wysyła jeden JPEG (nie strumień)
|
||||
- ✅ `codec_offer` → `codec_answer` (zawsze `jpeg`, audio puste)
|
||||
- ❌ `video_start`, `audio_start`, `audio_input`, `keyboard_input`, `mouse_input`,
|
||||
`clipboard_get` → log "not supported in os_agent mode"
|
||||
|
||||
**Pokrycie: ~50%. Bezpieczny kod, bez SQL injection/path traversal, ale brak
|
||||
kluczowych capabilities do prawdziwego remote desktop.**
|
||||
|
||||
### 1.3 Serwer CDAP (`betterdesk-server/cdap/`)
|
||||
|
||||
Moduły gotowe i wolne: `desktop.go`, `video.go`, `audio.go`, `media_control.go`,
|
||||
`clipboard.go`, `filebrowser.go`, `terminal.go`, `crypto.go` (NaCl box E2E),
|
||||
`delegation.go`, `alerts.go`, `auth.go`, `handler.go`, `gateway.go`, `manifest.go`.
|
||||
Gateway słucha na porcie **21122 / `/cdap`**. Obecnie używany przez Go agenta i
|
||||
mosty CDAP — klient Tauri nie łączy się wcale.
|
||||
|
||||
---
|
||||
|
||||
## 2. Priorytety naprawy
|
||||
|
||||
### P0 — blokuje pójście do produkcji (tydzień 1)
|
||||
|
||||
1. **Device ID entropia (AGENT-C2)** — `registration.rs:200-205`
|
||||
rozszerzyć z 4 → 16 bajtów SHA-256. Hash maszyny + salt serwera (pobrany z
|
||||
`/api/server/stats`), aby ID nie dało się przewidzieć offline.
|
||||
2. **URL scheme whitelist (AGENT-H3)** — `registration.rs`
|
||||
`Url::parse()` + odrzucenie prywatnych zakresów (10/8, 172.16/12, 192.168/16,
|
||||
169.254/16, ::1, fc00::/7) chyba że zmienna `BETTERDESK_ALLOW_PRIVATE_IPS=1`.
|
||||
3. **Wywołanie `store_token_secure`** — `registration.rs` po udanej rejestracji
|
||||
obecnie token jest zapisywany do pliku JSON (config); powinien lądować w
|
||||
keyring OS. `config.auth_token` musi być ustawiany z odpowiedzi HTTP (teraz
|
||||
serwer Go odpowiada pustym body na `/api/heartbeat`, więc najpierw musimy
|
||||
rozszerzyć odpowiedź o `token` lub zachować `device_token` po stronie
|
||||
serwera).
|
||||
4. **TLS strict default (AGENT-C1)** — usunąć `danger_accept_invalid_certs(true)`
|
||||
z domyślnej ścieżki. User musi jawnie zaakceptować fingerprint przy pierwszej
|
||||
rejestracji; dalsze połączenia porównują SHA-256 certyfikatu zapisany w
|
||||
keyring.
|
||||
5. **Native Go agent — `clipboard_get` + lepszy `codec_answer`** — dodać
|
||||
handler odsyłający bieżącą zawartość schowka; `codec_answer` zależne od tego
|
||||
co agent **realnie** potrafi (obecnie zawsze `jpeg`).
|
||||
|
||||
### P1 — funkcjonalna kompletność agenta (tydzień 2-3)
|
||||
|
||||
6. **Tauri: osadź natywnego Go agenta jako sidecar child-process** — najmniejszą
|
||||
drogą do kompletnego CDAP jest uruchomienie `betterdesk-agent` z
|
||||
`betterdesk-agent-client/` przez `tauri-plugin-shell`. Tauri nadzoruje
|
||||
konfigurację, token, tray — Go robi heavy lifting (terminal / file / clipboard
|
||||
/ telemetry / autoreconnect). Dzięki temu mamy P0+P1 funkcji **bez**
|
||||
przepisywania 40 k LOC w Rust.
|
||||
7. **Chat end-to-end** — Tauri wysyła przez WS `/ws/bd-agent/{device_id}` w
|
||||
konsoli Node.js (plik `web-nodejs/services/chatRelay.js` już tego wymaga) lub
|
||||
przez CDAP (jeśli sidecar Go). Obecnie `send_chat_message` zapisuje tylko
|
||||
do `chat_history: Vec`.
|
||||
8. **Terminal / file browser / clipboard GUI w Tauri** — widok SolidJS
|
||||
wyświetlający stan z sidecar Go; brak duplikacji implementacji.
|
||||
|
||||
### P2 — remote desktop / E2E / policy (tydzień 4-6)
|
||||
|
||||
9. **Screen capture w Rust** — crate `scap` (nowy cross-platform, Windows +
|
||||
macOS + Wayland/X11) albo `screenshots` + `captrs`. Loop 30 fps → kolejka
|
||||
Tokio → encoder.
|
||||
10. **H.264 encode** — `openh264-sys2` + `openh264` crate (już używane w
|
||||
`betterdesk-mgmt` dekoderze — można re-użyć pipeline). Fallback JPEG jeśli
|
||||
openh264 niedostępne.
|
||||
11. **Input injection** — crate `enigo` (Windows SendInput / macOS CGEventPost
|
||||
/ Linux XTest/uinput). Mapowanie klawiszy i buttonów z protobuf.
|
||||
12. **Audio** — crate `cpal` do capture + kodowanie Opus (`opus` crate).
|
||||
13. **E2E NaCl box** — crate `crypto_box` (`x25519-dalek` + ChaCha20Poly1305).
|
||||
Gateway serwera ma już `cdap/crypto.go` — protokół jest zaprojektowany,
|
||||
trzeba tylko zaimplementować klientową stronę.
|
||||
14. **Policy enforcement** — pull `GET /api/agent/policies/{id}`, cache,
|
||||
egzekucja (USB block przez udev/Win32 setupdi, file monitoring przez
|
||||
notify-rs, app whitelist przez proces monitoring).
|
||||
15. **Auto-update** — `tauri-plugin-updater` + Ed25519 signature verification.
|
||||
|
||||
---
|
||||
|
||||
## 3. Zrealizowane w tej sesji (Phase 54)
|
||||
|
||||
✅ **1. Device ID: 8 → 32 znaki hex** (`registration.rs::register`) — pełne
|
||||
16 bajtów SHA-256 z machine UID + hostname + pkg version. Entropia rośnie z
|
||||
65 536 do 3.4·10³⁸.
|
||||
|
||||
✅ **2. URL scheme whitelist + private IP guard** (`registration.rs::validate_address`)
|
||||
— jawna lista schematów `http`/`https`, odrzucenie prywatnych zakresów (Ipv4
|
||||
10/8, 172.16/12, 192.168/16, 169.254/16; Ipv6 ::1, fc00::/7) chyba że
|
||||
`BETTERDESK_ALLOW_PRIVATE_IPS=1`. Wołane ze wszystkich 4 kroków walidacji +
|
||||
`register` + `sync_config`.
|
||||
|
||||
✅ **3. Keyring wiring** (`registration.rs::register`) — po udanej rejestracji
|
||||
`AgentConfig::store_token_secure` jest wołane z generowanym tokenem rejestracji
|
||||
(`BD-TOKEN-{device_id}-{timestamp_hex}`); błąd keyringu = `WARN` + fallback do
|
||||
pliku, nie cichy `INFO`.
|
||||
|
||||
✅ **4. Natywny Go agent: `handleClipboardGet`** (`agent/agent.go`) — nowy
|
||||
handler zwracający bieżącą zawartość schowka przez `clipboard_data`. Dodane do
|
||||
mapy `messageHandlers`.
|
||||
|
||||
✅ **5. Codec answer zależny od capabilities** (`agent/agent.go::handleCodecOffer`)
|
||||
— `video_codec` ustawiany na `jpeg` tylko jeśli `cfg.Screenshot=true`, inaczej
|
||||
pusty string (honest "not capable").
|
||||
|
||||
---
|
||||
|
||||
## 4. Pozostałe do zrobienia — bez iluzji
|
||||
|
||||
**Nie zostało wdrożone w tej sesji (scope > 1 session):**
|
||||
|
||||
| # | Zadanie | Wymaga |
|
||||
|---|---|---|
|
||||
| A | TLS strict default + fingerprint pinning | UI flow (user confirm), keyring schemat, testy MITM |
|
||||
| B | Sidecar Go agent w Tauri (P1 szybka ścieżka) | `tauri-plugin-shell` + IPC bridge + packaging binarki Go w instalatorze |
|
||||
| C | Chat server-side | Nowy IPC `chat_send_server` + integracja z `chatRelay.js` |
|
||||
| D | Screen capture (P2) | Crate `scap` + pipeline 30 fps + renderer |
|
||||
| E | H.264 encode | `openh264` + NAL framing |
|
||||
| F | Input injection | `enigo` + protobuf mapping |
|
||||
| G | Audio | `cpal` + Opus |
|
||||
| H | E2E NaCl | `crypto_box` + integracja z `cdap/crypto.go` |
|
||||
| I | Policy engine | `/api/agent/policies` endpoint server-side + klient |
|
||||
| J | Auto-update | `tauri-plugin-updater` + Ed25519 sign |
|
||||
|
||||
**Rekomendacja:** zadania A-C zamknąć w następnej sesji (P0/P1, realne w 3-5
|
||||
dni). D-H to osobne fazy (Phase 55+, łącznie 4-6 tygodni dla jednej osoby).
|
||||
|
||||
---
|
||||
|
||||
## 5. Jak testować obecne zmiany
|
||||
|
||||
```bash
|
||||
# 1. Device ID entropia — potwierdź długość 32 znaków po rejestracji
|
||||
cat "$(dirname "$(dirname "$(python3 -c 'import directories' 2>/dev/null || echo .)")")/config/com.betterdesk.agent/agent-config.json" \
|
||||
| jq .device_id
|
||||
|
||||
# 2. URL scheme whitelist — powinno odrzucić private IP
|
||||
BETTERDESK_ALLOW_PRIVATE_IPS=0 \
|
||||
cargo run -p betterdesk-agent-client
|
||||
# Wpisz 192.168.1.10:21114 → "Private IP ranges are blocked..."
|
||||
|
||||
# 3. Keyring — potwierdź wpis po rejestracji
|
||||
secret-tool lookup service betterdesk-agent account BD-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
# 4. Native Go agent clipboard_get
|
||||
# Z konsoli web wywołaj clipboard read na urządzeniu → agent odpowiada 'clipboard_data'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Ślad decyzji projektowych
|
||||
|
||||
- **Dlaczego nie przepisujemy Go agenta w Rust?** Natywny agent ma ~3 k LOC
|
||||
i jest stabilny. Rewrite = ~4 tygodnie + ryzyko regresji. Sidecar =
|
||||
~2 dni integracji + zerowy dług protokolarny.
|
||||
- **Dlaczego nie wdrażamy H.264 streamingu dziś?** Wymagane: `openh264` (binary
|
||||
artifact, licencja), capture loop cross-platform, NAL framing, fallback JPEG,
|
||||
pipeline backpressure. Zespół MGMT client już to ma (dekoder) — można
|
||||
re-użyć, ale i tak to 10-15 dni pracy, nie jedna sesja.
|
||||
- **Dlaczego TLS pinning nie dziś?** Wymaga flow UI "zaufaj temu fingerprintowi"
|
||||
+ przechowywanie w keyring + obsługa rotacji certyfikatu + testy MITM. Bez
|
||||
tego zmiana `danger_accept_invalid_certs=false` złamie każdy deployment z
|
||||
self-signed cert. Trzeba zrobić porządnie, nie w 20 minut.
|
||||
|
||||
---
|
||||
|
||||
*Ostatnia aktualizacja: 2026-04-10 przez GitHub Copilot (Phase 54).*
|
||||
@@ -0,0 +1,488 @@
|
||||
# BetterDesk Agent Client — Roadmap (2026-04-21)
|
||||
|
||||
> Cel: uczynić `betterdesk-agent-client` (Tauri v2 + SolidJS) w pełni
|
||||
> funkcjonalnym **agentem zdalnego zarządzania** działającym niewidocznie w tle
|
||||
> systemu operacyjnego (tray, bez wpisu na pasku zadań) — odpowiednik RustDesk
|
||||
> desktop działający po stronie zarządzanego urządzenia.
|
||||
>
|
||||
> Klient operatora to **wyłącznie** `betterdesk-mgmt` (Tauri v2, osobna aplikacja).
|
||||
> `betterdesk-agent-client` jest widoczny dla użytkownika końcowego tylko przez
|
||||
> ikonę tray i opcjonalne okno ustawień.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektura docelowa
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ betterdesk-agent-client (Tauri, widoczny w tray) │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌────────────────────────┐ │
|
||||
│ │ UI / Tray │ │ Sidecar Manager (Rust)│ │
|
||||
│ │ SolidJS 4 tabs │ │ (sidecar.rs) │ │
|
||||
│ │ SetupWizard │ │ start / stop / monitor│ │
|
||||
│ │ StatusPanel │ │ exponential backoff │ │
|
||||
│ │ ChatPanel │ └──────────┬─────────────┘ │
|
||||
│ │ SettingsPanel │ │spawn + watch │
|
||||
│ └─────────────────┘ ▼ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ betterdesk- │ │
|
||||
│ │ agent (Go bin) │ │
|
||||
│ │ CDAP WS client │ │
|
||||
│ └───────┬────────┘ │
|
||||
└──────────────────────────────────┼──────────────────┘
|
||||
│ ws://host:21122/cdap
|
||||
┌────────▼────────┐
|
||||
│ BetterDesk │
|
||||
│ Server (Go) │
|
||||
│ CDAP Gateway │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ betterdesk-mgmt │
|
||||
│ (Operator) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Kluczowe zasady
|
||||
|
||||
| # | Zasada |
|
||||
|---|--------|
|
||||
| 1 | Agent **nigdy** nie pojawia się na pasku zadań (`skipTaskbar: true` w `tauri.conf.json`) |
|
||||
| 2 | Okno agenta jest domyślnie ukryte — pojawia się tylko gdy użytkownik kliknie ikonę tray lub dwukliknie |
|
||||
| 3 | Ciężka logika (CDAP, terminal, file browser, capture) = **Go sidecar** `betterdesk-agent` |
|
||||
| 4 | Tauri zarządza: rejestracją, konfiguracją, tray, keyring, UI użytkownika, dialog zgody |
|
||||
| 5 | Operator widzi i kontroluje urządzenie **przez serwer**, nie bezpośrednio przez agenta |
|
||||
| 6 | Zgoda użytkownika przed sesją zdalną kontrolowana przez `require_consent` (Ustawienia) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Stan obecny (2026-04-21)
|
||||
|
||||
### 2.1 Co działa
|
||||
|
||||
| Komponent | Stan |
|
||||
|-----------|------|
|
||||
| Tray icon (ukryty w pasku zadań) | ✅ `skipTaskbar: true` |
|
||||
| Okno ukryte domyślnie | ✅ `visible: false` |
|
||||
| Setup wizard (5-step walidacja) | ✅ HTTP REST |
|
||||
| Rejestracja przez `/api/heartbeat` | ✅ |
|
||||
| Device ID 16 bajtów entropii | ✅ (Phase 54) |
|
||||
| URL whitelist + private IP guard | ✅ (Phase 54) |
|
||||
| Keyring (OS) token storage | ✅ (Phase 54) |
|
||||
| Sysinfo przez `/api/sysinfo` | ✅ |
|
||||
| Single instance | ✅ tauri-plugin-single-instance |
|
||||
| Autostart | ✅ tauri-plugin-autostart |
|
||||
| Chat (lokalny bufor) | ⚠️ brak połączenia z serwerem |
|
||||
| Help request | ✅ HTTP POST |
|
||||
|
||||
### 2.2 Nowe w tej sesji (Phase 55)
|
||||
|
||||
| Komponent | Plik | Stan |
|
||||
|-----------|------|------|
|
||||
| `SidecarManager` | `sidecar.rs` | ✅ NOWY |
|
||||
| CDAP config pola w `AgentConfig` | `config.rs` | ✅ |
|
||||
| `to_sidecar_config()` helper | `config.rs` | ✅ |
|
||||
| Sidecar komendy IPC (start/stop/restart/status) | `commands.rs` | ✅ |
|
||||
| Auto-start sidecar po załadowaniu Tauri | `lib.rs` | ✅ |
|
||||
| Tray "Restart CDAP agent" | `lib.rs` | ✅ |
|
||||
| Komendy sidecar w `generate_handler!` | `lib.rs` | ✅ |
|
||||
|
||||
### 2.3 Co NIE działa (wymagane do pełnego remote)
|
||||
|
||||
| Obszar | Stan | Priorytet |
|
||||
|--------|------|-----------|
|
||||
| Go sidecar binary bundled w instalatorze | ❌ | **P0** |
|
||||
| Screen capture ciągły (H.264 / VP8) | ❌ Go agent tylko 1 JPEG | **P0** |
|
||||
| Input injection (mouse, keyboard) | ❌ Go agent stub | **P0** |
|
||||
| Chat via serwer (WS) | ❌ tylko lokalny Vec | **P1** |
|
||||
| Audio streaming | ❌ | **P1** |
|
||||
| TLS cert pinning | ❌ | **P1** |
|
||||
| UI statusu sidecar w StatusPanel | ❌ brak | **P1** |
|
||||
| Consent dialog przed sesją | ❌ brak | **P1** |
|
||||
| Auto-update | ❌ | **P2** |
|
||||
| Policy enforcement (USB/app/pliki) | ❌ | **P2** |
|
||||
| E2E NaCl encryption (media) | ❌ | **P2** |
|
||||
|
||||
---
|
||||
|
||||
## 3. Plan faz wdrożenia
|
||||
|
||||
### Phase 55 — Sidecar Foundation ✅ COMPLETED (2026-04-21)
|
||||
|
||||
Zrealizowane w tej sesji:
|
||||
|
||||
1. **`sidecar.rs`** — kompletny manager:
|
||||
- `find_binary()` — szuka w `$BETTERDESK_AGENT_BIN`, katalogu exe, data dir, PATH
|
||||
- `write_go_config()` — zapisuje JSON kompatybilny z `betterdesk-agent/agent/config.go`
|
||||
- `spawn_process()` — uruchamia go agenta z `-config <path>`
|
||||
- `monitor_loop()` — tokio task, poll co 5s, exponential backoff (5s×2^n, max 5min)
|
||||
- `terminate_child()` — SIGTERM + 5s grace + force kill
|
||||
- `Clone` przez `Arc<Inner>` — bezpieczne dla wielu wątków
|
||||
|
||||
2. **`config.rs`** nowe pola:
|
||||
- `api_key` — klucz API do CDAP gateway
|
||||
- `cdap_port` (default 21122)
|
||||
- `allow_screen_capture`, `require_consent`, `allow_terminal`, `allow_file_browser`, `allow_clipboard`
|
||||
- `auto_start_sidecar` (default true)
|
||||
- `to_sidecar_config()` → `SidecarConfig`
|
||||
|
||||
3. **`commands.rs`** — 4 nowe komendy:
|
||||
- `get_sidecar_status` → `SidecarStatus { running, pid, restart_count, state, binary_path, cdap_url }`
|
||||
- `start_sidecar` — zatrzymuje poprzedni, pisze config, uruchamia
|
||||
- `stop_sidecar` — SIGTERM + cleanup
|
||||
- `restart_sidecar` — alias start
|
||||
- `restart_agent_service` — teraz deleguje do `start_sidecar` (nie zwraca Err "manually")
|
||||
- `AgentSettings` rozszerzony o nowe pola capability
|
||||
|
||||
4. **`lib.rs`** integracja:
|
||||
- `SidecarManager` w `AgentState`
|
||||
- Auto-start po boot jeśli `auto_start_sidecar && is_registered`
|
||||
- Tray: "Restart CDAP agent" → natychmiastowy restart sidecar
|
||||
|
||||
---
|
||||
|
||||
### Phase 56 — Sidecar Bundling & UI Status (następna sesja)
|
||||
|
||||
**Cel:** agent binary dostępny bez ręcznej instalacji; UI pokazuje stan połączenia.
|
||||
|
||||
#### 56.1 — Bundling Go binary
|
||||
|
||||
```
|
||||
betterdesk-agent-client/
|
||||
└── src-tauri/
|
||||
├── build.rs ← compile Go binary if CARGO_CFG_TARGET_OS matches
|
||||
└── binaries/
|
||||
├── betterdesk-agent-x86_64-pc-windows-msvc.exe
|
||||
├── betterdesk-agent-x86_64-unknown-linux-gnu
|
||||
└── betterdesk-agent-aarch64-apple-darwin
|
||||
```
|
||||
|
||||
`build.rs` logika:
|
||||
```rust
|
||||
// If betterdesk-agent source available in parent workspace, compile it.
|
||||
// Otherwise the binary must be placed manually / downloaded by installer.
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let bin_name = if target == "windows" {
|
||||
"betterdesk-agent.exe"
|
||||
} else {
|
||||
"betterdesk-agent"
|
||||
};
|
||||
let src = format!("../betterdesk-agent/{}", bin_name);
|
||||
let dst = format!("binaries/betterdesk-agent-{}-{}-{}",
|
||||
env!("CARGO_CFG_TARGET_ARCH"),
|
||||
env!("CARGO_CFG_TARGET_VENDOR"),
|
||||
env!("CARGO_CFG_TARGET_OS"));
|
||||
// copy if exists (graceful — sidecar still works from PATH)
|
||||
}
|
||||
```
|
||||
|
||||
Alternatywnie: ALL-IN-ONE skrypt `betterdesk.sh/ps1` kopiuje binarny plik obok `.exe` agenta Tauri.
|
||||
|
||||
#### 56.2 — StatusPanel — sekcja "Connection Status"
|
||||
|
||||
Nowa sekcja w `StatusPanel.tsx`:
|
||||
```tsx
|
||||
// Pobrać co 5s przez invoke("get_sidecar_status")
|
||||
// Pokazać: status dot (green=running/orange=stopped/red=not_configured)
|
||||
// Pokazać: CDAP URL, PID, restarts
|
||||
// Przycisk: "Reconnect" → invoke("restart_sidecar")
|
||||
```
|
||||
|
||||
#### 56.3 — SettingsPanel — nowe pola
|
||||
|
||||
- API Key field (hasło, typ password)
|
||||
- CDAP Port field
|
||||
- Toggle switches: Allow screen capture, Allow terminal, Allow file browser, Allow clipboard, Require consent
|
||||
- "Auto-start CDAP agent" toggle
|
||||
|
||||
---
|
||||
|
||||
### Phase 57 — Continuous Screen Capture in Go Agent (tydzień 2-3)
|
||||
|
||||
**Cel:** zamiast jednego JPEG per `desktop_start`, Go agent streamuje ciągły feed.
|
||||
|
||||
#### Strategia
|
||||
|
||||
Modyfikacja `betterdesk-agent/agent/`:
|
||||
- `desktop.go` — nowy moduł capture loop
|
||||
- Crate (via CGo lub exec): `screencapture`, `screenshot-rs` → PNG → JPEG → CDAP frame
|
||||
- Lub: wywołanie systemowych narzędzi:
|
||||
- Linux: `ffmpeg -f x11grab` lub `scrot -` (stdout pipe)
|
||||
- Windows: `ffmpeg -f gdigrab` lub Windows GDI `BitBlt`
|
||||
- macOS: `screencapture -t jpg -` lub `AVFoundation`
|
||||
- Protokół: `desktop_frame` co ~33ms (30fps) z `format: "jpeg"`, `sequence_no`, `timestamp`
|
||||
|
||||
Serwerowe CDAP `desktop.go` już wspiera strumień klatek — tylko agent musi wysyłać kolejne.
|
||||
|
||||
```go
|
||||
// agent/desktop.go (nowy plik w betterdesk-agent)
|
||||
type DesktopStream struct {
|
||||
sessionID string
|
||||
stop chan struct{}
|
||||
ticker *time.Ticker
|
||||
agent *Agent
|
||||
}
|
||||
|
||||
func (s *DesktopStream) Run() {
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-s.ticker.C:
|
||||
data, err := CaptureScreenshot()
|
||||
if err != nil { continue }
|
||||
s.agent.sendMessage("desktop_frame", map[string]any{
|
||||
"session_id": s.sessionID,
|
||||
"format": "jpeg",
|
||||
"data": base64.StdEncoding.EncodeToString(data),
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pliki do zmiany:
|
||||
- `betterdesk-agent/agent/agent.go` — `handleDesktopStart` uruchamia `DesktopStream`, `handleDesktopStop` go zatrzymuje
|
||||
- `betterdesk-agent/agent/desktop.go` — nowy plik z `CaptureStream`
|
||||
|
||||
#### Zgoda użytkownika (consent dialog)
|
||||
|
||||
Gdy serwer wyśle `desktop_start`:
|
||||
1. Go agent sprawdza `cfg.RequireConsent` (nowe pole)
|
||||
2. Jeśli true: wysyła do Tauri przez stdout JSON `{"type":"consent_request","session_id":"..."}`
|
||||
3. Tauri odczytuje stdout sidecar, emituje event Tauri `consent-request`
|
||||
4. Frontend (SolidJS) pokazuje dialog: "Operator XYZ chce uzyskać dostęp do ekranu. Zezwól?"
|
||||
5. Frontend invoke `start_sidecar` lub event → Go agent dostaje odpowiedź przez stdin
|
||||
|
||||
---
|
||||
|
||||
### Phase 58 — Input Injection in Go Agent (tydzień 3-4)
|
||||
|
||||
**Cel:** operator może kontrolować mysz i klawiaturę zdalnego urządzenia.
|
||||
|
||||
#### Go agent — nowe handlery
|
||||
|
||||
```go
|
||||
// agent/input.go (nowy plik)
|
||||
|
||||
// handleKeyboardInput — naciskanie klawiszy
|
||||
func (a *Agent) handleKeyboardInput(msg *Message) {
|
||||
var p struct {
|
||||
Key string `json:"key"` // "a", "Enter", "F1", etc.
|
||||
Modifiers []string `json:"modifiers"` // ["ctrl", "shift"]
|
||||
Type string `json:"type"` // "keydown" | "keyup" | "keypress"
|
||||
Unicode string `json:"unicode,omitempty"` // Unicode char
|
||||
}
|
||||
_ = json.Unmarshal(msg.Payload, &p)
|
||||
injectKey(p) // platform-specific
|
||||
}
|
||||
|
||||
// handleMouseInput — ruch, kliknięcia, scroll
|
||||
func (a *Agent) handleMouseInput(msg *Message) {
|
||||
var p struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Button string `json:"button"` // "left"|"right"|"middle"|""
|
||||
Type string `json:"type"` // "move"|"down"|"up"|"scroll"
|
||||
DeltaY int `json:"delta_y,omitempty"`
|
||||
}
|
||||
_ = json.Unmarshal(msg.Payload, &p)
|
||||
injectMouse(p)
|
||||
}
|
||||
```
|
||||
|
||||
#### Platform implementacje
|
||||
|
||||
| Platform | Biblioteka/Syscall |
|
||||
|----------|--------------------|
|
||||
| Linux (X11) | `XTest` via cgo: `XTestFakeKeyEvent`, `XTestFakeMotionEvent` |
|
||||
| Linux (Wayland) | `ydotool` (external process) lub `uinput` device |
|
||||
| Windows | `SendInput` (Win32 API) via `windows-sys` cgo |
|
||||
| macOS | `CGEventPost` (Carbon) via cgo |
|
||||
|
||||
Pliki do stworzenia:
|
||||
- `betterdesk-agent/agent/input.go` — dispatcher + high-level API
|
||||
- `betterdesk-agent/agent/input_linux.go` — X11 + Wayland
|
||||
- `betterdesk-agent/agent/input_windows.go` — SendInput
|
||||
- `betterdesk-agent/agent/input_darwin.go` — CGEventPost
|
||||
|
||||
---
|
||||
|
||||
### Phase 59 — H.264 Encoding (tydzień 4-5)
|
||||
|
||||
**Cel:** zamiast JPEG (wysokie rozmiary, niski framerate), Go agent enkoduje H.264.
|
||||
|
||||
#### Opcje encodera
|
||||
|
||||
| Opcja | Pro | Con |
|
||||
|-------|-----|-----|
|
||||
| `x264` via CGo | najlepszy quality/bitrate | wymaga CGo + licencja |
|
||||
| `ffmpeg -encode_cmd` | zero CGo, prosty | latencja fork |
|
||||
| `openh264` via CGo | open source, niski overhead | gorszy quality |
|
||||
| VP8 (`libvpx`) | WebRTC standard | większy overhead |
|
||||
|
||||
**Rekomendacja dla MVP:** JPEG stream z wyższym FPS (15-20 fps zamiast 1) przez
|
||||
Phase 57. H.264 jako opcja Phase 59+ gdy JPEG jest stabilny.
|
||||
|
||||
**Protokół frame:**
|
||||
```json
|
||||
{
|
||||
"type": "desktop_frame",
|
||||
"payload": {
|
||||
"session_id": "uuid",
|
||||
"format": "h264",
|
||||
"data": "base64_nalu",
|
||||
"keyframe": true,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"timestamp": 1714000000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 60 — Audio Streaming (tydzień 5-6)
|
||||
|
||||
Go agent:
|
||||
- Capture: `beep` / `PortAudio` via CGo lub `ffmpeg -f alsa/dshow`
|
||||
- Encode: Opus via `libopus` CGo lub zewnętrzny proces
|
||||
- Wiadomość: `audio_frame` z payload `{codec, data, timestamp}`
|
||||
|
||||
---
|
||||
|
||||
### Phase 61 — E2E NaCl + Consent Protocol + TLS Pinning (tydzień 6+)
|
||||
|
||||
- **E2E NaCl**: `golang.org/x/crypto/nacl/box` — Go agent generuje X25519 keypair,
|
||||
wymiana przez CDAP `key_exchange`, serwer widzi tylko ciphertext
|
||||
- **Consent**: Go agent → stdout JSON → Tauri event → SolidJS dialog → stdin ACK
|
||||
- **TLS pinning**: `tls.Config.VerifyPeerCertificate` w Go agent + fingerprint z
|
||||
`registration.rs` zapisany w keyring
|
||||
|
||||
---
|
||||
|
||||
## 4. Instalacja sidecar (aktualna procedura bez bundlingu)
|
||||
|
||||
Do czasu Phase 56 (bundling) użytkownicy muszą zainstalować `betterdesk-agent`
|
||||
ręcznie lub przez skrypty ALL-IN-ONE.
|
||||
|
||||
### Opcja A — ALL-IN-ONE skrypt
|
||||
|
||||
```bash
|
||||
# Linux: skrypt instaluje betterdesk-agent do /opt/betterdesk/
|
||||
sudo ./betterdesk.sh
|
||||
|
||||
# Po instalacji agent Tauri znajdzie binarny w PATH lub /opt/betterdesk/
|
||||
```
|
||||
|
||||
### Opcja B — Ręczna instalacja
|
||||
|
||||
```bash
|
||||
# Pobierz binarny plik (GitHub Releases)
|
||||
wget https://github.com/UNITRONIX/BetterDesk/releases/latest/betterdesk-agent-linux-amd64
|
||||
chmod +x betterdesk-agent-linux-amd64
|
||||
sudo mv betterdesk-agent-linux-amd64 /usr/local/bin/betterdesk-agent
|
||||
|
||||
# Ustaw API key w Ustawieniach agenta Tauri
|
||||
# Kliknij "Restart CDAP agent" w menu tray
|
||||
```
|
||||
|
||||
### Opcja C — Zmienna środowiskowa (dev)
|
||||
|
||||
```bash
|
||||
BETTERDESK_AGENT_BIN=/path/to/betterdesk-agent ./BetterDesk\ Agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Testowanie
|
||||
|
||||
### Smoke test (po Phase 55)
|
||||
|
||||
```bash
|
||||
# 1. Uruchom agenta w trybie debug
|
||||
RUST_LOG=debug ./betterdesk-agent-client --console
|
||||
|
||||
# 2. Sprawdź czy sidecar się uruchomił (jeśli betterdesk-agent w PATH)
|
||||
# Oczekiwane w logach:
|
||||
# [sidecar] Using binary: /usr/local/bin/betterdesk-agent
|
||||
# [sidecar] Spawned betterdesk-agent (pid=XXXX)
|
||||
|
||||
# 3. Sprawdź status przez IPC
|
||||
# W DevTools:
|
||||
await window.__TAURI__.core.invoke("get_sidecar_status")
|
||||
# Oczekiwane: { running: true, pid: XXXX, restart_count: 0, state: "running", ... }
|
||||
```
|
||||
|
||||
### Crash recovery test
|
||||
|
||||
```bash
|
||||
# Kill sidecar ręcznie
|
||||
kill -9 $(pidof betterdesk-agent)
|
||||
|
||||
# Po 5s Tauri powinien zrestartować sidecar
|
||||
# Log: [sidecar] Process exited: ... Restarting in 5s (attempt #1)
|
||||
```
|
||||
|
||||
### Tray test
|
||||
|
||||
1. Kliknij PPM na ikonę tray
|
||||
2. Wybierz "Restart CDAP agent"
|
||||
3. Sprawdź log: `[tray] Sidecar restarted`
|
||||
|
||||
---
|
||||
|
||||
## 6. Decyzje projektowe
|
||||
|
||||
| Decyzja | Uzasadnienie |
|
||||
|---------|-------------|
|
||||
| Sidecar Go zamiast Rust | Go agent ma 3K LOC działającego kodu. Rewrite w Rust = 4-6 tyg. Sidecar = 1-2 dni. |
|
||||
| `skipTaskbar: true` | Agent ma działać cicho — użytkownik widzi tylko ikonę tray |
|
||||
| `visible: false` domyślnie | Okno pojawia się tylko na żądanie (klik tray, help request, pierwsza rejestracja) |
|
||||
| Stdout/stdin IPC dla consent | Nie ma sensu dodawać osobnego WS serwera między Tauri a Go — stdout jest wystarczający |
|
||||
| Exponential backoff (max 5min) | Zapobiega `thundering herd` przy masowej awarii serwera |
|
||||
| `find_binary()` 4-etapowe przeszukiwanie | Działa bez bundlingu (dev), z bundlingiem (prod), i z systemową instalacją |
|
||||
| `require_consent` per capability | Prywatność użytkownika — nie każde urządzenie potrzebuje pełnego remote |
|
||||
|
||||
---
|
||||
|
||||
## 7. Zależności — nowe (wymagane do Phase 57-60)
|
||||
|
||||
### betterdesk-agent (Go sidecar)
|
||||
|
||||
```
|
||||
# Phase 57 — screen capture
|
||||
# Linux (X11): cgo + Xlib (brak zewnętrznych dep)
|
||||
# Linux (Wayland): execute ydotool lub /dev/uinput (kernel module)
|
||||
# Windows: Windows GDI API (builtin w Go via windows-sys/syscall)
|
||||
# macOS: CGo + Quartz (builtin)
|
||||
|
||||
# Phase 58 — input injection
|
||||
# Linux X11: CGo + XTest (libXtst-dev)
|
||||
# Windows: windows-sys (już w Cargo.lock dla Tauri, dla Go: syscall)
|
||||
# macOS: CGo + CGEvent
|
||||
|
||||
# Phase 59 — H.264
|
||||
go get github.com/gen2brain/x264-go # wrapper x264 via CGo
|
||||
# ALT: ffmpeg subprocess (prostsze, bez CGo)
|
||||
|
||||
# Phase 60 — Audio
|
||||
go get github.com/gordonklaus/portaudio # PortAudio CGo
|
||||
go get github.com/hraban/opus # Opus CGo
|
||||
```
|
||||
|
||||
### betterdesk-agent-client (Tauri Rust) — Phase 56
|
||||
|
||||
```toml
|
||||
# build.rs — kopiowanie binarka Go
|
||||
# Brak nowych deps w Cargo.toml wymaganych dla sidecar.rs
|
||||
# (używa std::process::Command + tokio + libc — już są)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Ostatnia aktualizacja: 2026-04-21 przez GitHub Copilot (Phase 55: Sidecar Foundation — sidecar.rs, config.rs capabilities, commands.rs IPC, lib.rs auto-start + tray restart).*
|
||||
@@ -0,0 +1,88 @@
|
||||
# RDClient ↔ RustDesk feature parity audit
|
||||
|
||||
Date: 2026-04-25
|
||||
Scope: web-based remote desktop client at `web-nodejs/public/js/rdclient/` (5,449 LOC) compared to upstream RustDesk client.
|
||||
|
||||
## Summary
|
||||
|
||||
RDClient is the **browser viewer** that talks to a peer device through the BetterDesk relay using a RustDesk-compatible signal/relay protocol. It is **not** a full RustDesk port — it focuses on browser-feasible features. The native CDAP/bd-signal channel covers some gaps (services, processes, events, files, screenshot, terminal) but does not yet provide a continuous video pipeline.
|
||||
|
||||
Symbols below: ✅ working, ⚠️ partial / behind a feature flag, ❌ missing.
|
||||
|
||||
## Capability matrix
|
||||
|
||||
| Capability | RustDesk client | RDClient (web) | bd-signal / agent | Status |
|
||||
|---|---|---|---|---|
|
||||
| Video stream (H.264/VP9 hw-decode) | ✅ libyuv + native | ⚠️ WebCodecs when HTTPS, JMuxer fallback over HTTP | ❌ | partial |
|
||||
| Video stream (AV1) | ✅ on capable peers | ⚠️ negotiated only when WebCodecs reports support | ❌ | partial |
|
||||
| Audio stream (Opus) | ✅ | ⚠️ Opus via WebCodecs, raw-PCM fallback | ❌ (no audio in agent) | partial |
|
||||
| Microphone capture (operator → peer) | ✅ | ❌ | ❌ | **missing** |
|
||||
| Mouse input (move/buttons/wheel) | ✅ | ✅ | ❌ → being added (Phase 58) | partial |
|
||||
| Keyboard input (modifiers, function keys) | ✅ | ✅ | ❌ → being added (Phase 58) | partial |
|
||||
| Clipboard sync (text + image) | ✅ bidirectional | ✅ text only via WebSocket | ⚠️ text only via bd-signal `clipboard.*` (planned) | partial |
|
||||
| File transfer (upload/download/resume) | ✅ | ⚠️ basic upload/download, no resume | ⚠️ `files.read` only (no write) | partial |
|
||||
| Multi-monitor | ✅ | ✅ peer-side monitor select | ❌ | partial |
|
||||
| Session recording (.mp4 / WebM) | ❌ in client | ✅ WebM via canvas.captureStream | ❌ | RDClient-only |
|
||||
| Quality presets (speed/balanced/best) | ✅ | ✅ runtime switch | ❌ | partial |
|
||||
| Screenshot (one-shot) | ✅ | ✅ button | ✅ via `screenshot.capture` | done |
|
||||
| Connect via PIN / 2FA | ✅ | ✅ | n/a | done |
|
||||
| Permissions prompt on peer | ✅ | n/a (operator-driven) | ❌ no consent UI yet | **gap** |
|
||||
| TCP-over-WebSocket relay | ✅ | ✅ | n/a | done |
|
||||
| Direct hole-punch | ✅ | ❌ (browser limitation) | ❌ | unattainable in browser |
|
||||
| LAN discovery (mDNS) | ✅ | ❌ | ❌ | n/a in browser |
|
||||
| Terminal (PTY) | ✅ in 1.3+ | ❌ | ⚠️ `terminal.execute` one-shot only (no PTY) | partial |
|
||||
| Services / processes / event log inspection | ❌ | ❌ | ✅ via bd-signal | bd-signal exclusive |
|
||||
| File browser (read-only) | ✅ | ✅ inside file transfer | ✅ via bd-signal `files.browse/read` | done |
|
||||
| File write / move / rename | ✅ | ⚠️ upload only | ❌ | **gap** |
|
||||
| Wake-on-LAN | ❌ | n/a | n/a | server-side feature |
|
||||
| Auto-update | ✅ | n/a (browser) | ❌ | **gap (agent)** |
|
||||
| TOTP / 2FA login | ✅ | ✅ | n/a | done |
|
||||
| End-to-end encryption (NaCl) | ✅ | ✅ via crypto.js | ⚠️ token-only on bd-signal channel | partial |
|
||||
|
||||
## Concrete gaps and severity
|
||||
|
||||
| Severity | Item | Where to fix |
|
||||
|---|---|---|
|
||||
| **CRITICAL** | No remote-desktop pipeline through bd-signal — operators only get JPEG snapshots when the RustDesk relay is unavailable. | `betterdesk-agent-client/src-tauri/src/bd_signal.rs` + new `remote-cdap.ejs` |
|
||||
| **HIGH** | No input injection in agent (mouse/keyboard) — agent is read-only. | `bd_signal.rs` (`input.mouse`, `input.key` handlers) + `enigo` crate |
|
||||
| **HIGH** | No file write/delete/rename in agent. | `bd_signal.rs` add `files.write`, `files.delete`, `files.rename` |
|
||||
| **HIGH** | RDClient over plain HTTP cannot use WebCodecs → falls back to JMuxer (single-codec H.264, no AV1, no hw decode). | Force HTTPS in deployment; documented in DEPLOY.md |
|
||||
| **MEDIUM** | No bidirectional clipboard sync via bd-signal. | `bd_signal.rs` add `clipboard.get/set` |
|
||||
| **MEDIUM** | Permission prompt on peer for unattended operator sessions. | Tauri agent UI + new bd-signal `consent.request` |
|
||||
| **MEDIUM** | No auto-update mechanism in Tauri agent. | `tauri-plugin-updater` integration |
|
||||
| **LOW** | No microphone forwarding from operator to peer. | RDClient `audio.js` capture path + protocol |
|
||||
| **LOW** | No file-transfer resume after interruption. | RDClient `filetransfer.js` checkpoint state |
|
||||
|
||||
## Phase 58 deliverable (this iteration)
|
||||
|
||||
This iteration extends bd-signal so the JPEG-polling viewer (`/remote-cdap/:id`) becomes interactive:
|
||||
|
||||
- `input.mouse` handler — accepts `{x_rel, y_rel, button, action, wheel_dx, wheel_dy}` where coordinates are normalised 0..1 against the most recent screenshot, button∈{left,right,middle}, action∈{move,down,up,click,wheel}.
|
||||
- `input.key` handler — accepts `{key, code, action, modifiers}` where action∈{down,up,press}, modifiers∈{ctrl,shift,alt,meta}.
|
||||
- `input.text` handler — accepts `{text}` for safe Unicode typing.
|
||||
- Extended `screenshot.capture` reply with `width` and `height` so viewer can compute exact pixel coords without JPEG parsing.
|
||||
- Viewer extension: keyboard listener, mouse listener, focus & pointer-lock toggles. Throttled to ≤30 events/sec to keep the bd-signal channel responsive.
|
||||
|
||||
This does **not** replace the RustDesk relay-based pipeline — it provides a *fallback control path* when the relay is unavailable or for low-bandwidth environments where JPEG snapshots are sufficient (kiosk, low-FPS monitoring, single-shot administration).
|
||||
|
||||
## Phase 59+ (future, scoped)
|
||||
|
||||
| Phase | Goal | Estimated effort |
|
||||
|---|---|---|
|
||||
| 59 | Continuous JPEG streaming through bd-signal (push events from agent) | 1 day |
|
||||
| 60 | H.264 capture + encode in agent (via `xcap` + `openh264`) → WebCodecs path | 1–2 weeks |
|
||||
| 61 | Audio capture in agent → operator playback | 1 week |
|
||||
| 62 | Microphone forwarding (operator → peer) | 3 days |
|
||||
| 63 | File write / delete / rename in `bd_signal.rs` | 1 day |
|
||||
| 64 | Bi-directional clipboard via bd-signal | 1 day |
|
||||
| 65 | Consent / permission prompt on peer | 2 days |
|
||||
| 66 | Auto-update via `tauri-plugin-updater` + signed releases | 3 days |
|
||||
| 67 | RDClient: file-transfer resume + microphone capture | 1 week |
|
||||
|
||||
## References
|
||||
|
||||
- `web-nodejs/public/js/rdclient/client.js` — main browser client
|
||||
- `web-nodejs/public/js/rdclient/protocol.js` — RustDesk protocol layer
|
||||
- `betterdesk-agent-client/src-tauri/src/bd_signal.rs` — agent signal/control channel
|
||||
- `betterdesk-agent-client/src-tauri/src/cdap_client.rs` — CDAP gateway client
|
||||
- Upstream RustDesk: <https://github.com/rustdesk/rustdesk>
|
||||
@@ -0,0 +1,253 @@
|
||||
# Web Remote Client Unification Plan
|
||||
|
||||
> Status: Phase 1, 2.1, 3.1 (partial), 3.2, 3.3 (partial), 3.4, 3.5, 3.6, 3.8 deployed (2026-04-25). Remaining phases pending.
|
||||
>
|
||||
> Goal: merge the two browser remote desktop clients (`/remote/:id` RustDesk
|
||||
> client + `/remote-cdap/:id` CDAP agent viewer) into a single unified UI that
|
||||
> auto-detects the available transport, unlocks richer features when the
|
||||
> device runs the BetterDesk CDAP agent, and forwards the full RustDesk
|
||||
> feature set (mouse, keyboard, clipboard, audio, multi-monitor, file
|
||||
> transfer, recording).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Tab-close auto-disconnect ✅ DONE (2026-04-25)
|
||||
|
||||
Both web clients now wire `pagehide` and `beforeunload` listeners that close
|
||||
every active session immediately when the operator closes the tab, navigates
|
||||
away, or the browser evicts the page from bfcache. Saves bandwidth + CPU on
|
||||
the remote endpoint.
|
||||
|
||||
**Files changed**
|
||||
- `web-nodejs/public/js/cdap-desktop.js` — `closeAllDesktops()` + lifecycle
|
||||
hooks.
|
||||
- `web-nodejs/public/js/remote.js` — iterates `sessions` and calls
|
||||
`client.disconnect()`.
|
||||
|
||||
Server-side teardown chain was already correct: WS drop →
|
||||
`api.handleCDAPDesktop` → `cdap.Gateway.EndDesktopSession` → `desktop_end`
|
||||
forwarded to agent → `DesktopStreamer.Stop()`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Single entry point + transport router
|
||||
|
||||
**Goal:** one URL (`/remote/:id`), one EJS shell, two pluggable transports.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
remote.ejs (unified shell)
|
||||
├── TransportRouter
|
||||
│ ├── probe: GET /api/peers/:id → { device_type, cdap_connected, online }
|
||||
│ ├── decision tree:
|
||||
│ │ • cdap_connected=true → CDAPTransport
|
||||
│ │ • device_type=os_agent && offline → wait + RustDesk fallback
|
||||
│ │ • else → RustDeskTransport
|
||||
│ └── unified event surface:
|
||||
│ videoFrame, cursorUpdate, clipboardUpdate, monitorList,
|
||||
│ audioFrame, fileMeta, terminalChunk, ready, end, error
|
||||
│
|
||||
├── SessionView
|
||||
│ ├── toolbar (capability pills lit/dimmed by transport):
|
||||
│ │ 🎥 Video • 🔊 Audio • 📋 Clipboard • 📁 Files
|
||||
│ │ • 🖥️ Multi-monitor • 💻 Terminal • 💬 Chat • 🔴 Record
|
||||
│ ├── canvas + overlay
|
||||
│ ├── side dock (only shown if transport supports it):
|
||||
│ │ • file transfer panel
|
||||
│ │ • terminal panel (CDAP only)
|
||||
│ │ • live metrics panel (CDAP only)
|
||||
│ │ • chat panel
|
||||
│ └── InputDispatcher: encodes mouse/kbd once, sends to active transport
|
||||
│
|
||||
└── lifecycle: pagehide / beforeunload → transport.end()
|
||||
```
|
||||
|
||||
### PR breakdown
|
||||
|
||||
#### PR 2.1 — Single route ✅ DONE (2026-04-25)
|
||||
- `routes/remote.routes.js`: `/remote/:id` is now the canonical entry. Probes
|
||||
`/api/peers/:id` once on the server, sets `transport='cdap'|'rd'`, attaches
|
||||
a `capabilities` object, and renders the matching template. `?transport=`
|
||||
query param overrides auto-detection. `/remote-cdap/:id` kept as a 302
|
||||
redirect for legacy bookmarks and the existing `devices.js` Connect button.
|
||||
- Templates still split (`remote.ejs` vs `remote-cdap.ejs`); will be merged
|
||||
in PR 2.2 / 2.3.
|
||||
|
||||
#### PR 2.2 — Shared UI shell
|
||||
- Extract toolbar / sidebar / status bar into `views/partials/remote-shell.ejs`.
|
||||
- Move CSS into `web-nodejs/public/css/remote.css` (currently inline).
|
||||
- Delete `views/remote-cdap.ejs` once the unified shell handles both flows.
|
||||
|
||||
#### PR 2.3 — Transport adapters
|
||||
- `public/js/rdclient/transport-rd.js` — wraps existing rdclient `client.js`,
|
||||
emits unified events.
|
||||
- `public/js/rdclient/transport-cdap.js` — wraps current `cdap-desktop.js`
|
||||
WebSocket loop, emits the same unified events.
|
||||
- Both expose: `start()`, `end()`, `sendMouse()`, `sendKey()`,
|
||||
`sendClipboard()`, `requestKeyframe()`, `selectMonitor()`,
|
||||
`getCapabilities()`.
|
||||
|
||||
#### PR 2.4 — Input dispatcher
|
||||
- Move mouse / keyboard encoding into `public/js/rdclient/input.js` (already
|
||||
the canonical source).
|
||||
- Replace ad-hoc encoding inside `cdap-desktop.js` (`MOUSE_TYPE_*`,
|
||||
`MOUSE_BUTTON_*`) with calls into the shared dispatcher.
|
||||
- Clipboard hook: `navigator.clipboard.readText/writeText` with
|
||||
`execCommand('copy')` fallback. Permission prompt handled once on
|
||||
session start.
|
||||
|
||||
#### PR 2.5 — Side panels (CDAP-only)
|
||||
- Terminal panel: reuse `public/js/cdap-terminal.js`, dock as resizable
|
||||
right-hand panel, gated on `capabilities.terminal`.
|
||||
- File browser panel: reuse `public/js/cdap-filebrowser.js`, drag-and-drop
|
||||
upload.
|
||||
- Live metrics: small CPU/RAM/disk strip in the bottom toolbar from
|
||||
`cdap.deviceStateChanged` events.
|
||||
|
||||
#### PR 2.6 — Chat dock
|
||||
- Reuse `public/js/chat.js` + `chatRelay`, mount as collapsible right-edge
|
||||
drawer. Available on every transport.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Full RustDesk feature parity for CDAP
|
||||
|
||||
When the unified shell is in place, the CDAP transport becomes the place to
|
||||
add the small remaining gaps so it matches the RustDesk client byte-for-byte.
|
||||
|
||||
### 3.1 Keyboard map parity ✅ PARTIAL DONE (2026-04-25)
|
||||
- `cdap-desktop.js::sendKeyEvent` now drops OS-level auto-repeat
|
||||
(`e.repeat`) and routes single non-ASCII / non-alphanumeric printable
|
||||
characters (á, €, @, etc.) through the `text` input path so the
|
||||
agent's OS-level layout handles them. Modifier-laden combinations
|
||||
(Ctrl/Alt/Meta) keep the legacy keyboard path because the agent must
|
||||
see modifier presses, not the resolved character.
|
||||
- Pending: F13–F24, exotic media keys, dead-key composition, full
|
||||
RustDesk-equivalent modifier flag bit-mask (would land alongside
|
||||
PR 2.4 input dispatcher).
|
||||
|
||||
### 3.2 Virtual paste fallback ✅ DONE (2026-04-25)
|
||||
- Operator toolbar button `#bd-remote-paste` in `remote-cdap.ejs`
|
||||
reads the browser clipboard via `navigator.clipboard.readText()`
|
||||
and types it into the remote session as a single `input_type:"text"`
|
||||
event. Public API: `CDAPDesktop.pasteFromClipboard(deviceId, widgetId)`
|
||||
+ `CDAPDesktop.sendText(deviceId, widgetId, text)` for canned-text
|
||||
injection from future side panels.
|
||||
- Used when the device side refuses incoming clipboard sync, when the
|
||||
page lacks `clipboard-write` permission to the remote, or when the
|
||||
operator wants to inject text that never touches the device's
|
||||
clipboard history.
|
||||
|
||||
### 3.3 Pointer Lock + Keyboard Lock ✅ PARTIAL DONE (2026-04-25)
|
||||
- Fullscreen + Keyboard Lock implemented in `cdap-desktop.js::toggleFullscreen`.
|
||||
Captures `Escape`, `Tab`, `Meta`, `Alt`, `Ctrl`, `PrintScreen` via
|
||||
`navigator.keyboard.lock()` while in fullscreen. `setDisconnected`
|
||||
releases both fullscreen and the keyboard lock.
|
||||
- Toolbar button `#bd-remote-fullscreen` in `remote-cdap.ejs` toggles the
|
||||
state and swaps the icon based on `document.fullscreenchange`.
|
||||
- Pointer Lock NOT yet wired — the CDAP fast path encodes mouse coords
|
||||
as absolute canvas positions; pointer lock would require a separate
|
||||
relative-motion input pipeline. Tracked for a future phase together
|
||||
with PR 2.4 (input dispatcher).
|
||||
|
||||
### 3.4 Session recording ✅ DONE (2026-04-25)
|
||||
- `cdap-desktop.js` exposes `startRecording`, `stopRecording`,
|
||||
`downloadRecording`, `isRecording` on `window.CDAPDesktop`. Captures
|
||||
the canvas at 15 fps via `captureStream`, encodes to WebM with VP9 +
|
||||
Opus when supported (graceful fallback to VP8 → default WebM).
|
||||
- Toolbar `#bd-remote-record` button toggles between start/download.
|
||||
Recorder is auto-stopped when the session disconnects so abrupt
|
||||
closes still produce a downloadable blob (next click on Record).
|
||||
- Output: `cdap_session_<deviceId>_<ISO>.webm` saved via blob URL.
|
||||
|
||||
### 3.5 Operator presence / dead-man switch ✅ DONE (2026-04-25)
|
||||
- Browser sends `{type:'presence_ping'}` every 15s from
|
||||
`cdap-desktop.js::startPresencePing`.
|
||||
- Server: `api/cdap_handlers.go::handleCDAPDesktop` wraps each
|
||||
`wsConn.Read` in a 30s `context.WithTimeout`. On `DeadlineExceeded` the
|
||||
session is ended with reason `"browser presence timeout"`. The
|
||||
`presence_ping` case is a no-op since the read itself resets the
|
||||
deadline.
|
||||
- Catches operator OS crashes / abrupt power loss where `pagehide` does
|
||||
not fire. Avoids zombie agent capture.
|
||||
|
||||
### 3.6 Hi-DPI awareness ✅ DONE (2026-04-25)
|
||||
- `cdap-desktop.js` init message now includes `device_pixel_ratio`,
|
||||
`client_css_width`, `client_css_height` so the agent can pick a capture
|
||||
resolution that matches the operator's effective display. Unknown
|
||||
fields are ignored by older agents.
|
||||
|
||||
### 3.7 H.264 / VP9 fast path
|
||||
- Replace MJPEG stream with H.264 NALU stream when the agent has
|
||||
`openh264` / hardware codec available. Browser uses WebCodecs
|
||||
`VideoDecoder` (already present in rdclient `video.js`).
|
||||
- Falls back to MJPEG binary fast path (Phase 0) for browsers without
|
||||
WebCodecs.
|
||||
|
||||
### 3.8 Audio forwarding ✅ DONE (2026-04-25)
|
||||
- `cdap-audio` module is now loaded alongside `cdap-desktop` for the CDAP
|
||||
remote view (`pageScripts: ['cdap-desktop', 'cdap-audio']` in
|
||||
`views/remote-cdap.ejs`).
|
||||
- Toolbar `#bd-remote-audio` button connects / disconnects a receive-only
|
||||
audio session via `CDAPAudio.open(deviceId, 'remote-audio', { direction: 'receive' })`.
|
||||
A hidden audio widget element (`#wval-remote-audio`) gives the module
|
||||
a place to render its status / level meter without polluting the
|
||||
remote viewer chrome.
|
||||
- Push-to-talk (microphone) and volume slider deferred until PR 2.5
|
||||
(side panels) where the unified shell will host the audio panel.
|
||||
|
||||
### 3.9 File transfer drag-and-drop
|
||||
- Drop files on canvas → `cdap-filebrowser.js` upload.
|
||||
- Right-click "Send file" picker for the reverse direction.
|
||||
|
||||
### 3.10 Process list / kill
|
||||
- Agent: extend `agent/system.go` to expose `process_list` + `process_kill`
|
||||
CDAP commands.
|
||||
- Browser: small process panel inside the metrics dock.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance order suggestions
|
||||
|
||||
| Goal | Recommended PRs |
|
||||
|-------------------------------------------------|---------------------------|
|
||||
| Fastest "everything works in CDAP like rdclient"| 2.3, 2.4, 3.1, 3.2 |
|
||||
| Cleanest UI / less duplication | 2.1, 2.2, then 2.3 |
|
||||
| Most operator value first | 2.6 (chat), 3.4 (record), 3.8 (audio) |
|
||||
|
||||
---
|
||||
|
||||
## File touch list (rough)
|
||||
|
||||
```
|
||||
web-nodejs/
|
||||
routes/remote.routes.js PR 2.1
|
||||
views/remote.ejs PR 2.2
|
||||
views/partials/remote-shell.ejs (new) PR 2.2
|
||||
views/remote-cdap.ejs (delete) PR 2.2
|
||||
public/css/remote.css (new) PR 2.2
|
||||
public/js/remote.js PR 2.3
|
||||
public/js/rdclient/transport-rd.js (new) PR 2.3
|
||||
public/js/rdclient/transport-cdap.js (new) PR 2.3
|
||||
public/js/rdclient/input.js PR 2.4 / 3.1
|
||||
public/js/cdap-desktop.js (collapse) PR 2.3
|
||||
public/js/cdap-terminal.js PR 2.5
|
||||
public/js/cdap-filebrowser.js PR 2.5
|
||||
public/js/cdap-audio.js PR 3.8
|
||||
public/js/chat.js PR 2.6
|
||||
|
||||
betterdesk-server/
|
||||
api/cdap_handlers.go PR 3.1
|
||||
cdap/desktop.go PR 3.5
|
||||
cdap/audio.go PR 3.8
|
||||
|
||||
betterdesk-agent/
|
||||
agent/desktop.go PR 3.7 (H.264)
|
||||
agent/system.go PR 3.10
|
||||
agent/clipboard.go PR 3.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-04-25 — author: GitHub Copilot during BetterDesk session.*
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -2915,6 +2915,8 @@
|
||||
"no_restart_needed": "No restart required",
|
||||
"complete": "Update applied",
|
||||
"complete_summary": "The update has been applied successfully.",
|
||||
"complete_with_errors": "The update finished, but some steps failed. See details below.",
|
||||
"modal_done_with_errors_title": "Update finished with errors",
|
||||
"console_will_restart": "Console process will restart now…",
|
||||
"restart_complete_msg": "The console finished restarting. Click reload to load the new version. The page will reload automatically in 8 seconds.",
|
||||
"refresh_recommended": "Static assets changed — reload the page to see the new UI.",
|
||||
|
||||
@@ -2915,6 +2915,8 @@
|
||||
"no_restart_needed": "Restart nie jest wymagany",
|
||||
"complete": "Aktualizacja zastosowana",
|
||||
"complete_summary": "Aktualizacja została zastosowana pomyślnie.",
|
||||
"complete_with_errors": "Aktualizacja zakończona, ale niektóre kroki się nie powiodły. Szczegóły poniżej.",
|
||||
"modal_done_with_errors_title": "Aktualizacja zakończona z błędami",
|
||||
"console_will_restart": "Proces konsoli zostanie teraz zrestartowany…",
|
||||
"restart_complete_msg": "Konsola zakończyła restart. Kliknij Odśwież, aby załadować nową wersję. Strona zostanie odświeżona automatycznie za 8 sekund.",
|
||||
"refresh_recommended": "Zmieniły się statyczne zasoby — odśwież stronę, aby zobaczyć nowy interfejs.",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
|
||||
// Quality reporting interval (ms)
|
||||
const QUALITY_REPORT_INTERVAL = 5000;
|
||||
// Presence ping interval (ms) — paired with the 30s server-side dead-man
|
||||
// switch in api/cdap_handlers.go. Half the timeout gives one missed ping
|
||||
// of slack before the server tears the session down.
|
||||
const PRESENCE_PING_INTERVAL = 15000;
|
||||
// Cursor cache limit
|
||||
const CURSOR_CACHE_MAX = 50;
|
||||
|
||||
@@ -53,6 +57,9 @@
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl, ['cdap-desktop']);
|
||||
// Binary frames carry raw JPEG bytes (no base64, no JSON envelope)
|
||||
// for the desktop fast path. Anything else is JSON text.
|
||||
ws.binaryType = 'arraybuffer';
|
||||
} catch (err) {
|
||||
console.error('[CDAPDesktop] WS creation failed:', err);
|
||||
return;
|
||||
@@ -68,8 +75,10 @@
|
||||
deviceId,
|
||||
sessionId: null,
|
||||
connected: false,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
// Match the actual physical screen if available; the agent may
|
||||
// override these in 'ready' / frame messages with the real size.
|
||||
width: (window.screen && window.screen.width) || 1920,
|
||||
height: (window.screen && window.screen.height) || 1080,
|
||||
_frameImg: new Image(),
|
||||
// Quality reporting
|
||||
_frameCount: 0,
|
||||
@@ -92,16 +101,40 @@
|
||||
activeSessions[key] = session;
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send init message with desired resolution
|
||||
// Send init message with desired resolution.
|
||||
// quality 75 + 30 fps targets a smooth helpdesk experience on
|
||||
// LAN; the agent will throttle if CPU/bandwidth cannot keep up.
|
||||
// Frames are delivered over the binary WS fast path (no base64).
|
||||
//
|
||||
// Hi-DPI awareness (Phase 3.6): report the browser's pixel
|
||||
// ratio and the canvas's CSS pixel size so the agent can
|
||||
// capture at the operator's effective resolution and avoid
|
||||
// double-scaling on Retina/4K displays. Unknown fields are
|
||||
// ignored by older agents.
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = session.canvas.getBoundingClientRect();
|
||||
ws.send(JSON.stringify({
|
||||
width: session.width,
|
||||
height: session.height,
|
||||
quality: 70,
|
||||
fps: 15
|
||||
quality: 75,
|
||||
fps: 30,
|
||||
device_pixel_ratio: dpr,
|
||||
client_css_width: Math.round(rect.width || 0),
|
||||
client_css_height: Math.round(rect.height || 0)
|
||||
}));
|
||||
// Start sending presence pings every 15s. The Go server's
|
||||
// desktop read loop has a 30s deadline; missing pings cause
|
||||
// an automatic teardown so a crashed operator browser does
|
||||
// not leave the agent capturing forever.
|
||||
startPresencePing(session);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Binary fast path: raw JPEG bytes from the agent.
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
renderBinaryFrame(session, event.data);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleMessage(session, msg);
|
||||
@@ -144,6 +177,20 @@
|
||||
renderFrame(session, msg);
|
||||
break;
|
||||
|
||||
case 'desktop_meta':
|
||||
// First binary frame is about to start — size the canvas
|
||||
// to the agent's true capture dimensions so we don't
|
||||
// stretch and the input coordinates map 1:1.
|
||||
if (msg.width && msg.height) {
|
||||
if (session.canvas.width !== msg.width || session.canvas.height !== msg.height) {
|
||||
session.canvas.width = msg.width;
|
||||
session.canvas.height = msg.height;
|
||||
}
|
||||
session.width = msg.width;
|
||||
session.height = msg.height;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cursor_update':
|
||||
applyCursor(session, msg);
|
||||
break;
|
||||
@@ -178,6 +225,44 @@
|
||||
|
||||
// ── Frame Rendering ──────────────────────────────────────────────────
|
||||
|
||||
// renderBinaryFrame decodes a raw JPEG buffer received over the binary
|
||||
// WS fast path and paints it onto the session canvas. createImageBitmap
|
||||
// is async-decoded off the main thread and is significantly faster than
|
||||
// the legacy data-URL+Image() path — critical for hitting 30+ fps.
|
||||
function renderBinaryFrame(session, buffer) {
|
||||
session._frameCount++;
|
||||
session._frameBytes += buffer.byteLength;
|
||||
session._lastFrameTime = Date.now();
|
||||
|
||||
// Drop frames if the previous decode is still pending. Painting old
|
||||
// frames over fresher ones would only add latency.
|
||||
if (session._decodeInFlight) {
|
||||
session._droppedFrames++;
|
||||
return;
|
||||
}
|
||||
session._decodeInFlight = true;
|
||||
|
||||
const blob = new Blob([buffer], { type: 'image/jpeg' });
|
||||
createImageBitmap(blob)
|
||||
.then((bitmap) => {
|
||||
const { canvas, ctx } = session;
|
||||
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
session.width = bitmap.width;
|
||||
session.height = bitmap.height;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
if (typeof bitmap.close === 'function') bitmap.close();
|
||||
})
|
||||
.catch(() => {
|
||||
session._droppedFrames++;
|
||||
})
|
||||
.finally(() => {
|
||||
session._decodeInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
function renderFrame(session, msg) {
|
||||
if (!msg.data) return;
|
||||
|
||||
@@ -312,6 +397,28 @@
|
||||
setTimeout(() => indicator.classList.add('hidden'), 1500);
|
||||
}
|
||||
|
||||
// ── Presence ping (dead-man switch) ──────────────────────────────────
|
||||
|
||||
function startPresencePing(session) {
|
||||
if (session._presenceTimer) clearInterval(session._presenceTimer);
|
||||
session._presenceTimer = setInterval(() => {
|
||||
if (!session.ws || session.ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
session.ws.send(JSON.stringify({
|
||||
type: 'presence_ping',
|
||||
ts: Date.now()
|
||||
}));
|
||||
} catch { /* ignore — onclose will reset state */ }
|
||||
}, PRESENCE_PING_INTERVAL);
|
||||
}
|
||||
|
||||
function stopPresencePing(session) {
|
||||
if (session._presenceTimer) {
|
||||
clearInterval(session._presenceTimer);
|
||||
session._presenceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quality Reporting ────────────────────────────────────────────────
|
||||
|
||||
function startQualityReporting(session) {
|
||||
@@ -475,6 +582,10 @@
|
||||
|
||||
canvas.addEventListener('keydown', (e) => {
|
||||
if (!session.connected) return;
|
||||
// Phase 3.1: drop OS-level auto-repeat. The agent already
|
||||
// synthesises repeats on the remote side, and most apps treat
|
||||
// 30+ key presses per second as buggy paste-style input.
|
||||
if (e.repeat) { e.preventDefault(); return; }
|
||||
sendKeyEvent(session, e, 'keydown');
|
||||
e.preventDefault();
|
||||
});
|
||||
@@ -523,6 +634,34 @@
|
||||
}
|
||||
|
||||
function sendKeyEvent(session, e, eventType) {
|
||||
// Phase 3.1: when the browser produces a single non-ASCII character
|
||||
// (e.g. "ą", "@", "€"), the agent's per-letter VK fallback rejects
|
||||
// it. Route those through the `text` input path so the OS handles
|
||||
// the layout-aware translation. Only fire on keydown to avoid
|
||||
// double insertion. Modifiers are intentionally ignored here —
|
||||
// the browser already produced the resolved character.
|
||||
if (eventType === 'keydown'
|
||||
&& typeof e.key === 'string'
|
||||
&& e.key.length === 1
|
||||
&& !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
const cc = e.key.charCodeAt(0);
|
||||
const printable = cc >= 0x20 && cc !== 0x7F;
|
||||
const ascii = cc < 0x80;
|
||||
const isAlphaNum = ascii && (
|
||||
(cc >= 0x30 && cc <= 0x39) ||
|
||||
(cc >= 0x41 && cc <= 0x5A) ||
|
||||
(cc >= 0x61 && cc <= 0x7A)
|
||||
);
|
||||
if (printable && (!isAlphaNum || !ascii)) {
|
||||
sendInput(session, {
|
||||
type: 'input',
|
||||
input_type: 'text',
|
||||
text: e.key
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sendInput(session, {
|
||||
type: 'input',
|
||||
input_type: 'keyboard',
|
||||
@@ -538,6 +677,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 3.2: virtual paste — send arbitrary text as a single `text`
|
||||
// input event. Falls back to typing the clipboard contents when the
|
||||
// clipboard sync path is blocked (browser permission denied, remote
|
||||
// refuses incoming clipboard, or operator just wants to inject
|
||||
// canned text). Long strings are sent in a single payload; the agent
|
||||
// is responsible for splitting if needed.
|
||||
function sendText(session, text) {
|
||||
if (!session || !session.connected || !text) return false;
|
||||
sendInput(session, {
|
||||
type: 'input',
|
||||
input_type: 'text',
|
||||
text: String(text)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pasteFromClipboard(deviceId, widgetId) {
|
||||
const key = `${deviceId}:${widgetId}`;
|
||||
const session = activeSessions[key];
|
||||
if (!session || !session.connected) return false;
|
||||
|
||||
let text = '';
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
try {
|
||||
text = await navigator.clipboard.readText();
|
||||
} catch (err) {
|
||||
console.warn('[CDAPDesktop] Clipboard read failed:', err && err.message);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (!text) return false;
|
||||
return sendText(session, text);
|
||||
}
|
||||
|
||||
function sendInput(session, payload) {
|
||||
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.send(JSON.stringify(payload));
|
||||
@@ -558,6 +733,19 @@
|
||||
clearInterval(session._qualityTimer);
|
||||
session._qualityTimer = null;
|
||||
}
|
||||
stopPresencePing(session);
|
||||
// Stop recorder if still rolling — we don't want to leave dangling
|
||||
// MediaRecorder + canvas captureStream when the session ends.
|
||||
if (session._recorder && session._recorder.state === 'recording') {
|
||||
try { session._recorder.stop(); } catch { /* ignore */ }
|
||||
}
|
||||
// Release fullscreen + keyboard lock if we held them.
|
||||
if (document.fullscreenElement && session.widgetEl && session.widgetEl.contains(document.fullscreenElement)) {
|
||||
try { document.exitFullscreen(); } catch {}
|
||||
}
|
||||
if (navigator.keyboard && navigator.keyboard.unlock) {
|
||||
try { navigator.keyboard.unlock(); } catch {}
|
||||
}
|
||||
if (session.overlay) {
|
||||
session.overlay.classList.remove('hidden');
|
||||
session.overlay.querySelector('span:last-child').textContent =
|
||||
@@ -576,13 +764,204 @@
|
||||
if (!session) return;
|
||||
|
||||
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.send(JSON.stringify({ type: 'close' }));
|
||||
session.ws.close();
|
||||
try { session.ws.send(JSON.stringify({ type: 'close' })); } catch {}
|
||||
try { session.ws.close(1000, 'client_close'); } catch {}
|
||||
}
|
||||
setDisconnected(session);
|
||||
delete activeSessions[key];
|
||||
}
|
||||
|
||||
// Close every active desktop session — used by the tab-close / page-hide
|
||||
// handlers below so the agent tears down capture immediately instead of
|
||||
// waiting for a socket read timeout.
|
||||
function closeAllDesktops(reason) {
|
||||
const keys = Object.keys(activeSessions);
|
||||
for (const key of keys) {
|
||||
const session = activeSessions[key];
|
||||
if (!session) continue;
|
||||
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
session.ws.send(JSON.stringify({ type: 'close', reason: reason || 'tab_closed' }));
|
||||
} catch {}
|
||||
try { session.ws.close(1001, reason || 'tab_closed'); } catch {}
|
||||
}
|
||||
setDisconnected(session);
|
||||
delete activeSessions[key];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fullscreen + Keyboard Lock (Phase 3.3) ───────────────────────────
|
||||
//
|
||||
// Fullscreen toggles the widget container into the OS-level fullscreen
|
||||
// mode and, when supported, asks the browser to capture system
|
||||
// keyboard shortcuts (Alt+Tab, Win, PrintScreen) via the Keyboard
|
||||
// Lock API. Pointer Lock is intentionally not used here because the
|
||||
// CDAP fast path encodes mouse coordinates as absolute canvas
|
||||
// positions; relative motion deltas would need a separate input
|
||||
// pipeline. Keyboard Lock is only valid in fullscreen.
|
||||
|
||||
async function toggleFullscreen(deviceId, widgetId) {
|
||||
const key = `${deviceId}:${widgetId}`;
|
||||
const session = activeSessions[key];
|
||||
if (!session) return false;
|
||||
const target = session.widgetEl || session.canvas;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
try {
|
||||
await target.requestFullscreen();
|
||||
} catch (err) {
|
||||
console.warn('[CDAPDesktop] Fullscreen request failed:', err && err.message);
|
||||
return false;
|
||||
}
|
||||
if (navigator.keyboard && navigator.keyboard.lock) {
|
||||
try {
|
||||
await navigator.keyboard.lock([
|
||||
'Escape', 'Tab',
|
||||
'MetaLeft', 'MetaRight',
|
||||
'AltLeft', 'AltRight',
|
||||
'ControlLeft', 'ControlRight',
|
||||
'PrintScreen'
|
||||
]);
|
||||
} catch (err) {
|
||||
console.warn('[CDAPDesktop] Keyboard lock failed:', err && err.message);
|
||||
}
|
||||
}
|
||||
// Refocus canvas so keystrokes route to the remote.
|
||||
try { session.canvas.focus(); } catch {}
|
||||
return true;
|
||||
}
|
||||
|
||||
try { await document.exitFullscreen(); } catch {}
|
||||
if (navigator.keyboard && navigator.keyboard.unlock) {
|
||||
try { navigator.keyboard.unlock(); } catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFullscreen(deviceId, widgetId) {
|
||||
const key = `${deviceId}:${widgetId}`;
|
||||
const session = activeSessions[key];
|
||||
if (!session || !session.widgetEl) return false;
|
||||
return !!(document.fullscreenElement && session.widgetEl.contains(document.fullscreenElement));
|
||||
}
|
||||
|
||||
// ── Session Recording (Phase 3.4) ────────────────────────────────────
|
||||
//
|
||||
// Records the canvas frames to a WebM blob via MediaRecorder. The
|
||||
// frames are already painted on the canvas, so we capture from there
|
||||
// rather than re-decoding the JPEG stream. 15 fps + 2.5 Mbps gives a
|
||||
// legible audit recording without inflating disk usage. The blob is
|
||||
// built up in memory during the session and flushed on stop, so
|
||||
// operators should keep an eye on long sessions; future improvement:
|
||||
// periodic chunk download or server-side persistence.
|
||||
|
||||
function startRecording(deviceId, widgetId) {
|
||||
const key = `${deviceId}:${widgetId}`;
|
||||
const session = activeSessions[key];
|
||||
if (!session || !session.connected) return false;
|
||||
if (session._recorder) return false;
|
||||
if (typeof session.canvas.captureStream !== 'function' || typeof MediaRecorder === 'undefined') {
|
||||
console.warn('[CDAPDesktop] MediaRecorder / captureStream not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = session.canvas.captureStream(15);
|
||||
let mimeType = 'video/webm;codecs=vp9,opus';
|
||||
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
|
||||
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
|
||||
|
||||
session._recordedChunks = [];
|
||||
session._recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: 2500000
|
||||
});
|
||||
session._recorder.ondataavailable = (ev) => {
|
||||
if (ev.data && ev.data.size > 0) session._recordedChunks.push(ev.data);
|
||||
};
|
||||
session._recorder.start(1000);
|
||||
session._recordingStartTime = Date.now();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[CDAPDesktop] Recording start failed:', err && err.message);
|
||||
session._recorder = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording(deviceId, widgetId) {
|
||||
return new Promise((resolve) => {
|
||||
const key = `${deviceId}:${widgetId}`;
|
||||
const session = activeSessions[key];
|
||||
if (!session || !session._recorder || session._recorder.state === 'inactive') {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const recorder = session._recorder;
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(session._recordedChunks || [], { type: recorder.mimeType });
|
||||
session._recordedChunks = [];
|
||||
session._recorder = null;
|
||||
resolve(blob);
|
||||
};
|
||||
try { recorder.stop(); } catch { resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadRecording(deviceId, widgetId) {
|
||||
const blob = await stopRecording(deviceId, widgetId);
|
||||
if (!blob) return false;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `cdap_session_${deviceId}_${ts}.webm`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isRecording(deviceId, widgetId) {
|
||||
const key = `${deviceId}:${widgetId}`;
|
||||
const session = activeSessions[key];
|
||||
return !!(session && session._recorder && session._recorder.state === 'recording');
|
||||
}
|
||||
|
||||
// ── Tab / window lifecycle: auto-end sessions on close ───────────────
|
||||
//
|
||||
// Without these hooks the browser may take several seconds to drop the
|
||||
// WebSocket when the tab is closed, especially on mobile or when the
|
||||
// OS suspends the page. That leaves the agent still streaming and
|
||||
// consuming CPU until the server-side read loop times out. We send an
|
||||
// explicit close frame via both `pagehide` (covers tab close,
|
||||
// navigation, bfcache) and `beforeunload` (legacy fallback).
|
||||
function installLifecycleHandlers() {
|
||||
if (window.__cdapDesktopLifecycleInstalled) return;
|
||||
window.__cdapDesktopLifecycleInstalled = true;
|
||||
|
||||
const onGone = () => closeAllDesktops('tab_closed');
|
||||
// pagehide is the most reliable modern hook — fires for tab close,
|
||||
// navigation, and bfcache eviction.
|
||||
window.addEventListener('pagehide', onGone, { capture: true });
|
||||
// beforeunload is noisy but still useful as a secondary signal.
|
||||
window.addEventListener('beforeunload', onGone, { capture: true });
|
||||
// If the tab just becomes hidden, keep the session alive but
|
||||
// reduce load — future: request lower fps. For now this is a hook
|
||||
// point only.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// Intentionally do not close here — users switching tabs
|
||||
// briefly should resume instantly. Browsers will fire
|
||||
// `pagehide` if the tab is truly being unloaded.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
installLifecycleHandlers();
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
window.CDAPDesktop = {
|
||||
@@ -601,7 +980,21 @@
|
||||
setClipboardEnabled: (deviceId, widgetId, enabled) => {
|
||||
const s = activeSessions[`${deviceId}:${widgetId}`];
|
||||
if (s) s._clipboardEnabled = !!enabled;
|
||||
}
|
||||
},
|
||||
// Phase 3.3: fullscreen + keyboard lock
|
||||
toggleFullscreen,
|
||||
isFullscreen,
|
||||
// Phase 3.4: session recording
|
||||
startRecording,
|
||||
stopRecording,
|
||||
downloadRecording,
|
||||
isRecording,
|
||||
// Phase 3.2: virtual paste / arbitrary text injection
|
||||
sendText: (deviceId, widgetId, text) => {
|
||||
const s = activeSessions[`${deviceId}:${widgetId}`];
|
||||
return s ? sendText(s, text) : false;
|
||||
},
|
||||
pasteFromClipboard
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -392,6 +392,10 @@
|
||||
<span class="material-icons">screen_share</span>
|
||||
<span>${_('actions.web_remote') || 'Web Remote'}</span>
|
||||
</button>
|
||||
<button class="kebab-menu-item" data-action="cdap-viewer" data-id="${eid}">
|
||||
<span class="material-icons">photo_camera</span>
|
||||
<span>${_('actions.cdap_viewer') || 'CDAP Snapshot Viewer'}</span>
|
||||
</button>
|
||||
<button class="kebab-menu-item" data-action="connect-desktop" data-id="${eid}">
|
||||
<span class="material-icons">computer</span>
|
||||
<span>${_('actions.connect_desktop') || 'Desktop Client'}</span>
|
||||
@@ -536,6 +540,10 @@
|
||||
_tryAddRemoteTab(deviceId, data);
|
||||
break;
|
||||
|
||||
case 'cdap-viewer':
|
||||
window.open(`/remote-cdap/${encodeURIComponent(deviceId)}`, '_blank');
|
||||
break;
|
||||
|
||||
case 'connect-desktop':
|
||||
connectDesktopClient(deviceId);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
/**
|
||||
* BetterDesk Web Remote Client — CDAP Transport Adapter
|
||||
*
|
||||
* Drop-in replacement for `RDClient` when the target device is a
|
||||
* BetterDesk OS-agent (CDAP transport). Exposes the same public surface
|
||||
* (`connect`, `disconnect`, `authenticate`, `verify2fa`, event emitter
|
||||
* with `state`/`log`/`session_start`/`disconnected`/`error`/`stats`)
|
||||
* so `remote.js` can swap implementations without branching.
|
||||
*
|
||||
* The CDAP path:
|
||||
* - opens `/api/cdap/devices/:id/desktop` (subprotocol `cdap-desktop`)
|
||||
* - receives raw JPEG binary frames + JSON control messages
|
||||
* - sends mouse / keyboard / text input as JSON over the same socket
|
||||
*
|
||||
* No password challenge (Go server gates the WS upgrade with the
|
||||
* operator session), so we transition straight from `connecting` →
|
||||
* `streaming` on `ready`.
|
||||
*
|
||||
* Phase 2.3 (unification plan): replaces the standalone `cdap-desktop.js`
|
||||
* widget for the full-screen `/remote/:id?transport=cdap` viewer. The
|
||||
* widget stays for inline device-detail panels.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Mouse encoding (MUST match cdap server expectations + cdap-desktop.js)
|
||||
const MOUSE_TYPE_MOVE = 0;
|
||||
const MOUSE_TYPE_DOWN = 1;
|
||||
const MOUSE_TYPE_UP = 2;
|
||||
const MOUSE_TYPE_WHEEL = 3;
|
||||
const MOUSE_BUTTON_LEFT = 1;
|
||||
const MOUSE_BUTTON_RIGHT = 2;
|
||||
const MOUSE_BUTTON_MIDDLE = 4;
|
||||
|
||||
const PRESENCE_PING_MS = 15000;
|
||||
const STATS_INTERVAL_MS = 1000;
|
||||
|
||||
/**
|
||||
* Stub renderer that mirrors the subset of `RDRenderer` used by
|
||||
* `remote.js` (resize + scale mode). Frames are painted directly by
|
||||
* the adapter; no codec pipeline is involved.
|
||||
*/
|
||||
class CDAPRenderer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.scaleMode = 'fit';
|
||||
}
|
||||
resize() {
|
||||
// Canvas auto-sizes from `desktop_meta`; this is a no-op stub.
|
||||
// We still update the CSS object-fit rule on `setScaleMode`.
|
||||
}
|
||||
setScaleMode(mode) {
|
||||
this.scaleMode = mode;
|
||||
const map = {
|
||||
'fit': 'contain',
|
||||
'fill': 'cover',
|
||||
'1:1': 'none',
|
||||
'stretch': 'fill',
|
||||
};
|
||||
this.canvas.style.objectFit = map[mode] || 'contain';
|
||||
}
|
||||
// Subset of RDRenderer used elsewhere — left as no-ops so the
|
||||
// shared toolbar code does not throw when wired against CDAP.
|
||||
drawCursor() { /* handled by canvas.style.cursor on cursor_update */ }
|
||||
clear() {
|
||||
try { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); }
|
||||
catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
class CDAPSession {
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.deviceId
|
||||
* @param {string} [opts.scaleMode]
|
||||
* @param {number} [opts.fps]
|
||||
* @param {string} [opts.imageQuality] — 'Best' | 'Balanced' | 'Low'
|
||||
*/
|
||||
constructor(canvas, opts = {}) {
|
||||
if (!canvas) throw new Error('Canvas element required');
|
||||
if (!opts.deviceId) throw new Error('deviceId required');
|
||||
|
||||
this.deviceId = opts.deviceId;
|
||||
this.opts = opts;
|
||||
|
||||
this._state = 'idle';
|
||||
this._listeners = {};
|
||||
|
||||
this.renderer = new CDAPRenderer(canvas);
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
|
||||
// Public RDClient-compatible stubs so the shared toolbar code
|
||||
// does not crash. None of them are functional for the CDAP
|
||||
// transport yet (see Phase 2.5 / 2.6 in the unification plan).
|
||||
this.input = {
|
||||
start: () => { /* keyboard/mouse are bound on connect */ },
|
||||
stop: () => { /* released in disconnect */ },
|
||||
blockInput: () => false,
|
||||
setBlockInput: () => false,
|
||||
};
|
||||
// No-op file transfer stub (PR 2.5 will wire real CDAP file
|
||||
// transfer). Keeps the toolbar callbacks in `remote.js` from
|
||||
// throwing when the operator clicks file-browser buttons.
|
||||
this.fileTransfer = {
|
||||
browseParent: () => this._emit('log', 'File browser is not yet supported over CDAP.'),
|
||||
browseDir: () => this._emit('log', 'File browser is not yet supported over CDAP.'),
|
||||
cancelTransfer: () => false,
|
||||
upload: () => false,
|
||||
download: () => false,
|
||||
};
|
||||
|
||||
this._ws = null;
|
||||
this._connected = false;
|
||||
this._inputBound = false;
|
||||
this._presenceTimer = null;
|
||||
this._statsTimer = null;
|
||||
this._readyTimer = null;
|
||||
this._monitors = [];
|
||||
this._activeMonitor = 0;
|
||||
this._sessionId = null;
|
||||
|
||||
// Stats counters
|
||||
this._frameCount = 0;
|
||||
this._frameBytes = 0;
|
||||
this._lastStatsTime = 0;
|
||||
this._lastFrameTime = 0;
|
||||
|
||||
// Bound handlers (so remove works on disconnect)
|
||||
this._onMouseDown = this._handleMouseDown.bind(this);
|
||||
this._onMouseUp = this._handleMouseUp.bind(this);
|
||||
this._onMouseMove = this._handleMouseMove.bind(this);
|
||||
this._onWheel = this._handleWheel.bind(this);
|
||||
this._onKeyDown = this._handleKeyDown.bind(this);
|
||||
this._onKeyUp = this._handleKeyUp.bind(this);
|
||||
this._onContextMenu = (e) => e.preventDefault();
|
||||
this._onPaste = this._handlePaste.bind(this);
|
||||
}
|
||||
|
||||
get state() { return this._state; }
|
||||
get peerInfo() {
|
||||
return {
|
||||
username: this.deviceId,
|
||||
hostname: this.deviceId,
|
||||
version: 'cdap',
|
||||
platform: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Event Emitter ────────────────────────────────────────────────
|
||||
|
||||
on(event, fn) {
|
||||
(this._listeners[event] = this._listeners[event] || []).push(fn);
|
||||
return this;
|
||||
}
|
||||
off(event, fn) {
|
||||
const arr = this._listeners[event];
|
||||
if (arr) this._listeners[event] = arr.filter(f => f !== fn);
|
||||
return this;
|
||||
}
|
||||
_emit(event, ...args) {
|
||||
const arr = this._listeners[event];
|
||||
if (arr) arr.forEach(fn => { try { fn(...args); } catch (e) { console.error(e); } });
|
||||
}
|
||||
|
||||
_setState(s) {
|
||||
if (this._state === s) return;
|
||||
this._state = s;
|
||||
this._emit('state', s);
|
||||
}
|
||||
|
||||
// ── Public API (RDClient-compatible) ─────────────────────────────
|
||||
|
||||
async connect() {
|
||||
this._setState('connecting');
|
||||
this._emit('log', 'Opening CDAP desktop session…');
|
||||
console.log('[CDAP] connect()', this.deviceId);
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${window.location.host}/api/cdap/devices/${encodeURIComponent(this.deviceId)}/desktop`;
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(url, ['cdap-desktop']);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
} catch (err) {
|
||||
this._setState('error');
|
||||
this._emit('error', err.message || 'WebSocket open failed');
|
||||
throw err;
|
||||
}
|
||||
this._ws = ws;
|
||||
|
||||
ws.addEventListener('open', () => this._handleOpen());
|
||||
ws.addEventListener('message', (e) => this._handleMessage(e));
|
||||
ws.addEventListener('error', () => {
|
||||
console.warn('[CDAP] socket error');
|
||||
this._emit('log', 'CDAP socket error');
|
||||
});
|
||||
ws.addEventListener('close', (e) => this._handleClose(e));
|
||||
|
||||
// Phase 3: don't let the operator stare at "Connecting…" forever.
|
||||
// If the agent never replies with `ready` (e.g. screen capture
|
||||
// permission denied, agent offline, no admin role on device),
|
||||
// surface a clear error after 20s.
|
||||
this._readyTimer = setTimeout(() => {
|
||||
if (this._state === 'connecting') {
|
||||
console.warn('[CDAP] ready timeout — agent did not respond');
|
||||
this._emit('error',
|
||||
'Agent did not start the desktop session (timeout). ' +
|
||||
'Check that the agent is online and screen capture is enabled.');
|
||||
try { this._ws && this._ws.close(4001, 'ready_timeout'); } catch { /* noop */ }
|
||||
}
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._unbindInput();
|
||||
this._stopPresencePing();
|
||||
this._stopStats();
|
||||
if (this._readyTimer) { clearTimeout(this._readyTimer); this._readyTimer = null; }
|
||||
if (this._ws && this._ws.readyState !== WebSocket.CLOSED) {
|
||||
try { this._ws.close(1000, 'client_disconnect'); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
this._ws = null;
|
||||
this._connected = false;
|
||||
this._setState('disconnected');
|
||||
this._emit('disconnected', 'user');
|
||||
}
|
||||
|
||||
authenticate(_password) {
|
||||
// CDAP transport does not use a password challenge.
|
||||
return Promise.resolve();
|
||||
}
|
||||
verify2fa(_code) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// RDClient toolbar-shim methods. Most are no-ops because the
|
||||
// CDAP transport renegotiates quality/cursor/etc. via dedicated
|
||||
// control messages instead of in-band login options. They MUST
|
||||
// exist so the shared toolbar code in `remote.js` does not throw
|
||||
// when the operator clicks them.
|
||||
|
||||
setQuality(label) {
|
||||
const q = this._qualityFromLabel(label);
|
||||
this._send({ type: 'quality_set', quality: q });
|
||||
}
|
||||
setQualityPreset(preset) {
|
||||
// 'best' | 'balanced' | 'speed'
|
||||
const map = { best: 92, balanced: 75, speed: 50 };
|
||||
const q = map[String(preset || '').toLowerCase()] || 75;
|
||||
this._send({ type: 'quality_set', quality: q });
|
||||
}
|
||||
setFps(fps) {
|
||||
this._send({ type: 'quality_set', fps: this._normaliseFps(fps) });
|
||||
}
|
||||
setScaleMode(mode) {
|
||||
try { this.renderer.setScaleMode(mode); } catch { /* noop */ }
|
||||
}
|
||||
setShowCursor(_b) { /* CDAP cursor is server-driven */ }
|
||||
setShowRemoteCursor(b) { this._send({ type: 'show_cursor', enabled: !!b }); }
|
||||
setLockAfterSession(b) { this._send({ type: 'lock_after_session', enabled: !!b }); }
|
||||
setPrivacyMode(b) { this._send({ type: 'privacy_mode', enabled: !!b }); }
|
||||
setDisableClipboard(b) { this._send({ type: 'disable_clipboard', enabled: !!b }); }
|
||||
setBlockInput(b) { this._send({ type: 'block_input', enabled: !!b }); }
|
||||
setAudioMuted(_b) { /* audio is handled via separate /audio WS */ }
|
||||
|
||||
requestKeyframe() {
|
||||
this._send({ type: 'keyframe_request' });
|
||||
}
|
||||
sendRefreshScreen() {
|
||||
this._send({ type: 'keyframe_request' });
|
||||
}
|
||||
sendCAD() { this.sendCtrlAltDel(); }
|
||||
sendCtrlAltDel() {
|
||||
// Synthesised as Ctrl+Alt+Delete key combo.
|
||||
const send = (key, code, down) => this._send({
|
||||
type: 'input', input_type: 'keyboard',
|
||||
key, code, down,
|
||||
modifiers: { ctrl: down, alt: down, shift: false, meta: false },
|
||||
});
|
||||
send('Control', 'ControlLeft', true);
|
||||
send('Alt', 'AltLeft', true);
|
||||
send('Delete', 'Delete', true);
|
||||
send('Delete', 'Delete', false);
|
||||
send('Alt', 'AltLeft', false);
|
||||
send('Control', 'ControlLeft', false);
|
||||
}
|
||||
sendLockScreen() {
|
||||
this._send({ type: 'lock_screen' });
|
||||
}
|
||||
sendRestart() { this.sendRestartRemoteDevice(); }
|
||||
sendRestartRemoteDevice() { this._send({ type: 'restart_device' }); }
|
||||
sendClipboard(text) {
|
||||
if (!text) return false;
|
||||
return this.sendText(text);
|
||||
}
|
||||
toggleAudio() { /* page-level CDAPAudio handles this */ }
|
||||
sendChat(_msg) { /* not yet relayed via CDAP desktop channel */ }
|
||||
|
||||
// Monitors — populated from `monitor_list` control messages.
|
||||
getMonitors() { return this._monitors.slice(); }
|
||||
switchMonitor(idx) {
|
||||
const i = Math.max(0, Math.min(idx | 0, Math.max(this._monitors.length - 1, 0)));
|
||||
this._activeMonitor = i;
|
||||
this._send({ type: 'monitor_select', index: i });
|
||||
}
|
||||
|
||||
// Fullscreen — delegate to the container the toolbar passes in.
|
||||
toggleFullscreen(container) {
|
||||
const target = container || this.canvas.closest('.viewer-container') || this.canvas;
|
||||
if (!document.fullscreenElement) {
|
||||
if (target.requestFullscreen) target.requestFullscreen().catch(() => {});
|
||||
} else if (document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Local recording stubs — `remote.js btn-record` uses MediaRecorder
|
||||
// directly via canvas.captureStream(), so these are reserved for
|
||||
// future remote-side recording.
|
||||
startRecording() { return false; }
|
||||
stopRecording() { return false; }
|
||||
downloadRecording() { return false; }
|
||||
isRecording() { return false; }
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
frames: this._frameCount,
|
||||
bytes: this._frameBytes,
|
||||
fps: 0,
|
||||
kbps: 0,
|
||||
transport: 'cdap',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Internal: WS lifecycle ───────────────────────────────────────
|
||||
|
||||
_handleOpen() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const screenW = (window.screen && window.screen.width) || 1920;
|
||||
const screenH = (window.screen && window.screen.height) || 1080;
|
||||
|
||||
const quality = this._qualityFromLabel(this.opts.imageQuality);
|
||||
const fps = this._normaliseFps(this.opts.fps);
|
||||
|
||||
this._send({
|
||||
width: screenW,
|
||||
height: screenH,
|
||||
quality,
|
||||
fps,
|
||||
device_pixel_ratio: dpr,
|
||||
client_css_width: Math.round(rect.width || 0),
|
||||
client_css_height: Math.round(rect.height || 0),
|
||||
});
|
||||
|
||||
this._startPresencePing();
|
||||
}
|
||||
|
||||
_handleClose(e) {
|
||||
this._unbindInput();
|
||||
this._stopPresencePing();
|
||||
this._stopStats();
|
||||
if (this._readyTimer) { clearTimeout(this._readyTimer); this._readyTimer = null; }
|
||||
this._connected = false;
|
||||
const reason = (e && e.reason) || 'closed';
|
||||
console.log('[CDAP] socket closed', e && e.code, reason);
|
||||
this._setState('disconnected');
|
||||
this._emit('disconnected', reason);
|
||||
}
|
||||
|
||||
_handleMessage(event) {
|
||||
// Binary fast path: raw JPEG bytes.
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
this._renderBinaryFrame(event.data);
|
||||
return;
|
||||
}
|
||||
let msg;
|
||||
try { msg = JSON.parse(event.data); }
|
||||
catch { return; }
|
||||
this._dispatchControl(msg);
|
||||
}
|
||||
|
||||
_dispatchControl(msg) {
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
if (this._readyTimer) { clearTimeout(this._readyTimer); this._readyTimer = null; }
|
||||
this._sessionId = msg.session_id || null;
|
||||
this._connected = true;
|
||||
this._setState('streaming');
|
||||
this._bindInput();
|
||||
this._startStats();
|
||||
this._emit('login_success');
|
||||
this._emit('session_start');
|
||||
this._emit('log', 'Streaming');
|
||||
console.log('[CDAP] ready, session=', this._sessionId);
|
||||
break;
|
||||
|
||||
case 'desktop_meta':
|
||||
if (msg.width && msg.height) {
|
||||
if (this.canvas.width !== msg.width) this.canvas.width = msg.width;
|
||||
if (this.canvas.height !== msg.height) this.canvas.height = msg.height;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'frame':
|
||||
// Legacy JSON frame envelope (data URI / base64 jpeg).
|
||||
if (msg.data) this._renderEncodedFrame(msg);
|
||||
break;
|
||||
|
||||
case 'cursor_update':
|
||||
this._applyCursor(msg);
|
||||
break;
|
||||
|
||||
case 'clipboard_update':
|
||||
this._handleClipboardUpdate(msg);
|
||||
break;
|
||||
|
||||
case 'monitor_list': {
|
||||
// Agent reports the available displays. Cache and notify
|
||||
// remote.js so the toolbar can populate its dropdown.
|
||||
const list = Array.isArray(msg.monitors) ? msg.monitors : [];
|
||||
this._monitors = list.map((m, idx) => ({
|
||||
idx: typeof m.idx === 'number' ? m.idx : idx,
|
||||
name: m.name || `Monitor ${idx + 1}`,
|
||||
primary: !!m.primary,
|
||||
width: m.width | 0,
|
||||
height: m.height | 0,
|
||||
}));
|
||||
if (typeof msg.active === 'number') this._activeMonitor = msg.active;
|
||||
console.log('[CDAP] monitor_list', this._monitors);
|
||||
this._emit('monitors', this._monitors);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codec_answer':
|
||||
case 'quality_adjust':
|
||||
// Informational only.
|
||||
break;
|
||||
|
||||
case 'consent_required':
|
||||
case 'permission_required':
|
||||
this._emit('log', msg.message || 'Awaiting user consent on the device…');
|
||||
console.log('[CDAP] consent_required', msg);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
if (this._readyTimer) { clearTimeout(this._readyTimer); this._readyTimer = null; }
|
||||
console.error('[CDAP] error', msg);
|
||||
this._emit('error', msg.error || msg.message || 'CDAP error');
|
||||
break;
|
||||
|
||||
case 'end':
|
||||
if (this._readyTimer) { clearTimeout(this._readyTimer); this._readyTimer = null; }
|
||||
console.log('[CDAP] end', msg);
|
||||
this._setState('disconnected');
|
||||
this._emit('disconnected', msg.reason || 'agent_end');
|
||||
break;
|
||||
|
||||
default:
|
||||
// Surface unknown types in dev tools so we can spot
|
||||
// protocol drift between agent and gateway quickly.
|
||||
console.debug('[CDAP] unhandled message', msg.type, msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame rendering ──────────────────────────────────────────────
|
||||
|
||||
_renderBinaryFrame(buf) {
|
||||
const blob = new Blob([buf], { type: 'image/jpeg' });
|
||||
this._frameCount++;
|
||||
this._frameBytes += buf.byteLength;
|
||||
this._lastFrameTime = performance.now();
|
||||
// Prefer createImageBitmap (off-thread decode); fall back to Image.
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
createImageBitmap(blob).then(bm => {
|
||||
if (bm.width !== this.canvas.width || bm.height !== this.canvas.height) {
|
||||
if (bm.width > 0 && bm.height > 0) {
|
||||
this.canvas.width = bm.width;
|
||||
this.canvas.height = bm.height;
|
||||
}
|
||||
}
|
||||
try { this.ctx.drawImage(bm, 0, 0); } catch { /* noop */ }
|
||||
bm.close && bm.close();
|
||||
}).catch(() => { /* drop frame on decode error */ });
|
||||
} else {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
if (img.width !== this.canvas.width || img.height !== this.canvas.height) {
|
||||
this.canvas.width = img.width;
|
||||
this.canvas.height = img.height;
|
||||
}
|
||||
this.ctx.drawImage(img, 0, 0);
|
||||
} catch { /* noop */ }
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
}
|
||||
}
|
||||
|
||||
_renderEncodedFrame(msg) {
|
||||
const fmt = msg.format || 'jpeg';
|
||||
const src = msg.data.startsWith('data:') ? msg.data : `data:image/${fmt};base64,${msg.data}`;
|
||||
this._frameCount++;
|
||||
this._lastFrameTime = performance.now();
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
if (msg.width && this.canvas.width !== msg.width) this.canvas.width = msg.width;
|
||||
if (msg.height && this.canvas.height !== msg.height) this.canvas.height = msg.height;
|
||||
this.ctx.drawImage(img, 0, 0);
|
||||
} catch { /* noop */ }
|
||||
};
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
// ── Cursor + clipboard ───────────────────────────────────────────
|
||||
|
||||
_applyCursor(msg) {
|
||||
if (msg.hidden) {
|
||||
this.canvas.style.cursor = 'none';
|
||||
return;
|
||||
}
|
||||
// Best-effort: leave system cursor visible. Custom cursor PNG
|
||||
// assembly mirrors `cdap-desktop.js` but is costly per frame;
|
||||
// CDAP cursor frames are infrequent so this can be added in a
|
||||
// follow-up without affecting steady-state perf.
|
||||
this.canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
_handleClipboardUpdate(msg) {
|
||||
// Mirror device → operator clipboard when the agent allows it.
|
||||
const text = msg.text;
|
||||
if (!text) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(() => { /* permission denied */ });
|
||||
}
|
||||
this._emit('clipboard', text);
|
||||
}
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
|
||||
_bindInput() {
|
||||
if (this._inputBound) return;
|
||||
const c = this.canvas;
|
||||
c.addEventListener('mousedown', this._onMouseDown);
|
||||
c.addEventListener('mouseup', this._onMouseUp);
|
||||
c.addEventListener('mousemove', this._onMouseMove);
|
||||
c.addEventListener('wheel', this._onWheel, { passive: false });
|
||||
c.addEventListener('contextmenu', this._onContextMenu);
|
||||
c.addEventListener('paste', this._onPaste);
|
||||
document.addEventListener('keydown', this._onKeyDown);
|
||||
document.addEventListener('keyup', this._onKeyUp);
|
||||
c.tabIndex = 0;
|
||||
c.focus();
|
||||
this._inputBound = true;
|
||||
}
|
||||
|
||||
_unbindInput() {
|
||||
if (!this._inputBound) return;
|
||||
const c = this.canvas;
|
||||
c.removeEventListener('mousedown', this._onMouseDown);
|
||||
c.removeEventListener('mouseup', this._onMouseUp);
|
||||
c.removeEventListener('mousemove', this._onMouseMove);
|
||||
c.removeEventListener('wheel', this._onWheel);
|
||||
c.removeEventListener('contextmenu', this._onContextMenu);
|
||||
c.removeEventListener('paste', this._onPaste);
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
document.removeEventListener('keyup', this._onKeyUp);
|
||||
this._inputBound = false;
|
||||
}
|
||||
|
||||
_isInputFocused() {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
// Hidden inputs (e.g. password field after authenticate) do
|
||||
// not block keyboard capture.
|
||||
if (el.offsetParent === null) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
|
||||
}
|
||||
|
||||
_coords(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round((e.clientX - rect.left) / rect.width * this.canvas.width),
|
||||
y: Math.round((e.clientY - rect.top) / rect.height * this.canvas.height),
|
||||
};
|
||||
}
|
||||
|
||||
_mouseButton(e) {
|
||||
if (e.button === 0) return MOUSE_BUTTON_LEFT;
|
||||
if (e.button === 2) return MOUSE_BUTTON_RIGHT;
|
||||
if (e.button === 1) return MOUSE_BUTTON_MIDDLE;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_handleMouseDown(e) {
|
||||
if (!this._connected) return;
|
||||
const { x, y } = this._coords(e);
|
||||
const btn = this._mouseButton(e);
|
||||
this._send({ type: 'input', input_type: 'mouse', x, y, button: MOUSE_TYPE_DOWN | (btn << 3) });
|
||||
e.preventDefault();
|
||||
this.canvas.focus();
|
||||
}
|
||||
_handleMouseUp(e) {
|
||||
if (!this._connected) return;
|
||||
const { x, y } = this._coords(e);
|
||||
const btn = this._mouseButton(e);
|
||||
this._send({ type: 'input', input_type: 'mouse', x, y, button: MOUSE_TYPE_UP | (btn << 3) });
|
||||
e.preventDefault();
|
||||
}
|
||||
_handleMouseMove(e) {
|
||||
if (!this._connected) return;
|
||||
const { x, y } = this._coords(e);
|
||||
this._send({ type: 'input', input_type: 'mouse', x, y, button: MOUSE_TYPE_MOVE });
|
||||
}
|
||||
_handleWheel(e) {
|
||||
if (!this._connected) return;
|
||||
const { x, y } = this._coords(e);
|
||||
this._send({
|
||||
type: 'input', input_type: 'mouse',
|
||||
x, y,
|
||||
deltaX: e.deltaX,
|
||||
deltaY: e.deltaY,
|
||||
button: MOUSE_TYPE_WHEEL,
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
_handleKeyDown(e) {
|
||||
if (!this._connected) return;
|
||||
if (this._isInputFocused()) return;
|
||||
// Phase 3.1: drop OS-level auto-repeat — the agent synthesises
|
||||
// repeats on the remote side.
|
||||
if (e.repeat) { e.preventDefault(); return; }
|
||||
// Phase 3.1: Unicode → text fallback (modifierless single
|
||||
// printable non-alphanumeric char gets routed via input_type:
|
||||
// text so the agent's OS-side layout handles it).
|
||||
if (typeof e.key === 'string' && e.key.length === 1
|
||||
&& !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
const cc = e.key.charCodeAt(0);
|
||||
const printable = cc >= 0x20 && cc !== 0x7F;
|
||||
const ascii = cc < 0x80;
|
||||
const isAlphaNum = ascii && (
|
||||
(cc >= 0x30 && cc <= 0x39) ||
|
||||
(cc >= 0x41 && cc <= 0x5A) ||
|
||||
(cc >= 0x61 && cc <= 0x7A)
|
||||
);
|
||||
if (printable && (!isAlphaNum || !ascii)) {
|
||||
this._send({ type: 'input', input_type: 'text', text: e.key });
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._sendKeyEvent(e, true);
|
||||
e.preventDefault();
|
||||
}
|
||||
_handleKeyUp(e) {
|
||||
if (!this._connected) return;
|
||||
if (this._isInputFocused()) return;
|
||||
this._sendKeyEvent(e, false);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
_sendKeyEvent(e, down) {
|
||||
this._send({
|
||||
type: 'input',
|
||||
input_type: 'keyboard',
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
modifiers: {
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
meta: e.metaKey,
|
||||
},
|
||||
down,
|
||||
});
|
||||
}
|
||||
|
||||
_handlePaste(e) {
|
||||
if (!this._connected) return;
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text) this._send({ type: 'input', input_type: 'text', text });
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// ── Public helpers used by `remote.js` toolbar (Paste / Text) ────
|
||||
|
||||
sendText(text) {
|
||||
if (!this._connected || !text) return false;
|
||||
this._send({ type: 'input', input_type: 'text', text: String(text) });
|
||||
return true;
|
||||
}
|
||||
|
||||
async pasteFromClipboard() {
|
||||
if (!navigator.clipboard || !navigator.clipboard.readText) return false;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (!text) return false;
|
||||
return this.sendText(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire helpers ─────────────────────────────────────────────────
|
||||
|
||||
_send(payload) {
|
||||
const ws = this._ws;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
_qualityFromLabel(label) {
|
||||
switch (String(label || '').toLowerCase()) {
|
||||
case 'best': return 92;
|
||||
case 'low': return 50;
|
||||
case 'speed': return 50;
|
||||
case 'balanced':
|
||||
default: return 75;
|
||||
}
|
||||
}
|
||||
_normaliseFps(fps) {
|
||||
const n = Number(fps);
|
||||
if (!Number.isFinite(n) || n <= 0) return 30;
|
||||
return Math.min(60, Math.max(5, Math.round(n)));
|
||||
}
|
||||
|
||||
// ── Presence ping (Phase 3.5) ────────────────────────────────────
|
||||
|
||||
_startPresencePing() {
|
||||
this._stopPresencePing();
|
||||
this._presenceTimer = setInterval(() => {
|
||||
this._send({ type: 'ping', t: Date.now() });
|
||||
}, PRESENCE_PING_MS);
|
||||
}
|
||||
_stopPresencePing() {
|
||||
if (this._presenceTimer) {
|
||||
clearInterval(this._presenceTimer);
|
||||
this._presenceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats (1s aggregate, fed to the toolbar) ─────────────────────
|
||||
|
||||
_startStats() {
|
||||
this._stopStats();
|
||||
this._lastStatsTime = performance.now();
|
||||
this._statsTimer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
const dt = (now - this._lastStatsTime) / 1000;
|
||||
if (dt <= 0) return;
|
||||
const fps = this._frameCount / dt;
|
||||
const kbps = (this._frameBytes * 8 / 1000) / dt;
|
||||
this._frameCount = 0;
|
||||
this._frameBytes = 0;
|
||||
this._lastStatsTime = now;
|
||||
this._emit('stats', {
|
||||
fps: Math.round(fps * 10) / 10,
|
||||
kbps: Math.round(kbps),
|
||||
transport: 'cdap',
|
||||
});
|
||||
}, STATS_INTERVAL_MS);
|
||||
}
|
||||
_stopStats() {
|
||||
if (this._statsTimer) {
|
||||
clearInterval(this._statsTimer);
|
||||
this._statsTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.CDAPSession = CDAPSession;
|
||||
})();
|
||||
@@ -1183,7 +1183,15 @@ class RDClient {
|
||||
|
||||
_handleError(err) {
|
||||
console.error('[RDClient]', err);
|
||||
this._emit('error', err.message || err);
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
// Detect peer-offline scenarios where the agent is reachable through
|
||||
// bd-signal/CDAP but not through the RustDesk relay (no peer registration).
|
||||
// In that case, signal the UI that a CDAP fallback viewer is available.
|
||||
const offlineHint = /target offline|relay refused|peer.*offline|not online|not registered/i.test(msg);
|
||||
this._emit('error', msg, { cdapFallback: offlineHint });
|
||||
if (offlineHint) {
|
||||
this._emit('cdap_fallback_available', this.deviceId);
|
||||
}
|
||||
this._cleanup();
|
||||
this._setState('error');
|
||||
}
|
||||
|
||||
@@ -4,11 +4,29 @@
|
||||
* Supports multiple concurrent RDClient sessions
|
||||
*/
|
||||
|
||||
/* global RDClient, RDVideo */
|
||||
/* global RDClient, RDVideo, CDAPSession */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ---- Transport selection (PR 2.3) ----
|
||||
// The unified web client picks the right transport per-device based
|
||||
// on `window.__capabilities.transport` (set server-side from the Go
|
||||
// peer record). RustDesk peers go through `RDClient`; OS-agent /
|
||||
// CDAP-connected peers use `CDAPSession`, which exposes the same
|
||||
// public surface so the rest of the session manager is transport-
|
||||
// agnostic.
|
||||
function getTransportName() {
|
||||
const caps = window.__capabilities || {};
|
||||
return String(caps.transport || 'rd').toLowerCase() === 'cdap' ? 'cdap' : 'rd';
|
||||
}
|
||||
function createTransportClient(canvas, opts) {
|
||||
if (getTransportName() === 'cdap' && typeof CDAPSession === 'function') {
|
||||
return new CDAPSession(canvas, opts);
|
||||
}
|
||||
return new RDClient(canvas, opts);
|
||||
}
|
||||
|
||||
// ---- Simple toast notification ----
|
||||
function showToast(message, type) {
|
||||
const toast = document.createElement('div');
|
||||
@@ -53,6 +71,7 @@
|
||||
this.tfaOverlay = panel.querySelector('.session-2fa-overlay');
|
||||
this.tfaInput = panel.querySelector('.session-2fa-input');
|
||||
this.tfaError = panel.querySelector('.session-2fa-error');
|
||||
this.cdapFallbackBtn = panel.querySelector('.session-btn-cdap-fallback');
|
||||
this.client = null;
|
||||
this.state = 'idle';
|
||||
this.latency = 0;
|
||||
@@ -206,7 +225,7 @@
|
||||
// Create RDClient — start conservative; AdaptiveQuality promotes when the
|
||||
// pipeline proves it can keep up (prevents 3–7 FPS stalls on weaker CPUs/JMuxer).
|
||||
const userName = (window.BetterDesk.user && (window.BetterDesk.user.display_name || window.BetterDesk.user.username)) || 'BetterDesk Web';
|
||||
session.client = new RDClient(session.canvas, {
|
||||
session.client = createTransportClient(session.canvas, {
|
||||
deviceId: deviceId,
|
||||
serverPubKey: window.BetterDesk.serverPubKey || '',
|
||||
myName: userName,
|
||||
@@ -291,12 +310,13 @@
|
||||
session.connectionOverlay.style.display = 'flex';
|
||||
session.passwordOverlay.style.display = 'none';
|
||||
session.overlayActions.style.display = 'none';
|
||||
if (session.cdapFallbackBtn) session.cdapFallbackBtn.style.display = 'none';
|
||||
const spinner = session.connectionOverlay.querySelector('.spinner');
|
||||
if (spinner) spinner.style.display = 'block';
|
||||
session.statusText.textContent = _('remote.connecting');
|
||||
|
||||
const userName = (window.BetterDesk.user && (window.BetterDesk.user.display_name || window.BetterDesk.user.username)) || 'BetterDesk Web';
|
||||
session.client = new RDClient(session.canvas, {
|
||||
session.client = createTransportClient(session.canvas, {
|
||||
deviceId: session.deviceId,
|
||||
serverPubKey: window.BetterDesk.serverPubKey || '',
|
||||
myName: userName,
|
||||
@@ -337,12 +357,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
c.on('error', (msg) => {
|
||||
c.on('error', (msg, meta) => {
|
||||
setSessionStatus(session, 'error', msg);
|
||||
showSessionActions(session);
|
||||
if (meta && meta.cdapFallback) showCdapFallback(session);
|
||||
if (isActive(session)) setToolbarAutoHide(false);
|
||||
});
|
||||
|
||||
c.on('cdap_fallback_available', () => {
|
||||
showCdapFallback(session);
|
||||
});
|
||||
|
||||
c.on('disconnected', (reason) => {
|
||||
setSessionStatus(session, 'info', reason || _('remote.disconnected'));
|
||||
showSessionActions(session);
|
||||
@@ -403,6 +428,16 @@
|
||||
|
||||
c.on('chat', (text) => addChatMessage(session, text, 'received'));
|
||||
|
||||
// CDAP transport: agent emits `monitors` after `monitor_list`. Show
|
||||
// the toolbar dropdown on multi-display agents and refresh contents.
|
||||
c.on('monitors', (list) => {
|
||||
const btn = document.getElementById('btn-monitors');
|
||||
if (btn) btn.style.display = (Array.isArray(list) && list.length > 1) ? '' : 'none';
|
||||
if (isActive(session)) {
|
||||
try { updateMonitorMenu(); } catch { /* menu not yet built */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Security events: show warnings for E2E encryption issues
|
||||
c.on('signature_warning', (msg) => {
|
||||
console.warn('[Remote] Signature warning:', msg);
|
||||
@@ -436,6 +471,11 @@
|
||||
session.panel.querySelector('.session-btn-reconnect')
|
||||
?.addEventListener('click', () => reconnectSession(session));
|
||||
|
||||
session.panel.querySelector('.session-btn-cdap-fallback')
|
||||
?.addEventListener('click', () => {
|
||||
window.location.href = '/remote-cdap/' + encodeURIComponent(session.deviceId);
|
||||
});
|
||||
|
||||
session.panel.querySelector('.session-btn-authenticate')
|
||||
?.addEventListener('click', () => {
|
||||
const pw = session.passwordInput.value;
|
||||
@@ -787,6 +827,10 @@
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
}
|
||||
|
||||
function showCdapFallback(session) {
|
||||
if (session.cdapFallbackBtn) session.cdapFallbackBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
function syncToolbarToSession(session) {
|
||||
const stateLabels = {
|
||||
'idle': _('remote.status_idle'),
|
||||
@@ -1185,7 +1229,7 @@
|
||||
|
||||
document.getElementById('btn-connect-new')?.addEventListener('click', () => {
|
||||
const id = newSessionInput.value.trim();
|
||||
if (!id || !/^[A-Za-z0-9_-]{3,32}$/.test(id)) {
|
||||
if (!id || !/^[A-Za-z0-9_-]{3,64}$/.test(id)) {
|
||||
newSessionInput.classList.add('error');
|
||||
setTimeout(() => newSessionInput.classList.remove('error'), 1500);
|
||||
return;
|
||||
@@ -1269,5 +1313,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab / window lifecycle: auto-disconnect on tab close ─────────────
|
||||
//
|
||||
// Without this hook the remote peer keeps streaming video/audio until
|
||||
// the relay notices the WebSocket is gone (seconds, sometimes longer
|
||||
// when the OS pauses the page). An explicit `pagehide` / `beforeunload`
|
||||
// triggers a clean `disconnect()` on every active session so the peer
|
||||
// tears down immediately — saves bandwidth and CPU on the remote end.
|
||||
function installLifecycleHandlers() {
|
||||
const teardown = () => {
|
||||
for (const session of sessions.values()) {
|
||||
try {
|
||||
if (session.client) session.client.disconnect();
|
||||
} catch { /* ignore */ }
|
||||
if (session.mediaRecorder && session.mediaRecorder.state === 'recording') {
|
||||
try { session.mediaRecorder.stop(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
// pagehide fires on tab close, navigation, and bfcache eviction —
|
||||
// the most reliable modern hook.
|
||||
window.addEventListener('pagehide', teardown, { capture: true });
|
||||
// beforeunload is a secondary fallback for older browsers.
|
||||
window.addEventListener('beforeunload', teardown, { capture: true });
|
||||
}
|
||||
|
||||
init();
|
||||
installLifecycleHandlers();
|
||||
})();
|
||||
|
||||
@@ -1688,8 +1688,9 @@
|
||||
logUpdate(`Go toolchain install failed: ${result.toolchainInstall.error || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
const deployFailed = !!(result.serverDeploy && result.serverDeploy.success === false);
|
||||
if (result.serverBuild) {
|
||||
if (result.serverBuild.success) {
|
||||
if (result.serverBuild.success && !deployFailed) {
|
||||
const ms = result.serverBuild.duration || 0;
|
||||
const secs = ms ? Math.round(ms / 1000) : 0;
|
||||
const sizeMB = result.serverBuild.size ? ` (${(result.serverBuild.size / (1024 * 1024)).toFixed(1)} MB)` : '';
|
||||
@@ -1698,6 +1699,11 @@
|
||||
: `${_('updates.server_built')}${secs ? ` · ${secs}s` : ''}`;
|
||||
setUpdatePhase('server', 'done', detail);
|
||||
logUpdate(detail);
|
||||
} else if (result.serverBuild.success && deployFailed) {
|
||||
// Build OK but deploy to service path failed — surface as error
|
||||
const detail = _('updates.server_deploy_failed');
|
||||
setUpdatePhase('server', 'error', detail);
|
||||
logUpdate(`${detail}: ${result.serverDeploy.error || ''}`);
|
||||
} else {
|
||||
const detail = result.serverBuild.method === 'download'
|
||||
? _('updates.server_download_failed')
|
||||
@@ -1708,7 +1714,7 @@
|
||||
} else {
|
||||
setUpdatePhase('server', 'skipped', _('updates.server_skipped'));
|
||||
}
|
||||
if (result.serverDeploy && !result.serverDeploy.success) {
|
||||
if (deployFailed && result.serverBuild?.success) {
|
||||
logUpdate(`Deploy failed: ${result.serverDeploy.error || ''}`);
|
||||
}
|
||||
} else {
|
||||
@@ -1751,14 +1757,21 @@
|
||||
|
||||
function showUpdateCompletionModal(result) {
|
||||
const lines = [];
|
||||
lines.push(`<p>${Utils.escapeHtml(_('updates.complete_summary'))}</p>`);
|
||||
const deployFailed = !!(result.serverDeploy && result.serverDeploy.success === false);
|
||||
const hasFailures = (result.failed?.length || 0) > 0 || deployFailed;
|
||||
const summaryKey = hasFailures ? 'updates.complete_with_errors' : 'updates.complete_summary';
|
||||
lines.push(`<p>${Utils.escapeHtml(_(summaryKey))}</p>`);
|
||||
const stats = [
|
||||
{ label: _('updates.applied'), value: result.applied?.length || 0 },
|
||||
{ label: _('updates.failed'), value: result.failed?.length || 0 },
|
||||
{ label: _('updates.removed'), value: result.removed?.length || 0 }
|
||||
];
|
||||
lines.push(`<ul style="margin:8px 0;padding-left:20px;font-size:13px;">${stats.map(s => `<li>${Utils.escapeHtml(s.label)}: <strong>${s.value}</strong></li>`).join('')}</ul>`);
|
||||
if (result.serverBuild?.success) {
|
||||
if (deployFailed) {
|
||||
const errMsg = result.serverDeploy.error || '';
|
||||
lines.push(`<p style="font-size:13px;color:var(--danger,#e34935);"><strong>${Utils.escapeHtml(_('updates.server_deploy_failed'))}</strong></p>`);
|
||||
if (errMsg) lines.push(`<pre style="font-size:12px;background:var(--bg-secondary,#1a1a1a);padding:8px;border-radius:4px;overflow:auto;max-height:120px;white-space:pre-wrap;">${Utils.escapeHtml(errMsg)}</pre>`);
|
||||
} else if (result.serverBuild?.success) {
|
||||
const note = result.serverBuild.method === 'download' ? _('updates.server_downloaded') : _('updates.server_built');
|
||||
lines.push(`<p style="font-size:13px;color:var(--text-secondary);">${Utils.escapeHtml(note)}</p>`);
|
||||
}
|
||||
@@ -1770,7 +1783,7 @@
|
||||
|
||||
window.Modal.close();
|
||||
window.Modal.show({
|
||||
title: _('updates.modal_done_title'),
|
||||
title: _(hasFailures ? 'updates.modal_done_with_errors_title' : 'updates.modal_done_title'),
|
||||
content: lines.join(''),
|
||||
buttons: [
|
||||
{ label: _('updates.modal_close'), class: 'btn-secondary', onClick: () => { window.Modal.close(); } },
|
||||
|
||||
@@ -623,7 +623,13 @@
|
||||
|
||||
function skip() {
|
||||
if (!_isActive) return;
|
||||
|
||||
|
||||
// Permanently mark this tutorial as dismissed so autoStart won't
|
||||
// show it again until the user explicitly resets via the Help menu.
|
||||
var seen = JSON.parse(localStorage.getItem(STORAGE_SEEN) || '{}');
|
||||
seen[_tutorialType] = true;
|
||||
localStorage.setItem(STORAGE_SEEN, JSON.stringify(seen));
|
||||
|
||||
// Run afterHide for current step
|
||||
var step = _steps[_currentStep];
|
||||
if (step && step.afterHide) step.afterHide();
|
||||
|
||||
@@ -55,7 +55,7 @@ async function identifyDevice(req, res, next) {
|
||||
} catch (_) { /* ignored */ }
|
||||
}
|
||||
const deviceId = req.headers['x-device-id'];
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) {
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
req.deviceId = deviceId;
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ async function identifyDevice(req, res, next) {
|
||||
} catch (_) { /* ignored */ }
|
||||
}
|
||||
const deviceId = req.headers['x-device-id'];
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) {
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
req.deviceId = deviceId;
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ async function identifyDevice(req, res, next) {
|
||||
}
|
||||
// Fallback: X-Device-Id header (for registration before login)
|
||||
const deviceId = req.headers['x-device-id'];
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) {
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
req.deviceId = deviceId;
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -554,6 +554,95 @@ router.post('/api/devices/:id/files/read', requireAuth, requirePermission('devic
|
||||
proxyAgentRequest(req, res, 'files.read', { path, offset, length }, 30000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/files/write (Phase 63)
|
||||
* Body: { path, data: <base64>, mode?: 'overwrite|append|create' }
|
||||
* Max payload ~16 MB (enforced agent-side). Audited.
|
||||
*/
|
||||
router.post('/api/devices/:id/files/write', requireAuth, requirePermission('device.edit'), async (req, res) => {
|
||||
const path = String(req.body?.path || '').slice(0, 4096);
|
||||
const data = String(req.body?.data || '');
|
||||
const mode = ['overwrite', 'append', 'create'].includes(req.body?.mode) ? req.body.mode : 'overwrite';
|
||||
if (!path) return res.status(400).json({ success: false, error: 'path_required' });
|
||||
if (data.length > 22 * 1024 * 1024) {
|
||||
return res.status(413).json({ success: false, error: 'payload_too_large' });
|
||||
}
|
||||
try {
|
||||
await db.logAction(req.session.userId, 'files.write',
|
||||
`Write ${mode} on ${req.params.id}: ${path}`, req.ip || null);
|
||||
} catch (_) { /* non-fatal */ }
|
||||
proxyAgentRequest(req, res, 'files.write', { path, data, mode }, 30000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/files/delete (Phase 63)
|
||||
* Body: { path, recursive?: bool }
|
||||
*/
|
||||
router.post('/api/devices/:id/files/delete', requireAuth, requirePermission('device.edit'), async (req, res) => {
|
||||
const path = String(req.body?.path || '').slice(0, 4096);
|
||||
const recursive = req.body?.recursive === true;
|
||||
if (!path) return res.status(400).json({ success: false, error: 'path_required' });
|
||||
try {
|
||||
await db.logAction(req.session.userId, 'files.delete',
|
||||
`Delete${recursive ? ' (recursive)' : ''} on ${req.params.id}: ${path}`, req.ip || null);
|
||||
} catch (_) { /* non-fatal */ }
|
||||
proxyAgentRequest(req, res, 'files.delete', { path, recursive }, 15000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/files/rename (Phase 63)
|
||||
* Body: { from, to }
|
||||
*/
|
||||
router.post('/api/devices/:id/files/rename', requireAuth, requirePermission('device.edit'), async (req, res) => {
|
||||
const from = String(req.body?.from || '').slice(0, 4096);
|
||||
const to = String(req.body?.to || '').slice(0, 4096);
|
||||
if (!from || !to) return res.status(400).json({ success: false, error: 'paths_required' });
|
||||
try {
|
||||
await db.logAction(req.session.userId, 'files.rename',
|
||||
`Rename on ${req.params.id}: ${from} -> ${to}`, req.ip || null);
|
||||
} catch (_) { /* non-fatal */ }
|
||||
proxyAgentRequest(req, res, 'files.rename', { from, to }, 10000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/files/mkdir (Phase 63)
|
||||
* Body: { path, recursive?: bool (default true) }
|
||||
*/
|
||||
router.post('/api/devices/:id/files/mkdir', requireAuth, requirePermission('device.edit'), async (req, res) => {
|
||||
const path = String(req.body?.path || '').slice(0, 4096);
|
||||
const recursive = req.body?.recursive !== false;
|
||||
if (!path) return res.status(400).json({ success: false, error: 'path_required' });
|
||||
try {
|
||||
await db.logAction(req.session.userId, 'files.mkdir',
|
||||
`Mkdir on ${req.params.id}: ${path}`, req.ip || null);
|
||||
} catch (_) { /* non-fatal */ }
|
||||
proxyAgentRequest(req, res, 'files.mkdir', { path, recursive }, 10000);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/devices/:id/clipboard (Phase 64)
|
||||
* Reads the device's text clipboard.
|
||||
*/
|
||||
router.get('/api/devices/:id/clipboard', requireAuth, requirePermission('device.view'), (req, res) => {
|
||||
proxyAgentRequest(req, res, 'clipboard.get', null, 5000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/clipboard (Phase 64)
|
||||
* Body: { text } (max 1 MiB enforced agent-side)
|
||||
*/
|
||||
router.post('/api/devices/:id/clipboard', requireAuth, requirePermission('device.edit'), async (req, res) => {
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text : '';
|
||||
if (text.length > 1024 * 1024) {
|
||||
return res.status(413).json({ success: false, error: 'text_too_large' });
|
||||
}
|
||||
try {
|
||||
await db.logAction(req.session.userId, 'clipboard.set',
|
||||
`Clipboard set on ${req.params.id} (${text.length} chars)`, req.ip || null);
|
||||
} catch (_) { /* non-fatal */ }
|
||||
proxyAgentRequest(req, res, 'clipboard.set', { text }, 5000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/screenshot
|
||||
* Captures a JPEG snapshot from the agent. Returns base64 image.
|
||||
@@ -562,6 +651,51 @@ router.post('/api/devices/:id/screenshot', requireAuth, requirePermission('devic
|
||||
proxyAgentRequest(req, res, 'screenshot.capture', null, 20000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/input/mouse
|
||||
* Body: { action: 'move|down|up|click|wheel', x?, y?, x_rel?, y_rel?,
|
||||
* screen_w?, screen_h?, button?: 'left|right|middle',
|
||||
* wheel_dx?, wheel_dy? }
|
||||
* Forwards a single mouse event to the agent (Phase 58).
|
||||
*/
|
||||
router.post('/api/devices/:id/input/mouse', requireAuth, requirePermission('device.edit'), (req, res) => {
|
||||
const b = req.body || {};
|
||||
const payload = {
|
||||
action: String(b.action || 'move'),
|
||||
button: typeof b.button === 'string' ? b.button : undefined,
|
||||
};
|
||||
if (typeof b.x === 'number') payload.x = Math.trunc(b.x);
|
||||
if (typeof b.y === 'number') payload.y = Math.trunc(b.y);
|
||||
if (typeof b.x_rel === 'number') payload.x_rel = b.x_rel;
|
||||
if (typeof b.y_rel === 'number') payload.y_rel = b.y_rel;
|
||||
if (typeof b.screen_w === 'number') payload.screen_w = Math.trunc(b.screen_w);
|
||||
if (typeof b.screen_h === 'number') payload.screen_h = Math.trunc(b.screen_h);
|
||||
if (typeof b.wheel_dx === 'number') payload.wheel_dx = Math.trunc(b.wheel_dx);
|
||||
if (typeof b.wheel_dy === 'number') payload.wheel_dy = Math.trunc(b.wheel_dy);
|
||||
proxyAgentRequest(req, res, 'input.mouse', payload, 5000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/input/key
|
||||
* Body: { key: 'Enter|Escape|a|F5|...', action?: 'press|down|up' }
|
||||
*/
|
||||
router.post('/api/devices/:id/input/key', requireAuth, requirePermission('device.edit'), (req, res) => {
|
||||
const key = String(req.body?.key || '').slice(0, 32);
|
||||
if (!key) return res.status(400).json({ success: false, error: 'key_required' });
|
||||
const action = String(req.body?.action || 'press');
|
||||
proxyAgentRequest(req, res, 'input.key', { key, action }, 5000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/input/text
|
||||
* Body: { text: 'hello world' }
|
||||
*/
|
||||
router.post('/api/devices/:id/input/text', requireAuth, requirePermission('device.edit'), (req, res) => {
|
||||
const text = String(req.body?.text || '').slice(0, 4096);
|
||||
if (!text) return res.status(400).json({ success: false, error: 'text_required' });
|
||||
proxyAgentRequest(req, res, 'input.text', { text }, 8000);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/terminal/execute
|
||||
* Body: { command: 'ls -la /tmp' }
|
||||
|
||||
@@ -51,7 +51,7 @@ async function identifyDevice(req, res, next) {
|
||||
} catch (_) { /* ignored */ }
|
||||
}
|
||||
const deviceId = req.headers['x-device-id'];
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) {
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
req.deviceId = deviceId;
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -33,17 +33,29 @@ router.get('/remote', requireAuth, (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /remote/:deviceId - RustDesk-compatible remote desktop viewer
|
||||
* GET /remote/:deviceId - Unified remote desktop viewer (single entry point).
|
||||
*
|
||||
* Phase 2.1 of the unification plan: this route is now the only canonical
|
||||
* URL for browser-based remote desktop. The transport (RustDesk relay vs.
|
||||
* CDAP WebSocket) is auto-detected on the server by probing the Go server
|
||||
* for `device_type` and `cdap_connected`. The decision is then passed to
|
||||
* the appropriate template.
|
||||
*
|
||||
* Query overrides:
|
||||
* ?transport=cdap → force CDAP transport (skip auto-probe)
|
||||
* ?transport=rd → force RustDesk transport
|
||||
*
|
||||
* Until the unified `remote.ejs` shell lands (PR 2.2 / 2.3) we still render
|
||||
* the existing two templates underneath. Operators get a single URL and
|
||||
* shareable links work regardless of which transport is active.
|
||||
*/
|
||||
router.get('/remote/:deviceId', requireAuth, async (req, res) => {
|
||||
const deviceId = req.params.deviceId;
|
||||
|
||||
// Validate device ID format
|
||||
if (!deviceId || !/^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) {
|
||||
if (!deviceId || !/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
return res.redirect('/devices');
|
||||
}
|
||||
|
||||
// Look up device in database for display info (optional, not blocking)
|
||||
let device = null;
|
||||
try {
|
||||
device = await db.getDevice(deviceId);
|
||||
@@ -51,17 +63,71 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => {
|
||||
// Database lookup failure is non-blocking - viewer can still work
|
||||
}
|
||||
|
||||
// Probe Go server for authoritative transport hint. Local panel DB
|
||||
// does not carry `device_type` or `cdap_connected`.
|
||||
let isOsAgent = false;
|
||||
let isCdapConnected = false;
|
||||
let goPeer = null;
|
||||
try {
|
||||
const api = require('../services/betterdeskApi');
|
||||
goPeer = await api.getPeer(deviceId);
|
||||
if (goPeer) {
|
||||
isOsAgent = String(goPeer.device_type || '').toLowerCase() === 'os_agent';
|
||||
isCdapConnected = !!goPeer.cdap_connected;
|
||||
}
|
||||
} catch { /* non-fatal: degrade to standard viewer */ }
|
||||
|
||||
// Resolve transport: explicit query param wins, then auto-detect.
|
||||
const forced = String(req.query.transport || '').toLowerCase();
|
||||
let transport;
|
||||
if (forced === 'cdap' || forced === 'rd') {
|
||||
transport = forced;
|
||||
} else if (isOsAgent || isCdapConnected) {
|
||||
transport = 'cdap';
|
||||
} else {
|
||||
transport = 'rd';
|
||||
}
|
||||
|
||||
// Capability hints exposed to the browser so the unified UI can light
|
||||
// up the right toolbar buttons.
|
||||
const capabilities = {
|
||||
transport,
|
||||
os_agent: isOsAgent,
|
||||
cdap_connected: isCdapConnected,
|
||||
device_type: goPeer && goPeer.device_type ? String(goPeer.device_type) : '',
|
||||
};
|
||||
|
||||
// PR 2.2/2.3 unification: a single canonical web client (`remote.ejs`)
|
||||
// serves both transports. The browser branches on
|
||||
// `window.__capabilities.transport`. The legacy `remote-cdap` template
|
||||
// is no longer rendered; its inline widget remains usable from
|
||||
// device-detail panels via `cdap-desktop.js` directly.
|
||||
res.render('remote', {
|
||||
title: `${req.t('remote.title')} - ${deviceId}`,
|
||||
activePage: 'remote',
|
||||
deviceId: deviceId,
|
||||
device: device || { id: deviceId, hostname: '', platform: '', note: '' },
|
||||
serverPubKey: serverPubKey,
|
||||
// Use viewer layout instead of main layout
|
||||
capabilities,
|
||||
layout: 'viewer'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /remote-cdap/:deviceId - Legacy alias, redirects to unified entry.
|
||||
*
|
||||
* Kept for backwards compatibility with existing bookmarks, deep links, and
|
||||
* the `devices.js` "Connect" button. New code should link to
|
||||
* `/remote/:deviceId` directly.
|
||||
*/
|
||||
router.get('/remote-cdap/:deviceId', requireAuth, (req, res) => {
|
||||
const deviceId = req.params.deviceId;
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
return res.redirect(`/remote/${encodeURIComponent(deviceId)}?transport=cdap`);
|
||||
}
|
||||
return res.redirect('/devices');
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /remote-desktop/:deviceId - Legacy route, redirects to unified /remote/:deviceId
|
||||
*
|
||||
@@ -71,7 +137,7 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => {
|
||||
*/
|
||||
router.get('/remote-desktop/:deviceId', requireAuth, (req, res) => {
|
||||
const deviceId = req.params.deviceId;
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) {
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
return res.redirect(`/remote/${encodeURIComponent(deviceId)}`);
|
||||
}
|
||||
return res.redirect('/devices');
|
||||
|
||||
@@ -470,7 +470,7 @@ async function identifyDevice(req, res, next) {
|
||||
} catch (_) { /* ignored */ }
|
||||
}
|
||||
const deviceId = req.headers['x-device-id'];
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) {
|
||||
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
req.deviceId = deviceId;
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -263,6 +263,46 @@ app.use((err, req, res, next) => {
|
||||
|
||||
// ============ Startup ============
|
||||
|
||||
/**
|
||||
* Warn if the user set Go-server-only TLS env vars in the Node.js environment.
|
||||
* These variables (TLS_CERT, TLS_KEY) are read exclusively by the Go server.
|
||||
* The Node.js console uses SSL_CERT_PATH / SSL_KEY_PATH instead.
|
||||
* Silently ignoring them causes issue #104 — port 21121 stays HTTP while the
|
||||
* RustDesk client expects HTTPS, producing InvalidContentType errors.
|
||||
*/
|
||||
function warnGoTlsEnvVars() {
|
||||
const hasTlsCert = !!process.env.TLS_CERT;
|
||||
const hasTlsKey = !!process.env.TLS_KEY;
|
||||
if (!hasTlsCert && !hasTlsKey) return;
|
||||
|
||||
const hasSslCertPath = !!process.env.SSL_CERT_PATH;
|
||||
const hasSslKeyPath = !!process.env.SSL_KEY_PATH;
|
||||
|
||||
if (hasTlsCert || hasTlsKey) {
|
||||
console.warn('');
|
||||
console.warn(' ┌─────────────────────────────────────────────────────┐');
|
||||
console.warn(' │ ⚠ MISCONFIGURATION WARNING — TLS / SSL │');
|
||||
console.warn(' ├─────────────────────────────────────────────────────┤');
|
||||
console.warn(' │ TLS_CERT / TLS_KEY are Go server environment │');
|
||||
console.warn(' │ variables and are IGNORED by this Node.js console. │');
|
||||
console.warn(' │ │');
|
||||
console.warn(' │ To enable HTTPS on this console set: │');
|
||||
console.warn(' │ SSL_CERT_PATH=/path/to/fullchain.pem │');
|
||||
console.warn(' │ SSL_KEY_PATH=/path/to/privkey.pem │');
|
||||
console.warn(' │ │');
|
||||
if (!hasSslCertPath && !hasSslKeyPath) {
|
||||
console.warn(' │ ❌ SSL_CERT_PATH and SSL_KEY_PATH are NOT set. │');
|
||||
console.warn(' │ Port 21121 (RustDesk Client API) is HTTP. │');
|
||||
console.warn(' │ Clients connecting via HTTPS will fail with │');
|
||||
console.warn(' │ InvalidContentType errors. │');
|
||||
} else {
|
||||
console.warn(' │ ✅ SSL_CERT_PATH / SSL_KEY_PATH are set — OK. │');
|
||||
}
|
||||
console.warn(' └─────────────────────────────────────────────────────┘');
|
||||
console.warn('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load SSL certificates for HTTPS
|
||||
*/
|
||||
@@ -312,6 +352,9 @@ function createHttpRedirectServer() {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// Warn early about common TLS misconfiguration (Go env vars used instead of Node.js vars)
|
||||
warnGoTlsEnvVars();
|
||||
|
||||
try {
|
||||
// Initialize database adapter (creates tables, runs migrations)
|
||||
await db.init();
|
||||
|
||||
@@ -432,7 +432,9 @@ function normalisePeer(peer) {
|
||||
status_tier: peer.live_status || (peer.live_online ? 'online' : 'offline'),
|
||||
uuid: peer.uuid || '',
|
||||
nat_type: peer.nat_type || 0,
|
||||
disabled: !!(peer.disabled || peer.soft_deleted)
|
||||
disabled: !!(peer.disabled || peer.soft_deleted),
|
||||
device_type: peer.device_type || '',
|
||||
cdap_connected: !!peer.cdap_connected
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,18 @@ function createCdapMediaProxy(server, sessionMiddleware, opts) {
|
||||
`^\\/api\\/cdap\\/devices\\/([A-Za-z0-9_-]{1,64})\\/${channel}$`
|
||||
);
|
||||
|
||||
const roleLevel = { admin: 3, operator: 2, viewer: 1 };
|
||||
// Role levels — keep in sync with middleware/auth.js DEFAULT_ROLE_PERMISSIONS
|
||||
// super_admin and admin (legacy alias) are the highest. global_admin and
|
||||
// server_admin sit just below (parallel branches). operator/viewer/pro below.
|
||||
const roleLevel = {
|
||||
super_admin: 5,
|
||||
admin: 5,
|
||||
global_admin: 4,
|
||||
server_admin: 4,
|
||||
operator: 2,
|
||||
viewer: 1,
|
||||
pro: 1
|
||||
};
|
||||
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
|
||||
@@ -39,19 +50,33 @@ function createCdapMediaProxy(server, sessionMiddleware, opts) {
|
||||
|
||||
sessionMiddleware(req, {}, () => {
|
||||
if (!req.session || !req.session.userId) {
|
||||
console.warn(`[CDAP ${label}] 401 upgrade rejected for ${url.pathname} (no session; ip=${req.socket?.remoteAddress})`);
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const userLevel = roleLevel[req.session.role] || 0;
|
||||
// Session stores the user under req.session.user; older code paths
|
||||
// also write flat fields. Accept either shape.
|
||||
const sessUser = req.session.user || {};
|
||||
const userRole = sessUser.role || req.session.role || '';
|
||||
const userName = sessUser.username || req.session.username || `user#${req.session.userId}`;
|
||||
|
||||
const userLevel = roleLevel[userRole] || 0;
|
||||
const requiredLevel = roleLevel[minRole] || 3;
|
||||
if (userLevel < requiredLevel) {
|
||||
console.warn(`[CDAP ${label}] 403 upgrade rejected for ${url.pathname} (user=${userName} role=${userRole} level=${userLevel} < required=${requiredLevel})`);
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[CDAP ${label}] Upgrade accepted for device=${deviceId} user=${userName} role=${userRole}`);
|
||||
|
||||
// Attach normalized fields so the connection handler can use them.
|
||||
req._cdapUserName = userName;
|
||||
req._cdapUserRole = userRole;
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req, deviceId);
|
||||
});
|
||||
@@ -59,8 +84,9 @@ function createCdapMediaProxy(server, sessionMiddleware, opts) {
|
||||
});
|
||||
|
||||
wss.on('connection', (browserWs, req, deviceId) => {
|
||||
const username = req.session?.username || 'admin';
|
||||
console.log(`[CDAP ${label}] Proxy started for device ${deviceId} by ${username}`);
|
||||
const username = req._cdapUserName || req.session?.user?.username || req.session?.username || 'admin';
|
||||
const role = req._cdapUserRole || req.session?.user?.role || req.session?.role || 'admin';
|
||||
console.log(`[CDAP ${label}] Proxy started for device ${deviceId} by ${username} (role=${role})`);
|
||||
|
||||
const goApiBase = config.betterdeskApiUrl || 'http://localhost:21114/api';
|
||||
const goWsUrl = goApiBase
|
||||
@@ -72,24 +98,44 @@ function createCdapMediaProxy(server, sessionMiddleware, opts) {
|
||||
headers: {
|
||||
'X-API-Key': config.betterdeskApiKey || '',
|
||||
'X-Username': username,
|
||||
'X-Role': req.session?.role || 'admin'
|
||||
'X-Role': role
|
||||
},
|
||||
rejectUnauthorized: !config.allowSelfSignedCerts
|
||||
});
|
||||
|
||||
let goConnected = false;
|
||||
// Buffer messages from browser that arrive before the upstream
|
||||
// Go WS connection is open. The browser sends an "init" frame
|
||||
// immediately on ws.onopen — without buffering, that message is
|
||||
// silently dropped and the Go server never replies with "ready",
|
||||
// so the UI hangs at "Connecting...".
|
||||
const pendingBrowserMsgs = [];
|
||||
|
||||
goWs.on('open', () => { goConnected = true; });
|
||||
|
||||
browserWs.on('message', (data) => {
|
||||
if (goConnected && goWs.readyState === WebSocket.OPEN) {
|
||||
goWs.send(data);
|
||||
goWs.on('open', () => {
|
||||
goConnected = true;
|
||||
while (pendingBrowserMsgs.length > 0) {
|
||||
const { data, binary } = pendingBrowserMsgs.shift();
|
||||
try { goWs.send(data, { binary }); } catch (_) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
goWs.on('message', (data) => {
|
||||
browserWs.on('message', (data, isBinary) => {
|
||||
if (goConnected && goWs.readyState === WebSocket.OPEN) {
|
||||
goWs.send(data, { binary: isBinary });
|
||||
} else if (goWs.readyState === WebSocket.CONNECTING) {
|
||||
pendingBrowserMsgs.push({ data, binary: isBinary });
|
||||
}
|
||||
});
|
||||
|
||||
// CRITICAL: forward isBinary flag. Without it, `ws` defaults to
|
||||
// sending Buffer payloads as BINARY frames, but the Go server emits
|
||||
// JSON text frames (e.g. {"type":"ready"}, {"type":"frame"}).
|
||||
// The browser would receive Blobs that JSON.parse cannot handle,
|
||||
// so the "ready" handshake never fires and the overlay stays at
|
||||
// "Connecting..." while frames silently arrive as garbled binary.
|
||||
goWs.on('message', (data, isBinary) => {
|
||||
if (browserWs.readyState === WebSocket.OPEN) {
|
||||
browserWs.send(data);
|
||||
browserWs.send(data, { binary: isBinary });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -36,13 +36,23 @@ function initCdapTerminalProxy(server, sessionMiddleware) {
|
||||
return;
|
||||
}
|
||||
|
||||
// RBAC: only admin users can access terminal
|
||||
if (req.session.role !== 'admin') {
|
||||
// Session may store user under req.session.user (object) or as
|
||||
// flat fields. Accept either; treat super_admin/admin as admin.
|
||||
const sessUser = req.session.user || {};
|
||||
const userRole = sessUser.role || req.session.role || '';
|
||||
const userName = sessUser.username || req.session.username || `user#${req.session.userId}`;
|
||||
|
||||
// RBAC: only admin / super_admin users can access terminal
|
||||
if (userRole !== 'admin' && userRole !== 'super_admin') {
|
||||
console.warn(`[CDAP Terminal] 403 upgrade rejected (user=${userName} role=${userRole})`);
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
req._cdapUserName = userName;
|
||||
req._cdapUserRole = userRole;
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req, deviceId);
|
||||
});
|
||||
@@ -50,7 +60,8 @@ function initCdapTerminalProxy(server, sessionMiddleware) {
|
||||
});
|
||||
|
||||
wss.on('connection', (browserWs, req, deviceId) => {
|
||||
const username = req.session?.username || 'admin';
|
||||
const username = req._cdapUserName || req.session?.user?.username || 'admin';
|
||||
const role = req._cdapUserRole || req.session?.user?.role || 'admin';
|
||||
console.log(`[CDAP Terminal] Proxy session started for device ${deviceId} by ${username}`);
|
||||
|
||||
// Build Go server WebSocket URL
|
||||
@@ -65,22 +76,30 @@ function initCdapTerminalProxy(server, sessionMiddleware) {
|
||||
headers: {
|
||||
'X-API-Key': config.betterdeskApiKey || '',
|
||||
'X-Username': username,
|
||||
'X-Role': req.session?.role || 'admin'
|
||||
'X-Role': role
|
||||
},
|
||||
// Allow self-signed certs for local Go server
|
||||
rejectUnauthorized: !config.allowSelfSignedCerts
|
||||
});
|
||||
|
||||
let goConnected = false;
|
||||
// Buffer messages that arrive before upstream is open (race fix).
|
||||
const pendingBrowserMsgs = [];
|
||||
|
||||
goWs.on('open', () => {
|
||||
goConnected = true;
|
||||
while (pendingBrowserMsgs.length > 0) {
|
||||
const buffered = pendingBrowserMsgs.shift();
|
||||
try { goWs.send(buffered); } catch (_) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Relay: Browser → Go
|
||||
browserWs.on('message', (data) => {
|
||||
if (goConnected && goWs.readyState === WebSocket.OPEN) {
|
||||
goWs.send(data);
|
||||
} else if (goWs.readyState === WebSocket.CONNECTING) {
|
||||
pendingBrowserMsgs.push(data);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ function initRemoteRelay(server, sessionMiddleware) {
|
||||
if (agentMatch) {
|
||||
const deviceId = decodeURIComponent(agentMatch[1]);
|
||||
// Validate device ID format (reject path traversal etc.)
|
||||
if (!/^[A-Za-z0-9_-]{3,32}$/.test(deviceId)) {
|
||||
if (!/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
|
||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
|
||||
@@ -674,17 +674,48 @@ function deployServerBinary(builtBinaryPath, targetPath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Copy new binary to service path
|
||||
// Atomic replace: write to staging file in same dir, then rename over the
|
||||
// target. On Linux, rename(2) replaces a running executable's directory
|
||||
// entry without touching the existing inode, so a live server keeps
|
||||
// running on the old image while the new one becomes available for the
|
||||
// next start (this avoids ETXTBSY which copyFileSync hits when the
|
||||
// target is busy). On Windows the running .exe is locked, so we first
|
||||
// rename the target out of the way, then move the new one in.
|
||||
const stagingPath = targetPath + '.new.' + process.pid + '.' + Date.now();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.copyFileSync(builtBinaryPath, targetPath);
|
||||
fs.copyFileSync(builtBinaryPath, stagingPath);
|
||||
if (!IS_WINDOWS) {
|
||||
try { fs.chmodSync(targetPath, 0o755); } catch (_e) { /* ok */ }
|
||||
try { fs.chmodSync(stagingPath, 0o755); } catch (_e) { /* ok */ }
|
||||
}
|
||||
|
||||
if (IS_WINDOWS && fs.existsSync(targetPath)) {
|
||||
const lockedAside = targetPath + '.old.' + Date.now();
|
||||
try { fs.renameSync(targetPath, lockedAside); } catch (_e) { /* may fail if not locked */ }
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(stagingPath, targetPath);
|
||||
} catch (renameErr) {
|
||||
// Cross-device rename or other rename failure — fall back to copy
|
||||
// (still wraps the ETXTBSY case for non-Linux platforms or when
|
||||
// staging dir is on a different filesystem).
|
||||
try {
|
||||
fs.copyFileSync(stagingPath, targetPath);
|
||||
try { fs.unlinkSync(stagingPath); } catch (_e) { /* ok */ }
|
||||
if (!IS_WINDOWS) {
|
||||
try { fs.chmodSync(targetPath, 0o755); } catch (_e) { /* ok */ }
|
||||
}
|
||||
} catch (copyErr) {
|
||||
throw renameErr.code === 'ETXTBSY' ? renameErr : copyErr;
|
||||
}
|
||||
}
|
||||
return { success: true, backupPath };
|
||||
} catch (err) {
|
||||
// Cleanup staging if it survived
|
||||
try { if (fs.existsSync(stagingPath)) fs.unlinkSync(stagingPath); } catch (_e) { /* ok */ }
|
||||
// Attempt to restore backup on failure
|
||||
if (backupPath && fs.existsSync(backupPath)) {
|
||||
if (backupPath && fs.existsSync(backupPath) && !fs.existsSync(targetPath)) {
|
||||
try { fs.copyFileSync(backupPath, targetPath); } catch (_e) { /* critical */ }
|
||||
}
|
||||
return { success: false, error: `Deploy failed: ${err.message}` };
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
<link rel="stylesheet" href="/css/remote.css?v=<%= cacheVersion %>">
|
||||
</head>
|
||||
<body class="viewer-body">
|
||||
<%- body %>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script nonce="<%= cspNonce %>">
|
||||
// Global config for viewer
|
||||
@@ -33,6 +31,8 @@
|
||||
branding: <%- JSON.stringify(branding || {}) %>
|
||||
};
|
||||
</script>
|
||||
|
||||
<%- body %>
|
||||
|
||||
<!-- Translation helper -->
|
||||
<script src="/js/i18n-client.js?v=<%= cacheVersion %>"></script>
|
||||
@@ -53,6 +53,8 @@
|
||||
<script src="/js/rdclient/input.js?v=<%= cacheVersion %>"></script>
|
||||
<script src="/js/rdclient/filetransfer.js?v=<%= cacheVersion %>"></script>
|
||||
<script src="/js/rdclient/client.js?v=<%= cacheVersion %>"></script>
|
||||
<!-- CDAP transport adapter (RDClient-compatible surface for OS-agent devices) -->
|
||||
<script src="/js/rdclient/cdap-adapter.js?v=<%= cacheVersion %>"></script>
|
||||
|
||||
<!-- Page-specific scripts -->
|
||||
<% if (typeof pageScripts !== 'undefined' && pageScripts) { %>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<%- include('layouts/viewer', {
|
||||
title: deviceId,
|
||||
pageScripts: ['cdap-desktop', 'cdap-audio'],
|
||||
body: `
|
||||
<style>
|
||||
.bd-remote-wrap { position: fixed; inset: 0; display: flex; flex-direction: column; background: radial-gradient(circle at top, #18243a 0%, #0d1117 42%, #070b12 100%); color: #e6edf3; }
|
||||
.bd-remote-bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,.08); background: rgba(10,16,27,.8); backdrop-filter: blur(16px); }
|
||||
.bd-remote-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.bd-remote-title .material-icons { color: #58a6ff; }
|
||||
.bd-remote-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bd-remote-id { color: #8b949e; font-size: 12px; }
|
||||
.bd-remote-pill { padding: 4px 10px; border-radius: 999px; background: rgba(88,166,255,.16); color: #c9d1d9; font-size: 12px; }
|
||||
.bd-remote-grow { flex: 1; }
|
||||
.bd-remote-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.bd-remote-actions button,
|
||||
.bd-remote-actions a { display: inline-flex; align-items: center; gap: 6px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); color: #e6edf3; border-radius: 10px; padding: 8px 12px; font-size: 13px; text-decoration: none; cursor: pointer; }
|
||||
.bd-remote-actions button:hover,
|
||||
.bd-remote-actions a:hover { background: rgba(255,255,255,.12); }
|
||||
.bd-remote-actions button.recording { background: rgba(248,81,73,.18); border-color: rgba(248,81,73,.45); color: #ffa198; }
|
||||
.bd-remote-actions button.active { background: rgba(88,166,255,.22); border-color: rgba(88,166,255,.55); color: #79b8ff; }
|
||||
.bd-remote-stage { flex: 1; padding: 12px; min-height: 0; }
|
||||
.bd-remote-widget { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; border-radius: 18px; overflow: hidden; background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.08); box-shadow: 0 28px 80px rgba(0,0,0,.45); }
|
||||
.bd-remote-widget .cdap-desktop-toolbar { display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 0 16px; background: rgba(7,11,18,.92); border-bottom: 1px solid rgba(255,255,255,.06); }
|
||||
.bd-remote-widget .cdap-desktop-canvas-wrap { position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; background: #020409; }
|
||||
.bd-remote-widget .cdap-desktop-canvas { max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain; outline: none; cursor: default; }
|
||||
.bd-remote-widget .cdap-desktop-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: linear-gradient(180deg, rgba(2,4,9,.45), rgba(2,4,9,.82)); color: #c9d1d9; text-align: center; }
|
||||
.bd-remote-widget .cdap-desktop-overlay .material-icons { font-size: 44px; color: #58a6ff; }
|
||||
.bd-remote-widget .cdap-desktop-connect { position: absolute; right: 20px; bottom: 20px; z-index: 20; }
|
||||
.bd-remote-widget .cdap-desktop-connect.hidden,
|
||||
.bd-remote-widget .cdap-desktop-overlay.hidden,
|
||||
.bd-remote-widget .cdap-desktop-clipboard-indicator.hidden,
|
||||
.bd-remote-widget .hidden { display: none !important; }
|
||||
.bd-remote-widget .cdap-desktop-clipboard-indicator { margin-left: auto; font-size: 12px; padding: 4px 10px; border-radius: 999px; background: rgba(88,166,255,.14); color: #c9d1d9; }
|
||||
.bd-remote-widget .cdap-monitor-selector { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; color: #c9d1d9; }
|
||||
.bd-remote-widget .cdap-monitor-selector select { background: rgba(255,255,255,.06); color: #e6edf3; border: 1px solid rgba(255,255,255,.12); border-radius: 8px; padding: 6px 10px; }
|
||||
.bd-remote-hint { font-size: 12px; color: #8b949e; }
|
||||
@media (max-width: 900px) {
|
||||
.bd-remote-bar { flex-wrap: wrap; }
|
||||
.bd-remote-grow { display: none; }
|
||||
.bd-remote-actions { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="bd-remote-wrap">
|
||||
<div class="bd-remote-bar">
|
||||
<div class="bd-remote-title">
|
||||
<span class="material-icons">desktop_windows</span>
|
||||
<div>
|
||||
<div class="bd-remote-name">${deviceName || deviceId}</div>
|
||||
<div class="bd-remote-id">${deviceId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="bd-remote-pill">CDAP Desktop</span>
|
||||
<span class="bd-remote-hint">Click inside the canvas to capture keyboard and mouse input.</span>
|
||||
<span class="bd-remote-grow"></span>
|
||||
<div class="bd-remote-actions">
|
||||
<button type="button" id="bd-remote-paste" title="Paste clipboard text into the remote session"><span class="material-icons">content_paste</span>Paste</button>
|
||||
<button type="button" id="bd-remote-audio" title="Toggle remote audio"><span class="material-icons">volume_up</span>Audio</button>
|
||||
<button type="button" id="bd-remote-record" title="Record session to WebM"><span class="material-icons">fiber_manual_record</span>Record</button>
|
||||
<button type="button" id="bd-remote-fullscreen" title="Fullscreen + capture system shortcuts"><span class="material-icons">fullscreen</span>Fullscreen</button>
|
||||
<button type="button" id="bd-remote-reconnect"><span class="material-icons">refresh</span>Reconnect</button>
|
||||
<a href="/devices"><span class="material-icons">arrow_back</span>Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bd-remote-stage">
|
||||
<div class="cdap-desktop-widget bd-remote-widget" id="wval-remote-full">
|
||||
<div class="cdap-desktop-toolbar">
|
||||
<span class="cdap-desktop-clipboard-indicator hidden"></span>
|
||||
<span class="bd-remote-hint">BetterDesk native desktop session over CDAP WebSocket.</span>
|
||||
</div>
|
||||
<div class="cdap-desktop-canvas-wrap">
|
||||
<canvas class="cdap-desktop-canvas" width="1280" height="720"></canvas>
|
||||
<div class="cdap-desktop-overlay">
|
||||
<span class="material-icons">hourglass_top</span>
|
||||
<span>Waiting for desktop session...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cdap-desktop-connect">
|
||||
<button class="btn cdap-desktop-connect-btn" data-widget="remote-full" type="button">
|
||||
<span class="material-icons">play_arrow</span>
|
||||
Connect desktop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden audio widget element so cdap-audio.js can mount its level
|
||||
meter / status without rendering a visible UI; controlled from
|
||||
the toolbar Audio button. -->
|
||||
<div class="cdap-audio-widget" id="wval-remote-audio" style="display:none" aria-hidden="true">
|
||||
<div class="cdap-audio-status"></div>
|
||||
<div class="cdap-audio-level"><div class="cdap-audio-level-fill"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="${cspNonce}">
|
||||
(function () {
|
||||
const deviceId = ${JSON.stringify(deviceId)};
|
||||
const widgetId = 'remote-full';
|
||||
|
||||
function openDesktopSession() {
|
||||
const connectWrap = document.querySelector('#wval-remote-full .cdap-desktop-connect');
|
||||
connectWrap?.classList.add('hidden');
|
||||
if (window.CDAPDesktop) {
|
||||
window.CDAPDesktop.close(deviceId, widgetId);
|
||||
window.CDAPDesktop.open(deviceId, widgetId);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelector('.cdap-desktop-connect-btn')?.addEventListener('click', openDesktopSession);
|
||||
document.getElementById('bd-remote-reconnect')?.addEventListener('click', openDesktopSession);
|
||||
|
||||
// Fullscreen + Keyboard Lock (Phase 3.3) ----------------------------
|
||||
const fsBtn = document.getElementById('bd-remote-fullscreen');
|
||||
const fsIcon = fsBtn?.querySelector('.material-icons');
|
||||
fsBtn?.addEventListener('click', async () => {
|
||||
const fs = await window.CDAPDesktop?.toggleFullscreen(deviceId, widgetId);
|
||||
if (fsIcon) fsIcon.textContent = fs ? 'fullscreen_exit' : 'fullscreen';
|
||||
});
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (!fsIcon) return;
|
||||
fsIcon.textContent = window.CDAPDesktop?.isFullscreen(deviceId, widgetId) ? 'fullscreen_exit' : 'fullscreen';
|
||||
});
|
||||
|
||||
// Session recording (Phase 3.4) -------------------------------------
|
||||
const recBtn = document.getElementById('bd-remote-record');
|
||||
const recIcon = recBtn?.querySelector('.material-icons');
|
||||
recBtn?.addEventListener('click', async () => {
|
||||
if (window.CDAPDesktop?.isRecording(deviceId, widgetId)) {
|
||||
await window.CDAPDesktop.downloadRecording(deviceId, widgetId);
|
||||
if (recIcon) recIcon.textContent = 'fiber_manual_record';
|
||||
recBtn.classList.remove('recording');
|
||||
} else {
|
||||
const ok = window.CDAPDesktop?.startRecording(deviceId, widgetId);
|
||||
if (ok) {
|
||||
if (recIcon) recIcon.textContent = 'stop_circle';
|
||||
recBtn.classList.add('recording');
|
||||
} else {
|
||||
alert('Recording is not supported in this browser, or the session is not connected yet.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remote audio toggle (Phase 3.8) -----------------------------------
|
||||
const audioBtn = document.getElementById('bd-remote-audio');
|
||||
const audioIcon = audioBtn?.querySelector('.material-icons');
|
||||
const audioWidgetId = 'remote-audio';
|
||||
let audioActive = false;
|
||||
audioBtn?.addEventListener('click', () => {
|
||||
if (!window.CDAPAudio) {
|
||||
console.warn('[remote-cdap] CDAPAudio module unavailable');
|
||||
return;
|
||||
}
|
||||
if (audioActive) {
|
||||
window.CDAPAudio.close(deviceId, audioWidgetId);
|
||||
audioActive = false;
|
||||
if (audioIcon) audioIcon.textContent = 'volume_up';
|
||||
audioBtn.classList.remove('active');
|
||||
} else {
|
||||
window.CDAPAudio.open(deviceId, audioWidgetId, { direction: 'receive' });
|
||||
audioActive = true;
|
||||
if (audioIcon) audioIcon.textContent = 'volume_off';
|
||||
audioBtn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Virtual paste (Phase 3.2) ---------------------------------------
|
||||
// Reads the operator clipboard via navigator.clipboard.readText()
|
||||
// and types it into the remote session as a single text input
|
||||
// event. Useful when the device side refuses incoming clipboard
|
||||
// sync, or when the operator wants to paste from outside the page.
|
||||
document.getElementById('bd-remote-paste')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const ok = await window.CDAPDesktop?.pasteFromClipboard(deviceId, widgetId);
|
||||
if (!ok) {
|
||||
alert('Could not read the clipboard. Grant clipboard permission to this site or focus the canvas first.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[remote-cdap] paste failed:', err && err.message);
|
||||
}
|
||||
});
|
||||
|
||||
openDesktopSession();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
`
|
||||
}) %>
|
||||
@@ -196,6 +196,10 @@
|
||||
<span class="material-icons">refresh</span>
|
||||
${_('remote.reconnect')}
|
||||
</button>
|
||||
<button class="btn btn-secondary session-btn-cdap-fallback" style="display:none;margin-top:8px;" title="${_('remote.cdap_fallback_hint')}">
|
||||
<span class="material-icons">photo_camera</span>
|
||||
${_('remote.use_cdap_fallback')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,6 +301,7 @@
|
||||
<script nonce="${cspNonce}">
|
||||
window.__initialDeviceId = ` + JSON.stringify(deviceId) + `;
|
||||
window.__initialDeviceName = ` + JSON.stringify(device && device.hostname ? device.hostname : '') + `;
|
||||
window.__capabilities = ` + JSON.stringify(capabilities || { transport: 'rd' }) + `;
|
||||
</script>
|
||||
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user