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:
UNITRONIX
2026-04-26 01:23:18 +02:00
parent 323e2f1398
commit 4eed88673c
86 changed files with 13558 additions and 377 deletions
+17 -1
View File
@@ -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*
+8 -1
View File
@@ -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 devices 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": "確定"
}
}
+11 -1
View File
@@ -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
+98 -1
View File
@@ -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() {
+105 -29
View File
@@ -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"
}
}
}
+275 -80
View File
@@ -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;
+1 -1
View File
@@ -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 devices screen",
+1 -1
View File
@@ -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",
+4
View File
@@ -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!);
+18 -1
View File
@@ -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
View File
@@ -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:
}
}
}
}
+4
View File
@@ -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
+574
View File
@@ -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 0100.
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 0100 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 13 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])
// SOF0SOF15 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
}
+53
View File
@@ -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
}
+442
View File
@@ -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
+10
View File
@@ -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
}
+32
View File
@@ -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),
}}
}
+45
View File
@@ -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)
}
}
+183
View File
@@ -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
}
+328
View File
@@ -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
}
+327
View File
@@ -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 065535 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
}
}
+18 -6
View File
@@ -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
+66
View File
@@ -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."
}
+180
View File
@@ -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."
}
+59 -12
View File
@@ -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")
}
+2 -1
View File
@@ -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
)
+4
View File
@@ -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 == "" {
+154 -33
View File
@@ -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")
+62 -10
View File
@@ -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 13 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)
+19 -1
View File
@@ -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)
+29
View File
@@ -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)
+16
View File
@@ -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("========================================")
+231
View File
@@ -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).*
+488
View File
@@ -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).*
+88
View File
@@ -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 | 12 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>
+253
View File
@@ -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: F13F24, 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.*
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"vitest": "^3.2.4"
}
}
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+401 -8
View File
@@ -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
};
})();
+8
View File
@@ -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;
})();
+9 -1
View File
@@ -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');
}
+75 -5
View File
@@ -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 37 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();
})();
+18 -5
View File
@@ -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(); } },
+7 -1
View File
@@ -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();
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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();
}
+134
View File
@@ -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' }
+1 -1
View File
@@ -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();
}
+72 -6
View File
@@ -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');
+1 -1
View File
@@ -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();
}
+43
View File
@@ -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();
+3 -1
View File
@@ -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
};
}
+58 -12
View File
@@ -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 });
}
});
+23 -4
View File
@@ -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);
}
});
+1 -1
View File
@@ -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;
+35 -4
View File
@@ -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}` };
+4 -2
View File
@@ -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) { %>
+189
View File
@@ -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>
`
}) %>
+5
View File
@@ -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>
`