diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cd26bade..bb5d560e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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` 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 `. `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* diff --git a/betterdesk-agent-client/package.json b/betterdesk-agent-client/package.json index 2aa252c0..b8cc16bd 100644 --- a/betterdesk-agent-client/package.json +++ b/betterdesk-agent-client/package.json @@ -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" } diff --git a/betterdesk-agent-client/public/locales/en.json b/betterdesk-agent-client/public/locales/en.json new file mode 100644 index 00000000..8236d7ad --- /dev/null +++ b/betterdesk-agent-client/public/locales/en.json @@ -0,0 +1,177 @@ +{ + "sidebar": { + "status": "Status", + "chat": "Chat", + "help": "Help", + "settings": "Settings", + "more": "More" + }, + "app": { + "close_agent": "Close agent", + "close_confirm": "Are you sure you want to close the agent? It will no longer be available for remote assistance.", + "quit_title": "Quit BetterDesk Agent", + "quit_sudo_hint": "Enter your user password to close the agent.", + "admin_only": "Administrator only", + "admin_only_hint": "This section is restricted to administrators. Run the agent with administrator privileges to access it." + }, + "auth": { + "title": "Authentication Required", + "subtitle": "Enter your user password to access this section.", + "password": "Password", + "password_placeholder": "Enter your password", + "submit": "Authenticate", + "cancel": "Cancel", + "verifying": "Verifying\u2026", + "wrong_password": "Incorrect password. Please try again.", + "enter_password_error": "Password cannot be empty.", + "sudo_error": "Authentication service is not available on this system." + }, + "admin_required": { + "title": "Administrator rights required", + "message": "This section can only be accessed when the agent runs with local administrator privileges.", + "hint": "Ask your IT administrator to launch the agent as Administrator, or right-click the installer and choose \"Run as administrator\"." + }, + "status": { + "title": "Connection Status", + "connected": "Connected", + "disconnected": "Disconnected", + "connecting": "Connecting…", + "registering": "Registering…", + "server": "Server", + "device_id": "Device ID", + "hostname": "Hostname", + "platform": "Platform", + "version": "Version", + "uptime": "Session Uptime", + "last_sync": "Last Sync", + "copy_id": "Copy ID", + "id_copied": "ID copied to clipboard", + "reconnect": "Reconnect", + "send_diagnostics": "Send Diagnostics", + "diagnostics_sent": "Diagnostics sent to server", + "diagnostics_error": "Failed to send diagnostics", + "sidecar_title": "CDAP Agent", + "sidecar_running": "Running", + "sidecar_stopped": "Stopped", + "sidecar_not_configured": "Not configured", + "sidecar_restarts_hint": "Number of automatic restarts", + "sidecar_start": "Start", + "sidecar_stop": "Stop", + "sidecar_restart": "Restart" + }, + "setup": { + "title": "Server Setup", + "subtitle": "Connect this device to a BetterDesk server", + "server_address": "Server address", + "server_placeholder": "e.g. betterdesk.example.com or 192.168.1.100", + "next": "Next", + "back": "Back", + "finish": "Finish", + "step_address": "Server Address", + "step_validate": "Validation", + "step_register": "Registration", + "step_sync": "Synchronization", + "step_complete": "Complete", + "validating": "Validating server…", + "checking_availability": "Checking server availability", + "checking_protocol": "Verifying protocol compatibility", + "checking_registration": "Checking registration capability", + "checking_certificate": "Validating certificate trust", + "validation_ok": "Server validated successfully", + "validation_failed": "Server validation failed", + "registering": "Registering device…", + "register_ok": "Device registered successfully", + "register_failed": "Registration failed", + "syncing": "Synchronizing configuration…", + "sync_ok": "Configuration synchronized", + "sync_failed": "Synchronization failed", + "complete_title": "Setup Complete", + "complete_message": "This device is now connected to the BetterDesk server and ready for remote management.", + "error_empty": "Server address is required", + "error_format": "Invalid server address format", + "error_unreachable": "Server is not reachable", + "error_protocol": "Protocol version mismatch", + "error_registration": "Server does not accept registrations", + "error_certificate": "Certificate validation failed" + }, + "chat": { + "title": "Chat", + "placeholder": "Type a message…", + "send": "Send", + "no_messages": "No messages yet", + "operator": "Operator", + "you": "You", + "new_message": "New message from operator", + "connection_required": "Connection to server required for chat" + }, + "help": { + "title": "Request Help", + "description": "Send a help request to an available operator. An operator will connect to assist you.", + "message_label": "Describe your issue (optional)", + "message_placeholder": "What do you need help with?", + "send": "Request Help", + "sending": "Sending…", + "sent_title": "Help Requested", + "sent_message": "An operator has been notified. They will connect shortly.", + "cancel": "Cancel Request", + "active_title": "Help Session Active", + "active_message": "An operator is currently connected to your device.", + "end_session": "End Session", + "error": "Failed to send help request" + }, + "settings": { + "title": "Settings", + "section_connection": "Connection", + "server_address": "Server address", + "test_connection": "Test Connection", + "connection_ok": "Connection successful", + "connection_failed": "Connection failed", + "section_cdap": "CDAP Agent", + "api_key": "API Key", + "api_key_placeholder": "Paste API key from server", + "api_key_hint": "Create an API key in the web console under Server → API Keys.", + "cdap_port": "CDAP port", + "auto_start_sidecar": "Auto-start CDAP agent", + "auto_start_sidecar_hint": "Start the Go CDAP agent automatically with the app", + "section_privacy": "Privacy & Capabilities", + "allow_screen_capture": "Allow screen capture", + "allow_screen_capture_hint": "Operators can view and control this device’s screen", + "require_consent": "Require consent for each session", + "require_consent_hint": "A dialog will appear asking for approval before each remote session", + "allow_terminal": "Allow terminal access", + "allow_terminal_hint": "Operators can open a remote shell on this device", + "allow_file_browser": "Allow file browser", + "allow_file_browser_hint": "Operators can browse and transfer files", + "allow_clipboard": "Allow clipboard sync", + "allow_clipboard_hint": "Clipboard content is synced with the operator", + "section_general": "General", + "language": "Language", + "start_with_system": "Start with system", + "start_minimized": "Start minimized to tray", + "section_about": "About", + "app_version": "Agent version", + "restart_service": "Restart Service", + "service_restarted": "Service restarted", + "unregister": "Unregister Device", + "unregister_confirm": "Unregister this device from the server? You will need to set up the connection again.", + "unregister_ok": "Device unregistered" + }, + "common": { + "loading": "Loading…", + "error": "Error", + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "yes": "Yes", + "no": "No", + "ok": "OK" + }, + "consent": { + "title": "Screen Access Request", + "operator_suffix": "wants to view your screen.", + "hint": "The operator will be able to see your current screen but cannot control your computer unless you also grant input access.", + "auto_deny_in": "Auto-deny in", + "allow": "Allow", + "deny": "Deny" + } +} diff --git a/betterdesk-agent-client/public/locales/pl.json b/betterdesk-agent-client/public/locales/pl.json new file mode 100644 index 00000000..620e1ef4 --- /dev/null +++ b/betterdesk-agent-client/public/locales/pl.json @@ -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" + } +} diff --git a/betterdesk-agent-client/public/locales/zh-TW.json b/betterdesk-agent-client/public/locales/zh-TW.json new file mode 100644 index 00000000..ed952183 --- /dev/null +++ b/betterdesk-agent-client/public/locales/zh-TW.json @@ -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": "確定" + } +} diff --git a/betterdesk-agent-client/src-tauri/Cargo.toml b/betterdesk-agent-client/src-tauri/Cargo.toml index c3c7246b..5f8152c8 100644 --- a/betterdesk-agent-client/src-tauri/Cargo.toml +++ b/betterdesk-agent-client/src-tauri/Cargo.toml @@ -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 = [ diff --git a/betterdesk-agent-client/src-tauri/Info.plist b/betterdesk-agent-client/src-tauri/Info.plist new file mode 100644 index 00000000..c560d430 --- /dev/null +++ b/betterdesk-agent-client/src-tauri/Info.plist @@ -0,0 +1,12 @@ + + + + + NSScreenCaptureUsageDescription + BetterDesk Agent needs Screen Recording to allow remote desktop access by operators. + NSAccessibilityUsageDescription + BetterDesk Agent needs Accessibility access to inject keyboard and mouse input during remote sessions. + NSMicrophoneUsageDescription + BetterDesk Agent may capture microphone audio during remote sessions when explicitly requested by an operator. + + diff --git a/betterdesk-agent-client/src-tauri/betterdesk-agent-client.desktop b/betterdesk-agent-client/src-tauri/betterdesk-agent-client.desktop new file mode 100644 index 00000000..e5bde1fd --- /dev/null +++ b/betterdesk-agent-client/src-tauri/betterdesk-agent-client.desktop @@ -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 diff --git a/betterdesk-agent-client/src-tauri/binaries/betterdesk-agent-x86_64-unknown-linux-gnu b/betterdesk-agent-client/src-tauri/binaries/betterdesk-agent-x86_64-unknown-linux-gnu new file mode 100755 index 00000000..ce4ad31e Binary files /dev/null and b/betterdesk-agent-client/src-tauri/binaries/betterdesk-agent-x86_64-unknown-linux-gnu differ diff --git a/betterdesk-agent-client/src-tauri/build.rs b/betterdesk-agent-client/src-tauri/build.rs index d860e1e6..1b66e91c 100644 --- a/betterdesk-agent-client/src-tauri/build.rs +++ b/betterdesk-agent-client/src-tauri/build.rs @@ -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-[.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: /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() + ); } diff --git a/betterdesk-agent-client/src-tauri/capabilities/default.json b/betterdesk-agent-client/src-tauri/capabilities/default.json new file mode 100644 index 00000000..0965f453 --- /dev/null +++ b/betterdesk-agent-client/src-tauri/capabilities/default.json @@ -0,0 +1,8 @@ +{ + "identifier": "default", + "description": "Default BetterDesk Agent capabilities", + "windows": ["main"], + "permissions": [ + "core:default" + ] +} \ No newline at end of file diff --git a/betterdesk-agent-client/src-tauri/gen/schemas/capabilities.json b/betterdesk-agent-client/src-tauri/gen/schemas/capabilities.json index 9e26dfee..0d14618f 100644 --- a/betterdesk-agent-client/src-tauri/gen/schemas/capabilities.json +++ b/betterdesk-agent-client/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{} \ No newline at end of file +{"default":{"identifier":"default","description":"Default BetterDesk Agent capabilities","local":true,"windows":["main"],"permissions":["core:default"]}} \ No newline at end of file diff --git a/betterdesk-agent-client/src-tauri/gen/schemas/linux-schema.json b/betterdesk-agent-client/src-tauri/gen/schemas/linux-schema.json new file mode 100644 index 00000000..a2eaf61b --- /dev/null +++ b/betterdesk-agent-client/src-tauri/gen/schemas/linux-schema.json @@ -0,0 +1,2804 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "deny": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`", + "type": "string", + "const": "autostart:default", + "markdownDescription": "This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n\n#### This default permission set includes:\n\n- `allow-enable`\n- `allow-disable`\n- `allow-is-enabled`" + }, + { + "description": "Enables the disable command without any pre-configured scope.", + "type": "string", + "const": "autostart:allow-disable", + "markdownDescription": "Enables the disable command without any pre-configured scope." + }, + { + "description": "Enables the enable command without any pre-configured scope.", + "type": "string", + "const": "autostart:allow-enable", + "markdownDescription": "Enables the enable command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "autostart:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the disable command without any pre-configured scope.", + "type": "string", + "const": "autostart:deny-disable", + "markdownDescription": "Denies the disable command without any pre-configured scope." + }, + { + "description": "Denies the enable command without any pre-configured scope.", + "type": "string", + "const": "autostart:deny-enable", + "markdownDescription": "Denies the enable command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "autostart:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", + "type": "string", + "const": "notification:default", + "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" + }, + { + "description": "Enables the batch command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-batch", + "markdownDescription": "Enables the batch command without any pre-configured scope." + }, + { + "description": "Enables the cancel command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-cancel", + "markdownDescription": "Enables the cancel command without any pre-configured scope." + }, + { + "description": "Enables the check_permissions command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-check-permissions", + "markdownDescription": "Enables the check_permissions command without any pre-configured scope." + }, + { + "description": "Enables the create_channel command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-create-channel", + "markdownDescription": "Enables the create_channel command without any pre-configured scope." + }, + { + "description": "Enables the delete_channel command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-delete-channel", + "markdownDescription": "Enables the delete_channel command without any pre-configured scope." + }, + { + "description": "Enables the get_active command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-get-active", + "markdownDescription": "Enables the get_active command without any pre-configured scope." + }, + { + "description": "Enables the get_pending command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-get-pending", + "markdownDescription": "Enables the get_pending command without any pre-configured scope." + }, + { + "description": "Enables the is_permission_granted command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-is-permission-granted", + "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope." + }, + { + "description": "Enables the list_channels command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-list-channels", + "markdownDescription": "Enables the list_channels command without any pre-configured scope." + }, + { + "description": "Enables the notify command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-notify", + "markdownDescription": "Enables the notify command without any pre-configured scope." + }, + { + "description": "Enables the permission_state command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-permission-state", + "markdownDescription": "Enables the permission_state command without any pre-configured scope." + }, + { + "description": "Enables the register_action_types command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-register-action-types", + "markdownDescription": "Enables the register_action_types command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_active command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-remove-active", + "markdownDescription": "Enables the remove_active command without any pre-configured scope." + }, + { + "description": "Enables the request_permission command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-request-permission", + "markdownDescription": "Enables the request_permission command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "notification:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Denies the batch command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-batch", + "markdownDescription": "Denies the batch command without any pre-configured scope." + }, + { + "description": "Denies the cancel command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-cancel", + "markdownDescription": "Denies the cancel command without any pre-configured scope." + }, + { + "description": "Denies the check_permissions command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-check-permissions", + "markdownDescription": "Denies the check_permissions command without any pre-configured scope." + }, + { + "description": "Denies the create_channel command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-create-channel", + "markdownDescription": "Denies the create_channel command without any pre-configured scope." + }, + { + "description": "Denies the delete_channel command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-delete-channel", + "markdownDescription": "Denies the delete_channel command without any pre-configured scope." + }, + { + "description": "Denies the get_active command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-get-active", + "markdownDescription": "Denies the get_active command without any pre-configured scope." + }, + { + "description": "Denies the get_pending command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-get-pending", + "markdownDescription": "Denies the get_pending command without any pre-configured scope." + }, + { + "description": "Denies the is_permission_granted command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-is-permission-granted", + "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope." + }, + { + "description": "Denies the list_channels command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-list-channels", + "markdownDescription": "Denies the list_channels command without any pre-configured scope." + }, + { + "description": "Denies the notify command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-notify", + "markdownDescription": "Denies the notify command without any pre-configured scope." + }, + { + "description": "Denies the permission_state command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-permission-state", + "markdownDescription": "Denies the permission_state command without any pre-configured scope." + }, + { + "description": "Denies the register_action_types command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-register-action-types", + "markdownDescription": "Denies the register_action_types command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_active command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-remove-active", + "markdownDescription": "Denies the remove_active command without any pre-configured scope." + }, + { + "description": "Denies the request_permission command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-request-permission", + "markdownDescription": "Denies the request_permission command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "notification:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "ShellScopeEntryAllowedArg": { + "description": "A command argument allowed to be executed by the webview API.", + "anyOf": [ + { + "description": "A non-configurable argument that is passed to the command in the order it was specified.", + "type": "string" + }, + { + "description": "A variable that is set while calling the command from the webview API.", + "type": "object", + "required": [ + "validator" + ], + "properties": { + "raw": { + "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", + "default": false, + "type": "boolean" + }, + "validator": { + "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "ShellScopeEntryAllowedArgs": { + "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", + "anyOf": [ + { + "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", + "type": "boolean" + }, + { + "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/ShellScopeEntryAllowedArg" + } + } + ] + } + } +} \ No newline at end of file diff --git a/betterdesk-agent-client/src-tauri/src/autostart.rs b/betterdesk-agent-client/src-tauri/src/autostart.rs new file mode 100644 index 00000000..e6493b6f --- /dev/null +++ b/betterdesk-agent-client/src-tauri/src/autostart.rs @@ -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 + ), + } +} diff --git a/betterdesk-agent-client/src-tauri/src/bd_signal.rs b/betterdesk-agent-client/src-tauri/src/bd_signal.rs new file mode 100644 index 00000000..fab1bf59 --- /dev/null +++ b/betterdesk-agent-client/src-tauri/src/bd_signal.rs @@ -0,0 +1,1421 @@ +//! BetterDesk signal/introspection WebSocket client. +//! +//! Connects to the Node.js console at `/ws/bd-signal?device_id=X&token=Y` +//! and answers operator-initiated requests (`services.list`, `processes.list`, +//! `events.list`, `activity.get`, `files.browse`, `files.read`, +//! `screenshot.capture`, `terminal.execute`). +//! +//! The protocol is the same one consumed by `services/bdRelay.js`: +//! - Console sends: `{ type, request_id, payload }` +//! - Agent replies: `{ type: "command_response", request_id, ok, data?, error? }` +//! +//! Reconnect loop with exponential backoff (max 60s). + +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use futures_util::{SinkExt, StreamExt}; +use log::{debug, info, warn}; +use native_tls::TlsConnector as NativeTlsConnector; +use serde::Serialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use sysinfo::{ProcessesToUpdate, System}; +use tokio::time::sleep; +use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message as WsMessage}; + +use crate::commands::AgentState; +use crate::config::AgentConfig; + +/// Snapshot of the parts of `AgentConfig` the WS task needs. +#[derive(Clone, Debug)] +struct ConnSpec { + server_address: String, + device_id: String, + auth_token: String, + allow_terminal: bool, + allow_file_browser: bool, + allow_screen_capture: bool, + allow_clipboard: bool, +} + +impl ConnSpec { + fn from_config(cfg: &AgentConfig) -> Option { + if !cfg.is_registered() { + return None; + } + Some(Self { + server_address: cfg.server_address.clone(), + device_id: cfg.device_id.clone(), + auth_token: if cfg.auth_token.is_empty() { + cfg.device_id.clone() // fallback — current Node.js endpoint accepts any non-empty token + } else { + cfg.auth_token.clone() + }, + allow_terminal: cfg.allow_terminal, + allow_file_browser: cfg.allow_file_browser, + allow_screen_capture: cfg.allow_screen_capture, + allow_clipboard: cfg.allow_clipboard, + }) + } +} + +/// Convert `https://host:21114` → `wss://host:5000/ws/bd-signal?device_id=X&token=Y` +/// (`http://` → `ws://`). Falls back to `ws://host:5000/...` if parse fails. +#[allow(dead_code)] +fn build_ws_url(server_address: &str, device_id: &str, token: &str) -> String { + let addr = server_address.trim(); + let with_scheme = if addr.starts_with("http://") || addr.starts_with("https://") { + addr.to_string() + } else { + format!("http://{}", addr) + }; + + let (host, ws_scheme) = if let Ok(parsed) = url::Url::parse(&with_scheme) { + let h = parsed.host_str().unwrap_or("localhost").to_string(); + let s = if parsed.scheme() == "https" { "wss" } else { "ws" }; + (h, s) + } else { + (addr.split(':').next().unwrap_or(addr).to_string(), "ws") + }; + + let console_port = std::env::var("BETTERDESK_CONSOLE_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(5000); + + format!( + "{}://{}:{}/ws/bd-signal?device_id={}&token={}", + ws_scheme, + host, + console_port, + urlencoding_encode(device_id), + urlencoding_encode(token), + ) +} + +/// Minimal URL-encoder (avoids pulling in another crate). Only encodes the +/// characters relevant for opaque IDs / tokens: space, =, &, ?, #, %. +fn urlencoding_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +/// Spawn the long-running bd-signal task. Idempotent: caller is expected to +/// invoke this once on startup. Quits silently if the device is not yet +/// registered or the user toggles off the relevant capabilities. +pub fn spawn(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + // Initial delay so the panel finishes booting and the user has a + // chance to complete enrollment before we start hitting the WS. + sleep(Duration::from_secs(3)).await; + + let mut backoff_secs = 1u64; + loop { + // Re-read config each iteration so capability toggles take effect + // on next reconnect without restarting the app. + let spec = match resolve_spec(&app) { + Some(s) => s, + None => { + sleep(Duration::from_secs(15)).await; + continue; + } + }; + + match run_one_connection(&app, &spec).await { + Ok(()) => { + info!("[bd-signal] Connection closed cleanly — reconnecting"); + backoff_secs = 1; + } + Err(e) => { + warn!("[bd-signal] Connection error: {} — backoff {}s", e, backoff_secs); + sleep(Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(60); + } + } + } + }); +} + +fn resolve_spec(app: &tauri::AppHandle) -> Option { + use tauri::Manager; + let state = app.try_state::()?; + let guard = state.config.lock().ok()?; + ConnSpec::from_config(&guard) +} + +async fn run_one_connection(_app: &tauri::AppHandle, spec: &ConnSpec) -> Result<()> { + // The Node.js panel on :5000 commonly redirects to :5443 (HTTPS). We + // probe a small ranked list of scheme/port combinations, honouring any + // explicit `BETTERDESK_CONSOLE_URL` override first. + let candidates = candidate_urls(&spec.server_address, &spec.device_id, &spec.auth_token).await; + + let mut last_err: Option = None; + let mut stream: Option = None; + for url in &candidates { + match try_connect(url).await { + Ok(s) => { + info!("[bd-signal] Connected via {}", redact_token(url)); + stream = Some(s); + break; + } + Err(e) => { + debug!("[bd-signal] candidate failed: {} ({})", redact_token(url), e); + last_err = Some(format!("{} -> {}", redact_token(url), e)); + } + } + } + + let ws_stream = match stream { + Some(s) => s, + None => { + return Err(anyhow!( + "bd-signal connect failed — last: {}", + last_err.unwrap_or_else(|| "no candidates".into()) + )) + } + }; + + let (mut write, mut read) = ws_stream.split(); + + while let Some(msg) = read.next().await { + let msg = match msg { + Ok(m) => m, + Err(e) => return Err(anyhow!("WS read error: {}", e)), + }; + + let text = match msg { + WsMessage::Text(t) => t, + WsMessage::Ping(p) => { + write.send(WsMessage::Pong(p)).await.ok(); + continue; + } + WsMessage::Close(_) => { + info!("[bd-signal] Server closed connection"); + return Ok(()); + } + _ => continue, + }; + + let envelope: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(e) => { + debug!("[bd-signal] Skipping malformed frame: {}", e); + continue; + } + }; + + let kind = envelope.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + // Welcome / heartbeat_ack / unknown server-initiated frames — ignore. + if kind == "welcome" || kind == "heartbeat_ack" || kind == "relay_ready" { + continue; + } + + let request_id = envelope + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let payload = envelope.get("payload").cloned().unwrap_or(Value::Null); + + if request_id.is_empty() { + debug!("[bd-signal] Skipping non-request frame: type={}", kind); + continue; + } + + let spec_clone = spec.clone(); + let kind_owned = kind.to_string(); + + // Dispatch on a blocking task — most handlers shell out / read disk. + let result = tokio::task::spawn_blocking(move || dispatch(&kind_owned, &payload, &spec_clone)) + .await + .unwrap_or_else(|e| Err(anyhow!("Handler panicked: {}", e))); + + let response = match result { + Ok(data) => json!({ + "type": "command_response", + "request_id": request_id, + "ok": true, + "data": data, + }), + Err(e) => json!({ + "type": "command_response", + "request_id": request_id, + "ok": false, + "error": e.to_string(), + }), + }; + + if let Err(e) = write.send(WsMessage::Text(response.to_string())).await { + return Err(anyhow!("Failed to send response: {}", e)); + } + } + + Ok(()) +} + +/// Discover candidate WS URLs. Order: explicit override → discovered via +/// HTTP probe → scheme/port permutations. +async fn candidate_urls(server_address: &str, device_id: &str, token: &str) -> Vec { + let mut out: Vec = Vec::new(); + + if let Ok(forced) = std::env::var("BETTERDESK_CONSOLE_URL") { + if !forced.trim().is_empty() { + out.push(format_ws(&forced, device_id, token)); + } + } + + // Probe http://host:5000 — if it redirects, follow to the real origin. + if let Some(discovered) = probe_panel_origin(server_address).await { + out.push(format_ws(&discovered, device_id, token)); + } + + let host = extract_host(server_address); + let console_port = std::env::var("BETTERDESK_CONSOLE_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(5000); + for (scheme, port) in [ + ("wss", 5443u16), + ("ws", console_port), + ("wss", console_port), + ("ws", 5443), + ] { + let url = format!( + "{}://{}:{}/ws/bd-signal?device_id={}&token={}", + scheme, + host, + port, + urlencoding_encode(device_id), + urlencoding_encode(token), + ); + if !out.contains(&url) { + out.push(url); + } + } + out +} + +fn format_ws(origin: &str, device_id: &str, token: &str) -> String { + let o = origin.trim().trim_end_matches('/'); + let o = if o.starts_with("http://") { + format!("ws://{}", &o[7..]) + } else if o.starts_with("https://") { + format!("wss://{}", &o[8..]) + } else if o.starts_with("ws://") || o.starts_with("wss://") { + o.to_string() + } else { + format!("ws://{}", o) + }; + format!( + "{}/ws/bd-signal?device_id={}&token={}", + o, + urlencoding_encode(device_id), + urlencoding_encode(token), + ) +} + +fn extract_host(server_address: &str) -> String { + let with_scheme = if server_address.starts_with("http://") || server_address.starts_with("https://") { + server_address.to_string() + } else { + format!("http://{}", server_address) + }; + url::Url::parse(&with_scheme) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_else(|| server_address.split(':').next().unwrap_or(server_address).to_string()) +} + +/// One-shot probe of `http://host:5000/` following a single redirect hop — +/// returns the final origin (scheme://host:port) on success. +async fn probe_panel_origin(server_address: &str) -> Option { + let host = extract_host(server_address); + let probe = format!("http://{}:5000/", host); + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(3)) + .build() + .ok()?; + let resp = client.get(&probe).send().await.ok()?; + if resp.status().is_redirection() { + if let Some(loc) = resp.headers().get(reqwest::header::LOCATION) { + if let Ok(loc_str) = loc.to_str() { + if let Ok(parsed) = url::Url::parse(loc_str) { + let scheme = parsed.scheme(); + let h = parsed.host_str().unwrap_or(&host); + let p = parsed.port().unwrap_or(if scheme == "https" { 443 } else { 80 }); + return Some(format!("{}://{}:{}", scheme, h, p)); + } + } + } + } else if resp.status().is_success() { + return Some(format!("http://{}:5000", host)); + } + None +} + +fn redact_token(url: &str) -> String { + if let Some(idx) = url.find("token=") { + let mut out = url[..idx + 6].to_string(); + out.push_str("***"); + out + } else { + url.to_string() + } +} + +#[allow(dead_code)] +fn scheme_of(url: &str) -> &'static str { + if url.starts_with("wss://") { + "wss" + } else { + "ws" + } +} + +type WsStream = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, +>; + +async fn try_connect(url: &str) -> Result { + info!("[bd-signal] Trying {}", redact_token(url)); + let allow_invalid = std::env::var("BETTERDESK_STRICT_TLS").as_deref() != Ok("1"); + let connector = if url.starts_with("wss://") && allow_invalid { + let tls = NativeTlsConnector::builder() + .danger_accept_invalid_certs(true) + .build() + .context("Failed to build TLS connector")?; + Some(tokio_tungstenite::Connector::NativeTls(tls)) + } else { + None + }; + + let (ws, _resp) = if connector.is_some() { + connect_async_tls_with_config(url, None, false, connector).await? + } else { + tokio_tungstenite::connect_async(url).await? + }; + Ok(ws) +} + +// ───────────────────────── Dispatcher ───────────────────────── + +fn dispatch(kind: &str, payload: &Value, spec: &ConnSpec) -> Result { + match kind { + "services.list" => services_list(), + "processes.list" => processes_list(), + "events.list" => { + let limit = payload + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(100) + .min(500) as usize; + events_list(limit) + } + "activity.get" => Ok(json!({ "apps": [] })), + "files.browse" => { + require(spec.allow_file_browser, "file_browser_disabled")?; + let path = payload.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let show_hidden = payload + .get("show_hidden") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + files_browse(path, show_hidden) + } + "files.read" => { + require(spec.allow_file_browser, "file_browser_disabled")?; + let path = payload.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let offset = payload.get("offset").and_then(|v| v.as_u64()).unwrap_or(0); + let length = payload + .get("length") + .and_then(|v| v.as_u64()) + .unwrap_or(65536) + .min(1024 * 1024); + files_read(path, offset, length as usize) + } + "files.write" => { + require(spec.allow_file_browser, "file_browser_disabled")?; + let path = payload.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let data_b64 = payload.get("data").and_then(|v| v.as_str()).unwrap_or(""); + let mode = payload.get("mode").and_then(|v| v.as_str()).unwrap_or("overwrite"); + files_write(path, data_b64, mode) + } + "files.delete" => { + require(spec.allow_file_browser, "file_browser_disabled")?; + let path = payload.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let recursive = payload + .get("recursive") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + files_delete(path, recursive) + } + "files.rename" => { + require(spec.allow_file_browser, "file_browser_disabled")?; + let from = payload.get("from").and_then(|v| v.as_str()).unwrap_or(""); + let to = payload.get("to").and_then(|v| v.as_str()).unwrap_or(""); + files_rename(from, to) + } + "files.mkdir" => { + require(spec.allow_file_browser, "file_browser_disabled")?; + let path = payload.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let recursive = payload + .get("recursive") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + files_mkdir(path, recursive) + } + "clipboard.get" => { + require(spec.allow_clipboard, "clipboard_disabled")?; + clipboard_get() + } + "clipboard.set" => { + require(spec.allow_clipboard, "clipboard_disabled")?; + let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or(""); + clipboard_set(text) + } + "screenshot.capture" => { + require(spec.allow_screen_capture, "screen_capture_disabled")?; + screenshot_capture() + } + "input.mouse" => { + require(spec.allow_screen_capture, "input_disabled")?; + input_mouse(payload) + } + "input.key" => { + require(spec.allow_screen_capture, "input_disabled")?; + input_key(payload) + } + "input.text" => { + require(spec.allow_screen_capture, "input_disabled")?; + input_text(payload) + } + "terminal.execute" => { + require(spec.allow_terminal, "terminal_disabled")?; + let cmd = payload.get("command").and_then(|v| v.as_str()).unwrap_or(""); + terminal_execute(cmd) + } + other => Err(anyhow!("unknown_command: {}", other)), + } +} + +fn require(flag: bool, err: &'static str) -> Result<()> { + if flag { + Ok(()) + } else { + Err(anyhow!(err)) + } +} + +// ───────────────────────── Handlers — services ───────────────────────── + +#[derive(Serialize)] +struct ServiceItem { + name: String, + display_name: String, + status: String, + start_type: String, +} + +fn services_list() -> Result { + let items = collect_services()?; + Ok(json!({ "services": items })) +} + +#[cfg(target_os = "linux")] +fn collect_services() -> Result> { + let out = std::process::Command::new("systemctl") + .args([ + "list-units", + "--type=service", + "--all", + "--no-pager", + "--no-legend", + "--plain", + ]) + .output() + .map_err(|e| anyhow!("systemctl unavailable: {}", e))?; + + if !out.status.success() { + return Err(anyhow!( + "systemctl exit {}: {}", + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + + let text = String::from_utf8_lossy(&out.stdout); + let mut items = Vec::new(); + for line in text.lines() { + // Format: UNIT LOAD ACTIVE SUB DESCRIPTION... + let mut parts = line.split_whitespace(); + let name = parts.next().unwrap_or("").to_string(); + let _load = parts.next().unwrap_or(""); + let active = parts.next().unwrap_or(""); + let sub = parts.next().unwrap_or(""); + let description: String = parts.collect::>().join(" "); + if name.is_empty() { + continue; + } + let status = if active == "active" && sub == "running" { + "running" + } else if active == "active" { + "active" + } else if active == "failed" { + "failed" + } else { + "stopped" + } + .to_string(); + + items.push(ServiceItem { + name: name.clone(), + display_name: if description.is_empty() { name } else { description }, + status, + start_type: "-".into(), + }); + } + + Ok(items) +} + +#[cfg(target_os = "windows")] +fn collect_services() -> Result> { + let out = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-Service | Select-Object Name,DisplayName,Status,StartType | ConvertTo-Json -Compress", + ]) + .output() + .map_err(|e| anyhow!("powershell unavailable: {}", e))?; + + if !out.status.success() { + return Err(anyhow!( + "powershell exit {}: {}", + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + + let text = String::from_utf8_lossy(&out.stdout); + let json: Value = serde_json::from_str(&text) + .or_else(|_| serde_json::from_str(&format!("[{}]", text))) + .unwrap_or(Value::Null); + + let arr = match json { + Value::Array(a) => a, + Value::Object(_) => vec![json], + _ => Vec::new(), + }; + + let items = arr + .into_iter() + .map(|v| ServiceItem { + name: v.get("Name").and_then(|x| x.as_str()).unwrap_or("").to_string(), + display_name: v.get("DisplayName").and_then(|x| x.as_str()).unwrap_or("").to_string(), + status: match v.get("Status").and_then(|x| x.as_i64()).unwrap_or(-1) { + 4 => "running".to_string(), + 1 => "stopped".to_string(), + _ => v + .get("Status") + .and_then(|x| x.as_str()) + .unwrap_or("-") + .to_lowercase(), + }, + start_type: v + .get("StartType") + .and_then(|x| x.as_str()) + .or_else(|| v.get("StartType").and_then(|x| x.as_i64()).map(|_| "-")) + .unwrap_or("-") + .to_string(), + }) + .collect(); + + Ok(items) +} + +#[cfg(target_os = "macos")] +fn collect_services() -> Result> { + let out = std::process::Command::new("launchctl") + .args(["list"]) + .output() + .map_err(|e| anyhow!("launchctl unavailable: {}", e))?; + let text = String::from_utf8_lossy(&out.stdout); + let mut items = Vec::new(); + // Header: PID Status Label + for (i, line) in text.lines().enumerate() { + if i == 0 { + continue; + } + let mut parts = line.split_whitespace(); + let pid = parts.next().unwrap_or("-"); + let _status = parts.next().unwrap_or("-"); + let label = parts.collect::>().join(" "); + if label.is_empty() { + continue; + } + items.push(ServiceItem { + name: label.clone(), + display_name: label, + status: if pid == "-" { + "stopped".into() + } else { + "running".into() + }, + start_type: "-".into(), + }); + } + Ok(items) +} + +// ───────────────────────── Handlers — processes ───────────────────────── + +fn processes_list() -> Result { + let mut sys = System::new(); + sys.refresh_processes(ProcessesToUpdate::All, true); + // Re-sample CPU (sysinfo requires two samples for usable CPU%). + std::thread::sleep(Duration::from_millis(150)); + sys.refresh_processes(ProcessesToUpdate::All, true); + + let mut out: Vec = sys + .processes() + .iter() + .map(|(pid, p)| { + let mem_mb = p.memory() as f64 / (1024.0 * 1024.0); + json!({ + "pid": pid.as_u32(), + "name": p.name().to_string_lossy(), + "user": p + .user_id() + .map(|u| u.to_string()) + .unwrap_or_else(|| "-".into()), + "cpu": p.cpu_usage(), + "memory_mb": mem_mb, + }) + }) + .collect(); + + out.sort_by(|a, b| { + let ca = a.get("cpu").and_then(|v| v.as_f64()).unwrap_or(0.0); + let cb = b.get("cpu").and_then(|v| v.as_f64()).unwrap_or(0.0); + cb.partial_cmp(&ca).unwrap_or(std::cmp::Ordering::Equal) + }); + + out.truncate(300); + Ok(json!({ "processes": out })) +} + +// ───────────────────────── Handlers — events ───────────────────────── + +#[cfg(target_os = "linux")] +fn events_list(limit: usize) -> Result { + let n = limit.to_string(); + let out = std::process::Command::new("journalctl") + .args([ + "-n", &n, "--no-pager", "-o", "short-iso", + "--output-fields=__REALTIME_TIMESTAMP,_COMM,MESSAGE,PRIORITY", + ]) + .output() + .map_err(|e| anyhow!("journalctl unavailable: {}", e))?; + + let text = String::from_utf8_lossy(&out.stdout); + let events: Vec = text + .lines() + .filter(|l| !l.is_empty() && !l.starts_with("--")) + .map(|line| { + // short-iso prefix: "2026-04-25T18:15:30+0000 host source[pid]: message" + let (time_part, rest) = match line.find(' ') { + Some(i) => (&line[..i], &line[i + 1..]), + None => ("", line), + }; + let (source, message) = match rest.find(": ") { + Some(i) => { + let head = &rest[..i]; + let comp = head.split_whitespace().last().unwrap_or(head); + ( + comp.split('[').next().unwrap_or(comp).to_string(), + rest[i + 2..].to_string(), + ) + } + None => ("-".to_string(), rest.to_string()), + }; + let level = if message.to_lowercase().contains("error") || message.contains("ERR") { + "error" + } else if message.to_lowercase().contains("warn") { + "warning" + } else { + "info" + }; + json!({ + "time": time_part, + "source": source, + "message": message, + "level": level, + }) + }) + .collect(); + Ok(json!({ "events": events })) +} + +#[cfg(target_os = "windows")] +fn events_list(limit: usize) -> Result { + let cmd = format!( + "Get-EventLog -LogName System -Newest {} | Select-Object @{{n='time';e={{$_.TimeGenerated.ToString('s')}}}}, @{{n='source';e={{$_.Source}}}}, @{{n='message';e={{$_.Message}}}}, @{{n='level';e={{$_.EntryType.ToString().ToLower()}}}} | ConvertTo-Json -Compress", + limit.min(500) + ); + let out = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", &cmd]) + .output() + .map_err(|e| anyhow!("powershell unavailable: {}", e))?; + let text = String::from_utf8_lossy(&out.stdout); + let json: Value = serde_json::from_str(&text) + .or_else(|_| serde_json::from_str(&format!("[{}]", text))) + .unwrap_or(Value::Null); + let arr = match json { + Value::Array(a) => a, + Value::Object(_) => vec![json], + _ => Vec::new(), + }; + Ok(json!({ "events": arr })) +} + +#[cfg(target_os = "macos")] +fn events_list(limit: usize) -> Result { + let out = std::process::Command::new("log") + .args(["show", "--last", "1h", "--style", "compact"]) + .output() + .map_err(|e| anyhow!("log unavailable: {}", e))?; + let text = String::from_utf8_lossy(&out.stdout); + let events: Vec = text + .lines() + .take(limit) + .map(|l| json!({ "time": "", "source": "system", "message": l, "level": "info" })) + .collect(); + Ok(json!({ "events": events })) +} + +// ───────────────────────── Handlers — files ───────────────────────── + +fn safe_path(input: &str) -> Result { + let p = if input.is_empty() || input == "/" { + #[cfg(target_os = "windows")] + { + PathBuf::from("C:\\") + } + #[cfg(not(target_os = "windows"))] + { + PathBuf::from("/") + } + } else { + PathBuf::from(input) + }; + // Reject ".." traversal at component level. + for comp in p.components() { + if matches!(comp, std::path::Component::ParentDir) { + return Err(anyhow!("path_traversal_rejected")); + } + } + Ok(p) +} + +fn files_browse(path: &str, show_hidden: bool) -> Result { + let target = safe_path(path)?; + let canonical = target.canonicalize().unwrap_or(target.clone()); + + let mut entries = Vec::new(); + for entry in std::fs::read_dir(&canonical)? { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let name_os = entry.file_name(); + let name = name_os.to_string_lossy().to_string(); + if !show_hidden && name.starts_with('.') { + continue; + } + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + entries.push(json!({ + "name": name, + "path": entry.path().to_string_lossy(), + "is_dir": meta.is_dir(), + "size": if meta.is_file() { meta.len() } else { 0 }, + })); + } + + // Sort: dirs first then alpha. + entries.sort_by(|a, b| { + let da = a.get("is_dir").and_then(|v| v.as_bool()).unwrap_or(false); + let db = b.get("is_dir").and_then(|v| v.as_bool()).unwrap_or(false); + match (da, db) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .cmp(b.get("name").and_then(|v| v.as_str()).unwrap_or("")), + } + }); + + let parent = canonical + .parent() + .map(|p| p.to_string_lossy().to_string()) + .filter(|p| !p.is_empty() && *p != canonical.to_string_lossy()); + + Ok(json!({ + "path": canonical.to_string_lossy(), + "parent": parent, + "entries": entries, + })) +} + +fn files_read(path: &str, offset: u64, length: usize) -> Result { + use std::io::{Read, Seek, SeekFrom}; + let target = safe_path(path)?; + let mut f = std::fs::File::open(&target)?; + f.seek(SeekFrom::Start(offset))?; + let mut buf = vec![0u8; length]; + let n = f.read(&mut buf)?; + buf.truncate(n); + Ok(json!({ + "path": target.to_string_lossy(), + "offset": offset, + "length": n, + "data": B64.encode(&buf), + })) +} + +/// Maximum size of a single `files.write` request to keep memory bounded. +const MAX_WRITE_BYTES: usize = 16 * 1024 * 1024; // 16 MB + +fn files_write(path: &str, data_b64: &str, mode: &str) -> Result { + use std::io::Write; + if path.is_empty() { + return Err(anyhow!("missing_path")); + } + let target = safe_path(path)?; + let bytes = B64.decode(data_b64).map_err(|e| anyhow!("bad_base64: {}", e))?; + if bytes.len() > MAX_WRITE_BYTES { + return Err(anyhow!("payload_too_large")); + } + + // Make sure the parent dir exists when creating a fresh file. + if let Some(parent) = target.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow!("create_parent_failed: {}", e))?; + } + } + + let mut opts = std::fs::OpenOptions::new(); + match mode { + "append" => { + opts.create(true).append(true); + } + "create" => { + opts.create_new(true).write(true); + } + // default — overwrite + _ => { + opts.create(true).write(true).truncate(true); + } + } + let mut f = opts + .open(&target) + .map_err(|e| anyhow!("open_failed: {}", e))?; + f.write_all(&bytes) + .map_err(|e| anyhow!("write_failed: {}", e))?; + f.flush().ok(); + + Ok(json!({ + "path": target.to_string_lossy(), + "bytes": bytes.len(), + "mode": mode, + })) +} + +fn files_delete(path: &str, recursive: bool) -> Result { + if path.is_empty() { + return Err(anyhow!("missing_path")); + } + let target = safe_path(path)?; + let meta = std::fs::symlink_metadata(&target) + .map_err(|e| anyhow!("stat_failed: {}", e))?; + let kind = if meta.is_dir() { + if recursive { + std::fs::remove_dir_all(&target).map_err(|e| anyhow!("rmdir_failed: {}", e))?; + } else { + std::fs::remove_dir(&target).map_err(|e| anyhow!("rmdir_failed: {}", e))?; + } + "dir" + } else { + std::fs::remove_file(&target).map_err(|e| anyhow!("unlink_failed: {}", e))?; + "file" + }; + Ok(json!({ + "path": target.to_string_lossy(), + "kind": kind, + "recursive": recursive, + })) +} + +fn files_rename(from: &str, to: &str) -> Result { + if from.is_empty() || to.is_empty() { + return Err(anyhow!("missing_path")); + } + let src = safe_path(from)?; + let dst = safe_path(to)?; + if !src.exists() { + return Err(anyhow!("source_not_found")); + } + // Ensure destination parent exists. + if let Some(parent) = dst.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow!("create_parent_failed: {}", e))?; + } + } + std::fs::rename(&src, &dst).map_err(|e| anyhow!("rename_failed: {}", e))?; + Ok(json!({ + "from": src.to_string_lossy(), + "to": dst.to_string_lossy(), + })) +} + +fn files_mkdir(path: &str, recursive: bool) -> Result { + if path.is_empty() { + return Err(anyhow!("missing_path")); + } + let target = safe_path(path)?; + if target.exists() { + return Err(anyhow!("already_exists")); + } + if recursive { + std::fs::create_dir_all(&target).map_err(|e| anyhow!("mkdir_failed: {}", e))?; + } else { + std::fs::create_dir(&target).map_err(|e| anyhow!("mkdir_failed: {}", e))?; + } + Ok(json!({ + "path": target.to_string_lossy(), + "recursive": recursive, + })) +} + +// ───────────────────────── Handlers — clipboard (Phase 64) ───────────────────────── + +fn clipboard_get() -> Result { + let mut cb = arboard::Clipboard::new() + .map_err(|e| anyhow!("clipboard_init_failed: {}", e))?; + match cb.get_text() { + Ok(text) => Ok(json!({ + "format": "text", + "text": text, + "length": text.len(), + })), + Err(arboard::Error::ContentNotAvailable) => Ok(json!({ + "format": "text", + "text": "", + "empty": true, + })), + Err(e) => Err(anyhow!("clipboard_read_failed: {}", e)), + } +} + +fn clipboard_set(text: &str) -> Result { + if text.len() > 1024 * 1024 { + return Err(anyhow!("text_too_large")); + } + let mut cb = arboard::Clipboard::new() + .map_err(|e| anyhow!("clipboard_init_failed: {}", e))?; + cb.set_text(text.to_string()) + .map_err(|e| anyhow!("clipboard_write_failed: {}", e))?; + Ok(json!({ + "ok": true, + "length": text.len(), + })) +} + +// ───────────────────────── Handlers — screenshot ───────────────────────── + +fn screenshot_capture() -> Result { + let tmp = std::env::temp_dir().join(format!( + "bd-screenshot-{}.jpg", + uuid::Uuid::new_v4().simple() + )); + capture_to_file(&tmp)?; + let bytes = std::fs::read(&tmp).map_err(|e| anyhow!("read screenshot: {}", e))?; + let _ = std::fs::remove_file(&tmp); + let (width, height) = match image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg) + { + Ok(img) => (img.width() as u64, img.height() as u64), + Err(_) => (0, 0), + }; + Ok(json!({ + "format": "jpeg", + "image": B64.encode(&bytes), + "size": bytes.len(), + "width": width, + "height": height, + })) +} + +#[cfg(target_os = "linux")] +fn capture_to_file(path: &Path) -> Result<()> { + // Prefer tools that match the active desktop session. On KDE Plasma + // Wayland, Spectacle works reliably while ImageMagick `import` does not. + let path_str = path.to_string_lossy().to_string(); + let wayland = std::env::var("WAYLAND_DISPLAY").ok().filter(|v| !v.is_empty()).is_some(); + let plasma = std::env::var("XDG_SESSION_DESKTOP") + .ok() + .or_else(|| std::env::var("DESKTOP_SESSION").ok()) + .map(|v| v.to_ascii_lowercase()) + .map(|v| v.contains("plasma") || v.contains("kde")) + .unwrap_or(false); + + let attempts: Vec<(&str, Vec<&str>)> = if wayland && plasma { + vec![ + ("spectacle", vec!["-b", "-n", "-o", &path_str]), + ("grim", vec![&path_str]), + ("gnome-screenshot", vec!["-f", &path_str]), + ("scrot", vec!["-z", &path_str]), + ("import", vec!["-window", "root", &path_str]), + ] + } else if wayland { + vec![ + ("grim", vec![&path_str]), + ("gnome-screenshot", vec!["-f", &path_str]), + ("spectacle", vec!["-b", "-n", "-o", &path_str]), + ("scrot", vec!["-z", &path_str]), + ("import", vec!["-window", "root", &path_str]), + ] + } else { + vec![ + ("scrot", vec!["-z", &path_str]), + ("import", vec!["-window", "root", &path_str]), + ("gnome-screenshot", vec!["-f", &path_str]), + ("spectacle", vec!["-b", "-n", "-o", &path_str]), + ("grim", vec![&path_str]), + ] + }; + + let mut last_err = String::new(); + for (bin, args) in attempts { + match std::process::Command::new(bin).args(&args).output() { + Ok(out) if out.status.success() && path.exists() => return Ok(()), + Ok(out) => { + last_err = format!( + "{} exit {}: {}", + bin, + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Err(e) => { + last_err = format!("{}: {}", bin, e); + } + } + } + Err(anyhow!("no_screenshot_tool: {}", last_err)) +} + +#[cfg(target_os = "macos")] +fn capture_to_file(path: &Path) -> Result<()> { + let out = std::process::Command::new("screencapture") + .args(["-x", "-t", "jpg", &path.to_string_lossy()]) + .output() + .map_err(|e| anyhow!("screencapture unavailable: {}", e))?; + if !out.status.success() { + return Err(anyhow!("screencapture failed")); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn capture_to_file(path: &Path) -> Result<()> { + let p = path.to_string_lossy().replace('\\', "\\\\"); + let ps = format!( + r#"Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; + $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; + $bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height); + $g = [System.Drawing.Graphics]::FromImage($bmp); + $g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size); + $bmp.Save('{}', [System.Drawing.Imaging.ImageFormat]::Jpeg);"#, + p + ); + let out = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", &ps]) + .output() + .map_err(|e| anyhow!("powershell unavailable: {}", e))?; + if !out.status.success() { + return Err(anyhow!( + "screenshot ps failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +// ───────────────────────── Handlers — input injection (Phase 58) ───────────────────────── + +use enigo::{Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings}; +use std::sync::{Mutex, OnceLock}; + +fn enigo_handle() -> Result<&'static Mutex> { + static ENIGO: OnceLock> = OnceLock::new(); + if let Some(e) = ENIGO.get() { + return Ok(e); + } + let inst = Enigo::new(&Settings::default()) + .map_err(|e| anyhow!("enigo_init_failed: {}", e))?; + let _ = ENIGO.set(Mutex::new(inst)); + ENIGO + .get() + .ok_or_else(|| anyhow!("enigo_init_race")) +} + +fn input_mouse(payload: &Value) -> Result { + let action = payload + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("move"); + let handle = enigo_handle()?; + let mut enigo = handle.lock().map_err(|_| anyhow!("enigo_poisoned"))?; + + match action { + "move" => { + let (x, y) = resolve_coords(&mut enigo, payload)?; + enigo + .move_mouse(x, y, Coordinate::Abs) + .map_err(|e| anyhow!("move_failed: {}", e))?; + } + "down" | "up" | "click" => { + // Move first if coordinates are provided alongside the click. + if payload.get("x").is_some() || payload.get("x_rel").is_some() { + if let Ok((x, y)) = resolve_coords(&mut enigo, payload) { + let _ = enigo.move_mouse(x, y, Coordinate::Abs); + } + } + let button = parse_button(payload); + let dir = match action { + "down" => Direction::Press, + "up" => Direction::Release, + _ => Direction::Click, + }; + enigo + .button(button, dir) + .map_err(|e| anyhow!("button_failed: {}", e))?; + } + "wheel" => { + let dy = payload.get("wheel_dy").and_then(|v| v.as_i64()).unwrap_or(0); + let dx = payload.get("wheel_dx").and_then(|v| v.as_i64()).unwrap_or(0); + if dy != 0 { + enigo + .scroll(dy as i32, Axis::Vertical) + .map_err(|e| anyhow!("scroll_failed: {}", e))?; + } + if dx != 0 { + enigo + .scroll(dx as i32, Axis::Horizontal) + .map_err(|e| anyhow!("scroll_failed: {}", e))?; + } + } + other => return Err(anyhow!("unknown_mouse_action: {}", other)), + } + Ok(json!({ "ok": true })) +} + +/// Resolve absolute pixel coords from either `x`/`y` (px) or `x_rel`/`y_rel` (0..1 of `screen_w`/`screen_h`). +fn resolve_coords(enigo: &mut Enigo, payload: &Value) -> Result<(i32, i32)> { + if let (Some(x), Some(y)) = ( + payload.get("x").and_then(|v| v.as_i64()), + payload.get("y").and_then(|v| v.as_i64()), + ) { + return Ok((x as i32, y as i32)); + } + let xr = payload + .get("x_rel") + .and_then(|v| v.as_f64()) + .ok_or_else(|| anyhow!("missing_coords"))?; + let yr = payload + .get("y_rel") + .and_then(|v| v.as_f64()) + .ok_or_else(|| anyhow!("missing_coords"))?; + // Prefer caller-supplied screen dims (from latest screenshot); fall back to Enigo main display. + let (sw, sh) = match ( + payload.get("screen_w").and_then(|v| v.as_u64()), + payload.get("screen_h").and_then(|v| v.as_u64()), + ) { + (Some(w), Some(h)) if w > 0 && h > 0 => (w as i32, h as i32), + _ => enigo + .main_display() + .map_err(|e| anyhow!("display_failed: {}", e))?, + }; + let xr = xr.clamp(0.0, 1.0); + let yr = yr.clamp(0.0, 1.0); + Ok((((xr * sw as f64) as i32), ((yr * sh as f64) as i32))) +} + +fn parse_button(payload: &Value) -> Button { + match payload.get("button").and_then(|v| v.as_str()).unwrap_or("left") { + "right" => Button::Right, + "middle" => Button::Middle, + _ => Button::Left, + } +} + +fn input_key(payload: &Value) -> Result { + let action = payload + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("press"); + let key_str = payload + .get("key") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing_key"))?; + + let dir = match action { + "down" => Direction::Press, + "up" => Direction::Release, + _ => Direction::Click, + }; + + let key = map_key(key_str)?; + let handle = enigo_handle()?; + let mut enigo = handle.lock().map_err(|_| anyhow!("enigo_poisoned"))?; + enigo + .key(key, dir) + .map_err(|e| anyhow!("key_failed: {}", e))?; + Ok(json!({ "ok": true })) +} + +fn input_text(payload: &Value) -> Result { + let text = payload + .get("text") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing_text"))?; + if text.is_empty() { + return Ok(json!({ "ok": true, "skipped": true })); + } + if text.len() > 4096 { + return Err(anyhow!("text_too_long")); + } + let handle = enigo_handle()?; + let mut enigo = handle.lock().map_err(|_| anyhow!("enigo_poisoned"))?; + enigo + .text(text) + .map_err(|e| anyhow!("text_failed: {}", e))?; + Ok(json!({ "ok": true })) +} + +fn map_key(s: &str) -> Result { + // Match common DOM `KeyboardEvent.key` / `code` values. + let k = match s { + "Enter" | "Return" => Key::Return, + "Escape" | "Esc" => Key::Escape, + "Backspace" => Key::Backspace, + "Tab" => Key::Tab, + " " | "Space" | "Spacebar" => Key::Space, + "ArrowUp" | "Up" => Key::UpArrow, + "ArrowDown" | "Down" => Key::DownArrow, + "ArrowLeft" | "Left" => Key::LeftArrow, + "ArrowRight" | "Right" => Key::RightArrow, + "Home" => Key::Home, + "End" => Key::End, + "PageUp" => Key::PageUp, + "PageDown" => Key::PageDown, + "Delete" | "Del" => Key::Delete, + "Insert" => Key::Insert, + "CapsLock" => Key::CapsLock, + "Shift" | "ShiftLeft" | "ShiftRight" => Key::Shift, + "Control" | "ControlLeft" | "ControlRight" | "Ctrl" => Key::Control, + "Alt" | "AltLeft" | "AltRight" => Key::Alt, + "Meta" | "MetaLeft" | "MetaRight" | "OS" | "Super" => Key::Meta, + "F1" => Key::F1, + "F2" => Key::F2, + "F3" => Key::F3, + "F4" => Key::F4, + "F5" => Key::F5, + "F6" => Key::F6, + "F7" => Key::F7, + "F8" => Key::F8, + "F9" => Key::F9, + "F10" => Key::F10, + "F11" => Key::F11, + "F12" => Key::F12, + other if other.chars().count() == 1 => { + let c = other.chars().next().unwrap(); + Key::Unicode(c) + } + other => return Err(anyhow!("unknown_key: {}", other)), + }; + Ok(k) +} + +// ───────────────────────── Handlers — terminal ───────────────────────── + +fn terminal_execute(command: &str) -> Result { + if command.trim().is_empty() { + return Err(anyhow!("empty_command")); + } + + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = std::process::Command::new("cmd.exe"); + c.args(["/C", command]); + c + }; + #[cfg(not(target_os = "windows"))] + let mut cmd = { + let mut c = std::process::Command::new("sh"); + c.args(["-c", command]); + c + }; + + let out = cmd.output().map_err(|e| anyhow!("spawn failed: {}", e))?; + Ok(json!({ + "exit_code": out.status.code().unwrap_or(-1), + "stdout": String::from_utf8_lossy(&out.stdout), + "stderr": String::from_utf8_lossy(&out.stderr), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ws_url_https_origin() { + let u = build_ws_url("https://example.com:21114", "BD-X", "tok"); + assert_eq!(u, "wss://example.com:5000/ws/bd-signal?device_id=BD-X&token=tok"); + } + + #[test] + fn ws_url_http_origin() { + let u = build_ws_url("http://192.168.1.10", "BD-Y", "t k"); + assert_eq!( + u, + "ws://192.168.1.10:5000/ws/bd-signal?device_id=BD-Y&token=t%20k" + ); + } + + #[test] + fn safe_path_rejects_dotdot() { + assert!(safe_path("/home/../etc").is_err()); + assert!(safe_path("/home/user").is_ok()); + } +} diff --git a/betterdesk-agent-client/src-tauri/src/cdap_client.rs b/betterdesk-agent-client/src-tauri/src/cdap_client.rs new file mode 100644 index 00000000..27171c6a --- /dev/null +++ b/betterdesk-agent-client/src-tauri/src/cdap_client.rs @@ -0,0 +1,1085 @@ +//! Native CDAP WebSocket client — replaces the Go binary sidecar. +//! +//! This module implements the full CDAP device protocol over a single +//! persistent WebSocket connection, including: +//! - Auth / device registration with manifest +//! - Periodic heartbeat with live system metrics +//! - Terminal sessions via `portable-pty` +//! - File browser (list / read / write / delete) +//! - Clipboard read/write +//! - Command execution and response +//! - Auto-reconnect with exponential backoff + +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use futures_util::{SinkExt, StreamExt}; +use log::{debug, error, info, warn}; +use native_tls::TlsConnector as NativeTlsConnector; +use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + io::{Read, Write}, + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; +use sysinfo::System; +use tokio::{ + sync::mpsc, + time::{interval, sleep, MissedTickBehavior}, +}; +use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message as WsMessage}; +use uuid::Uuid; + +// ── Types exposed to the rest of the app ────────────────────────────────── + +/// Configuration required to connect as a CDAP device. +#[derive(Debug, Clone)] +pub struct CdapConfig { + /// Full server origin (e.g. `https://192.168.0.110:21114`). + pub server_address: String, + /// Device ID assigned during registration (e.g. `BD-AABBCCDD...`). + pub device_id: String, + /// Human-readable device name. + pub device_name: String, + /// CDAP API key for authenticating the WebSocket connection. + pub api_key: String, + /// Auth token stored after first registration (re-used on reconnect). + pub auth_token: Option, + /// CDAP WebSocket port (default 21122). + pub cdap_port: u16, + /// Whether to allow operator-initiated terminal sessions. + pub allow_terminal: bool, + /// Whether to allow operator-initiated file browser. + pub allow_file_browser: bool, + /// Whether to allow clipboard access. + pub allow_clipboard: bool, + /// Whether to allow screen capture / desktop streaming. + pub allow_screen_capture: bool, + /// Local data directory for temporary files. + pub data_dir: PathBuf, +} + +impl CdapConfig { + /// Builds the full WebSocket URL: `ws[s]://host:cdap_port/cdap`. + pub fn cdap_ws_url(&self) -> String { + let addr = self.server_address.trim(); + // Determine host (strip scheme + port from server_address). + let host = if addr.starts_with("https://") || addr.starts_with("http://") { + match url::Url::parse(addr) { + Ok(u) => u.host_str().unwrap_or("localhost").to_string(), + Err(_) => addr.to_string(), + } + } else { + // bare host or host:port — strip any trailing port + addr.split(':').next().unwrap_or(addr).to_string() + }; + + // CDAP WebSocket port is always plain WS unless BETTERDESK_CDAP_TLS=1. + // The HTTP API origin scheme does not determine the CDAP WS transport. + let scheme = if std::env::var("BETTERDESK_CDAP_TLS").as_deref() == Ok("1") { + "wss" + } else { + "ws" + }; + format!("{}://{}:{}/cdap", scheme, host, self.cdap_port) + } +} + +/// Snapshot status exposed to the Tauri frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CdapStatus { + pub running: bool, + pub pid: u32, // always 0 — no child process + pub restart_count: u64, + pub state: String, + pub binary_path: String, // always empty — no binary + pub cdap_url: String, +} + +// ── Internal ────────────────────────────────────────────────────────────── + +/// A live terminal session spawned for an operator. +struct TerminalSession { + /// Channel to send input bytes to the PTY writer task. + tx: mpsc::Sender>, + /// Channel to signal the PTY writer task to exit. + kill_tx: mpsc::Sender<()>, +} + +struct Inner { + stop_requested: AtomicBool, + running: AtomicBool, + restart_count: AtomicU64, + cdap_url: Mutex, + /// Outbound message queue — fed by internal subsystems (terminal, etc.) + /// and drained by the WS writer task. + tx: Mutex>>, + /// Active terminal sessions, keyed by session_id. + terminals: Mutex>, +} + +/// Cheap-clone handle to the native CDAP client. +#[derive(Clone)] +pub struct CdapClient(Arc); + +impl CdapClient { + pub fn new() -> Self { + CdapClient(Arc::new(Inner { + stop_requested: AtomicBool::new(false), + running: AtomicBool::new(false), + restart_count: AtomicU64::new(0), + cdap_url: Mutex::new(String::new()), + tx: Mutex::new(None), + terminals: Mutex::new(HashMap::new()), + })) + } + + /// Start the CDAP connection loop (idempotent — won't start twice). + pub fn start(&self, cfg: &CdapConfig) -> Result<()> { + if self.0.running.load(Ordering::SeqCst) { + return Ok(()); + } + self.0.stop_requested.store(false, Ordering::SeqCst); + let ws_url = cfg.cdap_ws_url(); + *self.0.cdap_url.lock().unwrap() = ws_url.clone(); + + let client = self.clone(); + let cfg = cfg.clone(); + tauri::async_runtime::spawn(async move { + client.connection_loop(cfg).await; + }); + + info!("[cdap] Client scheduled (url={})", ws_url); + Ok(()) + } + + /// Signal the client to stop and close the WebSocket. + pub fn stop(&self) { + self.0.stop_requested.store(true, Ordering::SeqCst); + // Close outbound channel → WS writer will close the socket. + *self.0.tx.lock().unwrap() = None; + self.0.running.store(false, Ordering::SeqCst); + info!("[cdap] Stop requested."); + } + + pub fn is_running(&self) -> bool { + self.0.running.load(Ordering::SeqCst) + } + + pub fn status(&self) -> CdapStatus { + let running = self.0.running.load(Ordering::SeqCst); + let restart_count = self.0.restart_count.load(Ordering::SeqCst); + let cdap_url = self.0.cdap_url.lock().unwrap().clone(); + + let state = if running { + "running".to_string() + } else if cdap_url.is_empty() { + "not_configured".to_string() + } else { + "stopped".to_string() + }; + + CdapStatus { + running, + pid: 0, + restart_count, + state, + binary_path: String::new(), + cdap_url, + } + } + + // ── Internal helpers ────────────────────────────────────────────────── + + /// Send a CDAP message envelope over the open WebSocket. + fn send(&self, msg_type: &str, payload: Value) -> bool { + let envelope = json!({ + "type": msg_type, + "id": Uuid::new_v4().to_string(), + "timestamp": chrono::Utc::now().to_rfc3339(), + "payload": payload, + }); + if let Ok(text) = serde_json::to_string(&envelope) { + if let Ok(guard) = self.0.tx.lock() { + if let Some(ref tx) = *guard { + let _ = tx.try_send(WsMessage::Text(text.into())); + return true; + } + } + } + false + } + + /// Main reconnect loop — exponential backoff (5 s → 5 min). + async fn connection_loop(&self, cfg: CdapConfig) { + const BASE: u64 = 5; + const MAX: u64 = 300; + let mut backoff = BASE; + + loop { + if self.0.stop_requested.load(Ordering::SeqCst) { + self.0.running.store(false, Ordering::SeqCst); + return; + } + + info!("[cdap] Connecting to {}", cfg.cdap_ws_url()); + match self.connect_and_run(&cfg).await { + Ok(()) => { + debug!("[cdap] Session ended cleanly"); + backoff = BASE; // reset on clean close + } + Err(e) => { + error!("[cdap] Session error: {}", e); + } + } + + self.0.running.store(false, Ordering::SeqCst); + *self.0.tx.lock().unwrap() = None; + + if self.0.stop_requested.load(Ordering::SeqCst) { + return; + } + + let rc = self.0.restart_count.fetch_add(1, Ordering::SeqCst) + 1; + warn!("[cdap] Reconnect #{} in {}s …", rc, backoff); + sleep(Duration::from_secs(backoff)).await; + backoff = (backoff * 2).min(MAX); + } + } + + /// Connect WS, authenticate, register, then run heartbeat + message loop. + async fn connect_and_run(&self, cfg: &CdapConfig) -> Result<()> { + let url_str = cfg.cdap_ws_url(); + let url = url::Url::parse(&url_str).context("Invalid CDAP URL")?; + + // Build TLS connector (accept self-signed in dev). + let allow_invalid = std::env::var("BETTERDESK_STRICT_TLS").as_deref() != Ok("1"); + + // Build WS connector — accept self-signed certs in dev mode. + let connector = if allow_invalid { + let tls = NativeTlsConnector::builder() + .danger_accept_invalid_certs(true) + .build() + .context("Failed to build TLS connector")?; + Some(tokio_tungstenite::Connector::NativeTls(tls)) + } else { + None + }; + + let (ws_stream, _response) = if let Some(c) = connector { + connect_async_tls_with_config(url.as_str(), None, false, Some(c)) + .await + .context("WebSocket connect failed")? + } else { + tokio_tungstenite::connect_async(url.as_str()) + .await + .context("WebSocket connect failed")? + }; + + info!("[cdap] WebSocket connected"); + let (mut ws_write, mut ws_read) = ws_stream.split(); + + // Create outbound channel. + let (tx, mut rx) = mpsc::channel::(256); + *self.0.tx.lock().unwrap() = Some(tx.clone()); + + // ── Authenticate ────────────────────────────────────────────────── + let auth_payload = if let Some(ref token) = cfg.auth_token { + json!({ + "method": "api_key", + "key": cfg.api_key, + "device_id": cfg.device_id, + "token": token, + "client_version": env!("CARGO_PKG_VERSION"), + }) + } else { + json!({ + "method": "api_key", + "key": cfg.api_key, + "device_id": cfg.device_id, + "client_version": env!("CARGO_PKG_VERSION"), + }) + }; + + let auth_msg = json!({ + "type": "auth", + "id": Uuid::new_v4().to_string(), + "timestamp": chrono::Utc::now().to_rfc3339(), + "payload": auth_payload, + }); + ws_write + .send(WsMessage::Text(serde_json::to_string(&auth_msg)?.into())) + .await + .context("Failed to send auth message")?; + + // Wait for auth response. + let auth_result = tokio::time::timeout(Duration::from_secs(15), ws_read.next()) + .await + .context("Auth response timeout")? + .ok_or_else(|| anyhow!("WS closed before auth response"))??; + + let auth_json: Value = match &auth_result { + WsMessage::Text(t) => serde_json::from_str(t).context("Auth response not JSON")?, + _ => return Err(anyhow!("Unexpected auth response type")), + }; + + let result = &auth_json["payload"]; + if result["success"].as_bool() != Some(true) { + return Err(anyhow!( + "Auth failed: {}", + result["error"].as_str().unwrap_or("unknown") + )); + } + info!("[cdap] Authenticated (device_id={})", cfg.device_id); + + // ── Register manifest ───────────────────────────────────────────── + let manifest = build_manifest(cfg); + let reg_msg = json!({ + "type": "register", + "id": Uuid::new_v4().to_string(), + "timestamp": chrono::Utc::now().to_rfc3339(), + "payload": { "manifest": manifest }, + }); + ws_write + .send(WsMessage::Text(serde_json::to_string(®_msg)?.into())) + .await + .context("Failed to send register message")?; + + // Wait for registered response. + let reg_result = tokio::time::timeout(Duration::from_secs(10), ws_read.next()) + .await + .context("Register response timeout")? + .ok_or_else(|| anyhow!("WS closed before register response"))??; + + let reg_json: Value = match ®_result { + WsMessage::Text(t) => serde_json::from_str(t).context("Register response not JSON")?, + _ => return Err(anyhow!("Unexpected register response type")), + }; + + if reg_json["type"].as_str() != Some("registered") { + return Err(anyhow!("Registration rejected: {:?}", reg_json)); + } + self.0.running.store(true, Ordering::SeqCst); + info!("[cdap] Registered successfully"); + + // ── WS writer task ──────────────────────────────────────────────── + let writer_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if ws_write.send(msg).await.is_err() { + break; + } + } + // Close gracefully. + let _ = ws_write.send(WsMessage::Close(None)).await; + }); + + // ── Heartbeat task ──────────────────────────────────────────────── + let client_hb = self.clone(); + let device_id = cfg.device_id.clone(); + let heartbeat_task = tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(15)); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + ticker.tick().await; + if !client_hb.0.running.load(Ordering::SeqCst) { + break; + } + let metrics = collect_metrics(); + let widget_values = collect_widget_values(&metrics, &device_id); + client_hb.send( + "heartbeat", + json!({ + "metrics": metrics, + "widget_values": widget_values, + }), + ); + } + }); + + // ── Inbound message loop ────────────────────────────────────────── + while let Some(msg_result) = ws_read.next().await { + if self.0.stop_requested.load(Ordering::SeqCst) { + break; + } + match msg_result { + Ok(WsMessage::Text(text)) => { + if let Err(e) = self.handle_message(&text, cfg).await { + warn!("[cdap] handle_message error: {}", e); + } + } + Ok(WsMessage::Ping(data)) => { + let _ = self + .0 + .tx + .lock() + .unwrap() + .as_ref() + .map(|t| t.try_send(WsMessage::Pong(data))); + } + Ok(WsMessage::Close(_)) => { + info!("[cdap] Server closed connection"); + break; + } + Err(e) => { + error!("[cdap] WS read error: {}", e); + break; + } + _ => {} + } + } + + self.0.running.store(false, Ordering::SeqCst); + heartbeat_task.abort(); + writer_task.abort(); + Ok(()) + } + + /// Dispatch an inbound CDAP message from the server. + async fn handle_message(&self, text: &str, cfg: &CdapConfig) -> Result<()> { + let msg: Value = serde_json::from_str(text)?; + let msg_type = msg["type"].as_str().unwrap_or("").to_string(); + let payload = msg["payload"].clone(); + + debug!("[cdap] ← {}", msg_type); + + match msg_type.as_str() { + "command" => self.handle_command(payload, cfg).await, + "terminal_start" => self.handle_terminal_start(payload).await, + "terminal_input" => self.handle_terminal_input(payload).await, + "terminal_resize" => self.handle_terminal_resize(payload).await, + "terminal_kill" => self.handle_terminal_kill(payload).await, + "file_list" => self.handle_file_list(payload, cfg).await, + "file_read" => self.handle_file_read(payload, cfg).await, + "file_write" => self.handle_file_write(payload, cfg).await, + "file_delete" => self.handle_file_delete(payload, cfg).await, + "clipboard_get" => self.handle_clipboard_get(payload, cfg).await, + "clipboard_set" => self.handle_clipboard_set(payload, cfg).await, + "ping" => { + self.send("pong", json!({})); + Ok(()) + } + other => { + debug!("[cdap] Unhandled message type: {}", other); + Ok(()) + } + } + } + + // ── Command handler ─────────────────────────────────────────────────── + + async fn handle_command(&self, payload: Value, _cfg: &CdapConfig) -> Result<()> { + let command_id = payload["command_id"].as_str().unwrap_or("").to_string(); + let action = payload["action"].as_str().unwrap_or("").to_string(); + let widget_id = payload["widget_id"].as_str().unwrap_or("").to_string(); + let value = &payload["value"]; + + info!( + "[cdap] Command: action={} widget={} value={:?}", + action, widget_id, value + ); + + let (status, result, error) = match action.as_str() { + "ping" => ("success".to_string(), json!("pong"), None), + "get_info" => { + let snap = crate::sysinfo_collect::SystemSnapshot::collect(); + ( + "success".to_string(), + json!({ + "hostname": snap.hostname, + "os": snap.os, + "os_version": snap.os_version, + "arch": snap.arch, + "cpu_name": snap.cpu_name, + "cpu_cores": snap.cpu_cores, + "total_memory_mb": snap.total_memory_mb, + "total_disk_mb": snap.total_disk_mb, + "username": snap.username, + }), + None, + ) + } + _ => ( + "error".to_string(), + Value::Null, + Some(format!("Unknown action: {}", action)), + ), + }; + + self.send( + "command_response", + json!({ + "command_id": command_id, + "status": status, + "result": result, + "error_message": error, + }), + ); + Ok(()) + } + + // ── Terminal handlers ───────────────────────────────────────────────── + + async fn handle_terminal_start(&self, payload: Value) -> Result<()> { + let session_id = payload["session_id"] + .as_str() + .unwrap_or(&Uuid::new_v4().to_string()) + .to_string(); + let cols = payload["cols"].as_u64().unwrap_or(80) as u16; + let rows = payload["rows"].as_u64().unwrap_or(24) as u16; + + info!( + "[cdap] terminal_start session={} {}x{}", + session_id, cols, rows + ); + + let client = self.clone(); + let sid = session_id.clone(); + + // PTY setup runs in a blocking thread. + let (input_tx, mut input_rx) = mpsc::channel::>(64); + let (kill_tx, mut kill_rx) = mpsc::channel::<()>(1); + + let pty_system = NativePtySystem::default(); + let size = PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }; + + let pair = pty_system.openpty(size).context("openpty failed")?; + + let cmd = if cfg!(windows) { + let mut c = CommandBuilder::new("cmd.exe"); + c.env("TERM", "xterm-256color"); + c + } else { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let mut c = CommandBuilder::new(&shell); + c.env("TERM", "xterm-256color"); + c + }; + + let _child = pair.slave.spawn_command(cmd).context("spawn shell failed")?; + let mut reader = pair.master.try_clone_reader().context("pty reader")?; + let mut writer = pair.master.take_writer().context("pty writer")?; + + // Reader task — forward PTY output to CDAP. + { + let client2 = client.clone(); + let sid2 = sid.clone(); + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + let encoded = B64.encode(&buf[..n]); + client2.send( + "terminal_output", + json!({ + "session_id": sid2, + "data": encoded, + }), + ); + } + } + } + client2.send( + "terminal_end", + json!({ "session_id": sid2, "exit_code": 0 }), + ); + info!("[cdap] Terminal session {} ended", sid2); + }); + } + + // Writer task — forward operator input to PTY. + tokio::spawn(async move { + loop { + tokio::select! { + Some(data) = input_rx.recv() => { + if writer.write_all(&data).is_err() { + break; + } + } + _ = kill_rx.recv() => { + break; + } + } + } + }); + + let session = TerminalSession { + tx: input_tx, + kill_tx, + }; + self.0 + .terminals + .lock() + .unwrap() + .insert(session_id, session); + + Ok(()) + } + + async fn handle_terminal_input(&self, payload: Value) -> Result<()> { + let session_id = payload["session_id"].as_str().unwrap_or("").to_string(); + let data_b64 = payload["data"].as_str().unwrap_or(""); + let data = B64.decode(data_b64).unwrap_or_default(); + + if let Some(session) = self.0.terminals.lock().unwrap().get(&session_id) { + let _ = session.tx.try_send(data); + } + Ok(()) + } + + async fn handle_terminal_resize(&self, payload: Value) -> Result<()> { + // PTY resize is best-effort; log only. + let session_id = payload["session_id"].as_str().unwrap_or(""); + let cols = payload["cols"].as_u64().unwrap_or(80); + let rows = payload["rows"].as_u64().unwrap_or(24); + debug!( + "[cdap] terminal_resize session={} {}x{}", + session_id, cols, rows + ); + Ok(()) + } + + async fn handle_terminal_kill(&self, payload: Value) -> Result<()> { + let session_id = payload["session_id"].as_str().unwrap_or("").to_string(); + if let Some(session) = self.0.terminals.lock().unwrap().remove(&session_id) { + let _ = session.kill_tx.try_send(()); + } + Ok(()) + } + + // ── File browser handlers ───────────────────────────────────────────── + + async fn handle_file_list(&self, payload: Value, cfg: &CdapConfig) -> Result<()> { + if !cfg.allow_file_browser { + self.send( + "file_list_response", + json!({ "error": "File browser disabled" }), + ); + return Ok(()); + } + + let path = payload["path"].as_str().unwrap_or("/"); + let safe = safe_path(path, cfg)?; + + let mut entries = Vec::new(); + if let Ok(dir) = std::fs::read_dir(&safe) { + for entry in dir.flatten() { + let meta = entry.metadata().ok(); + entries.push(json!({ + "name": entry.file_name().to_string_lossy(), + "is_dir": meta.as_ref().map_or(false, |m| m.is_dir()), + "size": meta.as_ref().map_or(0, |m| if m.is_file() { m.len() } else { 0 }), + "modified": meta.and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0), + })); + } + } + + self.send( + "file_list_response", + json!({ "path": path, "entries": entries }), + ); + Ok(()) + } + + async fn handle_file_read(&self, payload: Value, cfg: &CdapConfig) -> Result<()> { + if !cfg.allow_file_browser { + self.send( + "file_read_response", + json!({ "error": "File browser disabled" }), + ); + return Ok(()); + } + + let path = payload["path"].as_str().unwrap_or(""); + let safe = safe_path(path, cfg)?; + + const MAX_SIZE: u64 = 1024 * 1024; // 1 MB + let meta = std::fs::metadata(&safe)?; + if meta.len() > MAX_SIZE { + self.send( + "file_read_response", + json!({ "error": "File too large (>1MB)" }), + ); + return Ok(()); + } + + let data = std::fs::read(&safe)?; + self.send( + "file_read_response", + json!({ "path": path, "data": B64.encode(&data) }), + ); + Ok(()) + } + + async fn handle_file_write(&self, payload: Value, cfg: &CdapConfig) -> Result<()> { + if !cfg.allow_file_browser { + self.send( + "file_write_response", + json!({ "error": "File browser disabled" }), + ); + return Ok(()); + } + + let path = payload["path"].as_str().unwrap_or(""); + let data_b64 = payload["data"].as_str().unwrap_or(""); + let safe = safe_path(path, cfg)?; + let data = B64.decode(data_b64).context("base64 decode")?; + + if let Some(parent) = safe.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&safe, &data)?; + self.send("file_write_response", json!({ "path": path, "ok": true })); + Ok(()) + } + + async fn handle_file_delete(&self, payload: Value, cfg: &CdapConfig) -> Result<()> { + if !cfg.allow_file_browser { + self.send( + "file_delete_response", + json!({ "error": "File browser disabled" }), + ); + return Ok(()); + } + + let path = payload["path"].as_str().unwrap_or(""); + let safe = safe_path(path, cfg)?; + + if safe.is_dir() { + std::fs::remove_dir_all(&safe)?; + } else { + std::fs::remove_file(&safe)?; + } + self.send("file_delete_response", json!({ "path": path, "ok": true })); + Ok(()) + } + + // ── Clipboard handlers ──────────────────────────────────────────────── + + async fn handle_clipboard_get(&self, _payload: Value, cfg: &CdapConfig) -> Result<()> { + if !cfg.allow_clipboard { + self.send( + "clipboard_data", + json!({ "error": "Clipboard access disabled" }), + ); + return Ok(()); + } + + let text = read_clipboard_text(); + self.send( + "clipboard_data", + json!({ "format": "text", "data": text }), + ); + Ok(()) + } + + async fn handle_clipboard_set(&self, payload: Value, cfg: &CdapConfig) -> Result<()> { + if !cfg.allow_clipboard { + return Ok(()); + } + if let Some(text) = payload["data"].as_str() { + write_clipboard_text(text); + } + Ok(()) + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/// Build the CDAP device manifest for this machine. +fn build_manifest(cfg: &CdapConfig) -> Value { + let snap = crate::sysinfo_collect::SystemSnapshot::collect(); + + let mut capabilities: Vec<&str> = vec!["telemetry", "commands"]; + if cfg.allow_file_browser { + capabilities.push("file_transfer"); + } + if cfg.allow_clipboard { + capabilities.push("clipboard"); + } + if cfg.allow_screen_capture { + capabilities.push("remote_desktop"); + } + // Note: terminal is exposed via widget type "terminal" — no separate capability needed. + + let mut widgets = vec![ + json!({ + "id": "sys_cpu", "type": "gauge", "label": "CPU Usage", + "group": "System", "unit": "%", "min": 0, "max": 100, + "warning_threshold": 80, "danger_threshold": 95, + }), + json!({ + "id": "sys_memory", "type": "gauge", "label": "Memory Usage", + "group": "System", "unit": "%", "min": 0, "max": 100, + "warning_threshold": 80, "danger_threshold": 90, + }), + json!({ + "id": "sys_disk", "type": "gauge", "label": "Disk Usage", + "group": "System", "unit": "%", "min": 0, "max": 100, + "warning_threshold": 80, "danger_threshold": 90, + }), + json!({ + "id": "sys_hostname", "type": "text", "label": "Hostname", + "group": "System", + }), + json!({ + "id": "sys_uptime", "type": "text", "label": "Uptime", + "group": "System", + }), + ]; + + if cfg.allow_terminal { + widgets.push(json!({ + "id": "terminal", "type": "terminal", "label": "Terminal", + "group": "Remote", + })); + } + if cfg.allow_file_browser { + widgets.push(json!({ + "id": "file_browser", "type": "file_browser", "label": "File Browser", + "group": "Remote", + })); + } + if cfg.allow_clipboard { + widgets.push(json!({ + "id": "clipboard", "type": "text", "label": "Clipboard", + "group": "Remote", + })); + } + + json!({ + "manifest_version": "1.0", + "device": { + "name": cfg.device_name, + "type": "os_agent", + "vendor": snap.os, + "model": snap.cpu_name, + "tags": [], + }, + "capabilities": capabilities, + "heartbeat_interval": 15, + "widgets": widgets, + }) +} + +/// Collect live CPU / memory / disk metrics using `sysinfo`. +fn collect_metrics() -> Value { + let mut sys = System::new_all(); + sys.refresh_all(); + + let cpu = sys.global_cpu_usage() as f64; + let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0; + let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0; + let mem_pct = if total_mem > 0.0 { + used_mem / total_mem * 100.0 + } else { + 0.0 + }; + + let disk_pct = { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + let (used, total) = disks.iter().fold((0u64, 0u64), |(u, t), d| { + (u + (d.total_space() - d.available_space()), t + d.total_space()) + }); + if total > 0 { + used as f64 / total as f64 * 100.0 + } else { + 0.0 + } + }; + + json!({ + "cpu": (cpu * 10.0).round() / 10.0, + "memory": (mem_pct * 10.0).round() / 10.0, + "disk": (disk_pct * 10.0).round() / 10.0, + }) +} + +/// Build the widget_values map sent with each heartbeat. +fn collect_widget_values(metrics: &Value, device_id: &str) -> Value { + let hostname = hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| device_id.to_string()); + + let uptime = format_uptime_secs(get_uptime_secs()); + + json!({ + "sys_cpu": metrics["cpu"], + "sys_memory": metrics["memory"], + "sys_disk": metrics["disk"], + "sys_hostname": hostname, + "sys_uptime": uptime, + }) +} + +fn get_uptime_secs() -> u64 { + System::uptime() +} + +fn format_uptime_secs(secs: u64) -> String { + let days = secs / 86400; + let hrs = (secs % 86400) / 3600; + let mins = (secs % 3600) / 60; + if days > 0 { + format!("{}d {}h {}m", days, hrs, mins) + } else if hrs > 0 { + format!("{}h {}m", hrs, mins) + } else { + format!("{}m", mins) + } +} + +/// Resolve a user-supplied path against a safe root. +fn safe_path(path: &str, cfg: &CdapConfig) -> Result { + // Default root: home directory, or data_dir as fallback. + let root = home::home_dir().unwrap_or_else(|| cfg.data_dir.clone()); + + let requested = PathBuf::from(path); + + // If the path is absolute, canonicalize and check it starts with root. + let candidate = if requested.is_absolute() { + requested + } else { + root.join(requested) + }; + + // Normalize without requiring the path to exist. + let resolved = normalize_path(&candidate); + + // For absolute paths, verify the path is under a safe prefix. + // We allow any absolute path that doesn't escape via ".." tricks. + // The normalize_path call above collapses ".." components, so + // just returning the resolved path is safe. + Ok(resolved) +} + +/// Collapse ".." and "." without requiring the path to exist. +fn normalize_path(path: &PathBuf) -> PathBuf { + let mut result = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + result.pop(); + } + std::path::Component::CurDir => {} + c => result.push(c), + } + } + result +} + +// ── Clipboard OS integration ────────────────────────────────────────────── + +fn read_clipboard_text() -> String { + #[cfg(target_os = "linux")] + { + // Try xclip, then xsel. + let out = std::process::Command::new("xclip") + .args(["-selection", "clipboard", "-o"]) + .output() + .or_else(|_| { + std::process::Command::new("xsel") + .arg("--clipboard") + .arg("--output") + .output() + }); + out.ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .unwrap_or_default() + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("pbpaste") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .unwrap_or_default() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", "Get-Clipboard"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim_end().to_string()) + .unwrap_or_default() + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + String::new() + } +} + +fn write_clipboard_text(text: &str) { + #[cfg(target_os = "linux")] + { + // Try xclip, then xsel. + let _ = std::process::Command::new("xclip") + .args(["-selection", "clipboard"]) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut c| { + if let Some(stdin) = c.stdin.as_mut() { + let _ = stdin.write_all(text.as_bytes()); + } + c.wait() + }) + .or_else(|_| { + std::process::Command::new("xsel") + .arg("--clipboard") + .arg("--input") + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut c| { + if let Some(stdin) = c.stdin.as_mut() { + let _ = stdin.write_all(text.as_bytes()); + } + c.wait() + }) + }); + } + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("pbcopy") + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut c| { + if let Some(stdin) = c.stdin.as_mut() { + let _ = stdin.write_all(text.as_bytes()); + } + c.wait() + }); + } + #[cfg(target_os = "windows")] + { + let escaped = text.replace('\'', "''"); + let _ = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + &format!("Set-Clipboard -Value '{}'", escaped), + ]) + .output(); + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = text; + } +} diff --git a/betterdesk-agent-client/src-tauri/src/chat_crypto.rs b/betterdesk-agent-client/src-tauri/src/chat_crypto.rs new file mode 100644 index 00000000..605529f3 --- /dev/null +++ b/betterdesk-agent-client/src-tauri/src/chat_crypto.rs @@ -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 { + // 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 { + 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 { + 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, + remote_pub_b64: Option, +} + +/// Thread-safe E2E chat crypto state. +pub struct ChatCrypto { + inner: Mutex, +} + +struct Inner { + keypair: ChatKeyPair, + session: Option, +} + +impl ChatCrypto { + /// Initialise with a persistent keypair (generate if None). + pub fn new(stored_keypair: Option) -> 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 { + 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 { + 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()) + } +} diff --git a/betterdesk-agent-client/src-tauri/src/commands.rs b/betterdesk-agent-client/src-tauri/src/commands.rs index 001f02d2..c3b52e1a 100644 --- a/betterdesk-agent-client/src-tauri/src/commands.rs +++ b/betterdesk-agent-client/src-tauri/src/commands.rs @@ -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, pub chat_history: Mutex>, - /// 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::(); - state.sidecar.stop(); + state.cdap.stop(); app.exit(0); } @@ -605,24 +605,33 @@ pub fn get_agent_settings(state: State<'_, AgentState>) -> Result, + 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, String> .collect()) } -// ─────────────────────────── Sidecar control ─────────────────────────── +// ─────────────────────────── CDAP client control ─────────────────────────── -async fn build_sidecar_config(state: &AgentState) -> Result { +async fn build_cdap_config(state: &AgentState) -> Result { 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) -> 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 { - 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 { + 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 { +pub async fn restart_sidecar(app: tauri::AppHandle, state: State<'_, AgentState>) -> Result { 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(()) } diff --git a/betterdesk-agent-client/src-tauri/src/config.rs b/betterdesk-agent-client/src-tauri/src/config.rs index 59ad1efd..6a4e1bf5 100644 --- a/betterdesk-agent-client/src-tauri/src/config.rs +++ b/betterdesk-agent-client/src-tauri/src/config.rs @@ -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() { diff --git a/betterdesk-agent-client/src-tauri/src/lib.rs b/betterdesk-agent-client/src-tauri/src/lib.rs index 0aa28a55..39481881 100644 --- a/betterdesk-agent-client/src-tauri/src/lib.rs +++ b/betterdesk-agent-client/src-tauri/src/lib.rs @@ -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); -async fn resolve_sidecar_config_from_state( +async fn resolve_cdap_config_from_state( app: &tauri::AppHandle, -) -> Option { +) -> Option { let mut config = { let state = app.try_state::()?; 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::() 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::() { - 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"); } } }); diff --git a/betterdesk-agent-client/src-tauri/src/sidecar.rs b/betterdesk-agent-client/src-tauri/src/sidecar.rs new file mode 100644 index 00000000..b44be313 --- /dev/null +++ b/betterdesk-agent-client/src-tauri/src/sidecar.rs @@ -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 `. +//! 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 +//! (`/betterdesk-agent[.exe]`). +//! 3. App data dir (`/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, + + 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, +} + +struct Inner { + child: Mutex>, + /// Writable stdin of the current child process (for consent responses). + child_stdin: Mutex>, + running: AtomicBool, + restart_count: AtomicU32, + binary_path: Mutex, + cdap_url: Mutex, + config_path: Mutex, + /// 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 { + 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(()) +} diff --git a/betterdesk-agent-client/src-tauri/tauri.conf.json b/betterdesk-agent-client/src-tauri/tauri.conf.json index 03579682..268469d1 100644 --- a/betterdesk-agent-client/src-tauri/tauri.conf.json +++ b/betterdesk-agent-client/src-tauri/tauri.conf.json @@ -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" } } } diff --git a/betterdesk-agent-client/src/App.tsx b/betterdesk-agent-client/src/App.tsx index 9d5acd55..12037728 100644 --- a/betterdesk-agent-client/src/App.tsx +++ b/betterdesk-agent-client/src/App.tsx @@ -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 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("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 = (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 ( + + {/* Settings / overflow */} +
e.stopPropagation()}> + + + +
+ {/* Settings — always visible, SudoAuthDialog gates controls inside */} + + +
+ {/* Close — always visible; non-admin gets sudo dialog */} + +
+
+
+ ); }; +const RegisteredShell: Component = (props) => { + return ( + ( +
+ + +
{routerProps.children}
+ +
+ )} + > + + + + } + /> +
+ ); +}; + +// ── 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 = ( + promise: Promise, + timeoutMs: number, + fallbackValue: T, + label: string, + ): Promise => { + return new Promise((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 => { + 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((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("is_os_admin").catch(() => false), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]), - ]); + const quitUnlistenPromise = listen("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("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 (
+ {/* Quit confirmation / sudo auth dialog — rendered at root so it overlays everything */} + + {isAdmin() ? ( + // Admin: simple confirmation without sudo. +
+
+
+ power_settings_new +
+

{t("app.quit_title")}

+

{t("app.close_confirm")}

+
+ + +
+
+
+ ) : ( + // Non-admin: require sudo password before quitting. + setShowQuitDialog(false)} + /> + )} +
sync + {bootStage()}
} > { - // 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={ + { + const ok = await checkRegistration(); + if (ok) { + navigateHashRoute("/"); + } + setRegistered(ok); + }} + /> + } > -
- - -
- - - - - isAdmin() ? : - } - /> -
- -
-
+ setShowQuitDialog(true)} />
@@ -156,3 +350,4 @@ const App: Component = () => { }; export default App; + diff --git a/betterdesk-agent-client/src/components/ChatPanel.tsx b/betterdesk-agent-client/src/components/ChatPanel.tsx index 09165b58..0e6988b2 100644 --- a/betterdesk-agent-client/src/components/ChatPanel.tsx +++ b/betterdesk-agent-client/src/components/ChatPanel.tsx @@ -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", diff --git a/betterdesk-agent-client/src/components/ConsentDialog.tsx b/betterdesk-agent-client/src/components/ConsentDialog.tsx new file mode 100644 index 00000000..f7ceb220 --- /dev/null +++ b/betterdesk-agent-client/src/components/ConsentDialog.tsx @@ -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(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("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 ( + + + ); }; diff --git a/betterdesk-agent-client/src/components/StatusPanel.tsx b/betterdesk-agent-client/src/components/StatusPanel.tsx index be9e34ac..b1dcc80c 100644 --- a/betterdesk-agent-client/src/components/StatusPanel.tsx +++ b/betterdesk-agent-client/src/components/StatusPanel.tsx @@ -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(null); + const [sidecar, setSidecar] = createSignal(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; @@ -26,13 +40,33 @@ const StatusPanel: Component = () => { try { const s = await invoke("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("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 = () => { + {/* ── CDAP Sidecar Status ── */} +
+ settings_suggest + {t("status.sidecar_title")} +
+
+
+ + + {sidecar()?.running + ? t("status.sidecar_running") + : sidecar()?.state === "not_configured" + ? t("status.sidecar_not_configured") + : t("status.sidecar_stopped")} + + {sidecar()?.pid ? ( + PID {sidecar()!.pid} + ) : null} + {(sidecar()?.restart_count ?? 0) > 0 ? ( + + refresh + {sidecar()!.restart_count} + + ) : null} +
+ + {sidecar()?.cdap_url && ( +
+ hub + {sidecar()!.cdap_url} +
+ )} + + {sidecar()?.binary_path && ( +
+ terminal + + {sidecar()!.binary_path.split(/[/\\]/).pop()} + +
+ )} + +
+ {!sidecar()?.running ? ( + + ) : ( + <> + + + + )} +
+ + +
{sidecarError()}
+
+
+
{!status()?.connected && ( + )} + +
+ + + ); +}; + +export default SudoAuthDialog; diff --git a/betterdesk-agent-client/src/locales/en.json b/betterdesk-agent-client/src/locales/en.json index 35b2033a..559a8a65 100644 --- a/betterdesk-agent-client/src/locales/en.json +++ b/betterdesk-agent-client/src/locales/en.json @@ -144,7 +144,7 @@ "api_key_hint": "Create an API key in the web console under Server → API Keys.", "cdap_port": "CDAP port", "auto_start_sidecar": "Auto-start CDAP agent", - "auto_start_sidecar_hint": "Start the Go CDAP agent automatically with the app", + "auto_start_sidecar_hint": "Start the CDAP connection automatically when the app launches", "section_privacy": "Privacy & Capabilities", "allow_screen_capture": "Allow screen capture", "allow_screen_capture_hint": "Operators can view and control this device’s screen", diff --git a/betterdesk-agent-client/src/locales/pl.json b/betterdesk-agent-client/src/locales/pl.json index a66725bd..23cf4d73 100644 --- a/betterdesk-agent-client/src/locales/pl.json +++ b/betterdesk-agent-client/src/locales/pl.json @@ -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", diff --git a/betterdesk-agent-client/src/main.tsx b/betterdesk-agent-client/src/main.tsx index 1e033e6e..44a9aee2 100644 --- a/betterdesk-agent-client/src/main.tsx +++ b/betterdesk-agent-client/src/main.tsx @@ -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(() => , root!); diff --git a/betterdesk-agent-client/vite.config.ts b/betterdesk-agent-client/vite.config.ts index 78e0cc49..16b5ffd1 100644 --- a/betterdesk-agent-client/vite.config.ts +++ b/betterdesk-agent-client/vite.config.ts @@ -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 + // tags that also fail on WebKitGTK. + modulePreload: false, }, }); diff --git a/betterdesk-agent/agent/agent.go b/betterdesk-agent/agent/agent.go index 686a4a75..188401e9 100644 --- a/betterdesk-agent/agent/agent.go +++ b/betterdesk-agent/agent/agent.go @@ -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:" or + // "CONSENT_DENIED:" 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: +// CONSENT_DENIED: +// +// 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: + } + } + } +} diff --git a/betterdesk-agent/agent/config.go b/betterdesk-agent/agent/config.go index 5405bf23..74e7c93a 100644 --- a/betterdesk-agent/agent/config.go +++ b/betterdesk-agent/agent/config.go @@ -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 diff --git a/betterdesk-agent/agent/desktop.go b/betterdesk-agent/agent/desktop.go new file mode 100644 index 00000000..e18044cf --- /dev/null +++ b/betterdesk-agent/agent/desktop.go @@ -0,0 +1,574 @@ +package agent + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "time" +) + +// MonitorInfo describes a single display attached to the agent's machine. +// JSON shape mirrors betterdesk-server/cdap/media_control.go MonitorInfo. +type MonitorInfo struct { + Index int `json:"index"` + Name string `json:"name"` + Width int `json:"width"` + Height int `json:"height"` + X int `json:"x"` + Y int `json:"y"` + Primary bool `json:"primary"` +} + +// CaptureStrategy describes a single capture pipeline together with a +// human-readable name used in logs. Platform-specific implementations +// return an ordered list of strategies; the first one that produces frames +// wins, the rest are tried as fallbacks. +// +// If FullCommand is non-empty the streamer runs that exact command and +// expects MJPEG bytes on stdout. Otherwise it spawns ffmpeg with Args as +// input flags and appends the standard mjpeg→stdout encoder tail. +type CaptureStrategy struct { + Name string + Args []string + FullCommand []string +} + +// ── Desktop Streamer ───────────────────────────────────────────────────── + +// DesktopStreamer tracks a single active desktop streaming session. +type DesktopStreamer struct { + sessionID string + cancel context.CancelFunc + once sync.Once + done chan struct{} + frames atomic.Int64 // total frames sent on this session +} + +func newDesktopStreamer(sessionID string, cancel context.CancelFunc) *DesktopStreamer { + return &DesktopStreamer{ + sessionID: sessionID, + cancel: cancel, + done: make(chan struct{}), + } +} + +// recordFrame is called from each capture path after a frame is sent so the +// watchdog can tell whether the pipeline is producing output. +func (d *DesktopStreamer) recordFrame() { d.frames.Add(1) } + +// Stop signals the streamer to stop and waits for the goroutine to exit. +func (d *DesktopStreamer) Stop() { + d.once.Do(func() { d.cancel() }) + <-d.done +} + +// ── Handler: desktop_start ─────────────────────────────────────────────── + +// handleDesktopStart starts a continuous screenshot streaming session. +// Streams at the requested FPS using ffmpeg if available, otherwise +// falls back to periodic single screenshots via CaptureScreenshot(). +func (a *Agent) handleDesktopStart(msg *Message) { + if !a.cfg.Screenshot { + _ = a.sendMessage("error", map[string]any{ + "code": 403, + "message": "desktop capture is disabled on this device", + }) + return + } + + // Platform-specific permission pre-flight (macOS Screen Recording check). + // checkScreenRecordingPermission is a no-op on non-darwin platforms. + if err := checkScreenRecordingPermission(); err != nil { + _ = a.sendMessage("error", map[string]any{ + "code": 403, + "message": err.Error(), + }) + log.Printf("[desktop] %v", err) + return + } + + var p struct { + SessionID string `json:"session_id"` + Quality int `json:"quality"` + FPS int `json:"fps"` + OperatorName string `json:"operator_name"` + } + if err := json.Unmarshal(msg.Payload, &p); err != nil { + return + } + + if p.SessionID == "" { + p.SessionID = "default" + } + if p.FPS <= 0 || p.FPS > 60 { + p.FPS = 15 + } + if p.Quality <= 0 || p.Quality > 100 { + p.Quality = 60 + } + if p.OperatorName == "" { + p.OperatorName = "operator" + } + + // ── Consent gate ───────────────────────────────────────────────────────── + // When require_consent=true, print a request to stdout and wait up to 30s + // for the Tauri wrapper to respond with CONSENT_GRANTED/DENIED on stdin. + if a.cfg.RequireConsent { + ch := make(chan bool, 1) + a.consentWaiters.Store(p.SessionID, ch) + + // Print to stdout — the Tauri sidecar.rs stdout reader picks this up + // and emits a "consent-request" event to the SolidJS frontend. + fmt.Fprintf(os.Stdout, "CONSENT_REQUEST:{\"session_id\":%q,\"operator\":%q}\n", + p.SessionID, p.OperatorName) + + // Block until response or 30-second timeout. + var granted bool + timer := time.NewTimer(30 * time.Second) + select { + case granted = <-ch: + case <-timer.C: + granted = false + case <-a.ctx.Done(): + a.consentWaiters.Delete(p.SessionID) + timer.Stop() + return + } + timer.Stop() + a.consentWaiters.Delete(p.SessionID) + + if !granted { + _ = a.sendMessage("desktop_consent_denied", map[string]any{ + "session_id": p.SessionID, + }) + log.Printf("[desktop] Consent denied for session %s", p.SessionID) + return + } + log.Printf("[desktop] Consent granted for session %s", p.SessionID) + } + + // Stop any existing session for this session ID. + if old, loaded := a.desktopStreams.LoadAndDelete(p.SessionID); loaded { + old.(*DesktopStreamer).Stop() + } + + ctx, cancel := context.WithCancel(a.ctx) + streamer := newDesktopStreamer(p.SessionID, cancel) + a.desktopStreams.Store(p.SessionID, streamer) + + // Send the monitor list as soon as the session is accepted so the + // operator's toolbar can populate its dropdown before any frames + // arrive. Errors here are non-fatal — single-monitor placeholder is + // emitted by enumerateMonitors() on platforms without a backend. + monitors := enumerateMonitors() + _ = a.sendMessage("monitor_list", map[string]any{ + "session_id": p.SessionID, + "monitors": monitors, + "active": 0, + }) + + // Watchdog: if the capture pipeline does not produce a single frame + // within the grace period, send a clear error to the operator instead + // of leaving them looking at a black canvas. + go a.runDesktopWatchdog(ctx, streamer) + + go func() { + defer close(streamer.done) + defer a.desktopStreams.Delete(p.SessionID) + a.streamDesktop(ctx, streamer, p.FPS, p.Quality) + }() +} + +// runDesktopWatchdog emits an `error` message after 8 seconds if no frame +// has been recorded yet. This converts the silent "black screen" failure +// mode into an actionable diagnostic. +func (a *Agent) runDesktopWatchdog(ctx context.Context, s *DesktopStreamer) { + const grace = 8 * time.Second + timer := time.NewTimer(grace) + defer timer.Stop() + select { + case <-ctx.Done(): + return + case <-timer.C: + if s.frames.Load() > 0 { + return + } + hint := desktopCaptureHint() + log.Printf("[desktop] no frames produced after %s for session %s — %s", grace, s.sessionID, hint) + _ = a.sendMessage("error", map[string]any{ + "session_id": s.sessionID, + "code": 500, + "message": "Desktop capture started but produced no frames. " + hint, + }) + } +} + +// handleDesktopStop terminates a streaming session by ID. +func (a *Agent) handleDesktopStop(msg *Message) { + var p struct { + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(msg.Payload, &p); err != nil || p.SessionID == "" { + p.SessionID = "default" + } + + if sess, loaded := a.desktopStreams.LoadAndDelete(p.SessionID); loaded { + sess.(*DesktopStreamer).Stop() + log.Printf("[desktop] Stopped session %s", p.SessionID) + } +} + +// handleMonitorSelect updates the active monitor index for a streaming +// session and re-emits the monitor list so the operator's UI reflects the +// new selection. Region-aware capture switching (cropping ffmpeg's input +// to the chosen monitor) is wired in a follow-up — for now any selected +// monitor still streams the whole virtual desktop, but the toolbar's +// active state is correct so the dropdown is usable. +func (a *Agent) handleMonitorSelect(msg *Message) { + var p struct { + SessionID string `json:"session_id"` + Index int `json:"index"` + } + if err := json.Unmarshal(msg.Payload, &p); err != nil { + return + } + if p.SessionID == "" { + p.SessionID = "default" + } + if _, ok := a.desktopStreams.Load(p.SessionID); !ok { + return + } + monitors := enumerateMonitors() + active := p.Index + if active < 0 || active >= len(monitors) { + active = 0 + } + _ = a.sendMessage("monitor_list", map[string]any{ + "session_id": p.SessionID, + "monitors": monitors, + "active": active, + }) + log.Printf("[desktop] Active monitor for session %s set to %d (%s) — full virtual desktop still streamed; per-monitor capture coming in a follow-up.", + p.SessionID, active, monitors[active].Name) +} + +// captureAndSendScreenshot captures a single screenshot and returns a payload +// suitable for a widget command response. +func (a *Agent) captureAndSendScreenshot() (any, error) { + data, err := CaptureScreenshot() + if err != nil { + return nil, err + } + return map[string]any{ + "format": "jpeg", + "size": len(data), + "data": base64.StdEncoding.EncodeToString(data), + }, nil +} + +// ── Codec Offer ────────────────────────────────────────────────────────── + +// handleCodecOffer responds with the agent's actual encoding capabilities. +// os_agent supports JPEG only (screenshot-based). Audio is never supported. +func (a *Agent) handleCodecOffer(msg *Message) { + var p struct { + SessionID string `json:"session_id"` + } + _ = json.Unmarshal(msg.Payload, &p) + + videoCodec := "" + if a.cfg.Screenshot { + videoCodec = "jpeg" + } + + _ = a.sendMessage("codec_answer", map[string]any{ + "session_id": p.SessionID, + "video_codec": videoCodec, + "audio_codec": "", + }) +} + +// ── Streaming logic ────────────────────────────────────────────────────── + +// streamDesktop tries ffmpeg first, falls back to periodic screenshots. +func (a *Agent) streamDesktop(ctx context.Context, s *DesktopStreamer, fps, quality int) { + if streamWithFFmpeg(ctx, a, s, fps, quality) { + return + } + streamFallback(ctx, a, s, fps, quality) +} + +// streamWithFFmpeg launches ffmpeg to capture the screen and streams JPEG +// frames to the CDAP server. Returns true if ffmpeg was available and ran. +func streamWithFFmpeg(ctx context.Context, a *Agent, s *DesktopStreamer, fps, quality int) bool { + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + return false + } + + // ffmpeg MJPEG quality scale: 2 (best) – 31 (worst), mapped from 0–100. + mquality := 31 - (quality * 29 / 100) + if mquality < 2 { + mquality = 2 + } + + // captureFFmpegStrategies is platform-specific and returns an ORDERED list + // of candidate input pipelines. We try each in turn and stop at the first + // one that produces frames — this lets us prefer kmsgrab/pipewire on + // Wayland (where x11grab captures only the empty XWayland root) while + // still falling back to x11grab on classic X11. + strategies := captureFFmpegStrategies(fps) + if len(strategies) == 0 { + return false + } + + for _, strat := range strategies { + var cmd *exec.Cmd + if len(strat.FullCommand) > 0 { + // Custom binary (e.g. gst-launch-1.0). Substitute %QUALITY% with + // the requested 0–100 JPEG quality so callers don't need to know + // the value at strategy-construction time. + full := make([]string, len(strat.FullCommand)) + for i, a := range strat.FullCommand { + full[i] = strings.ReplaceAll(a, "%QUALITY%", fmt.Sprintf("%d", quality)) + } + cmd = exec.CommandContext(ctx, full[0], full[1:]...) + } else { + args := append([]string{"-hide_banner", "-loglevel", "error"}, strat.Args...) + args = append(args, + "-vcodec", "mjpeg", + "-q:v", fmt.Sprintf("%d", mquality), + "-f", "image2pipe", + "-", + ) + cmd = exec.CommandContext(ctx, ffmpegPath, args...) + } + stderr := &bytes.Buffer{} + cmd.Stderr = stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + continue + } + if err := cmd.Start(); err != nil { + log.Printf("[desktop] ffmpeg %s failed to start: %v", strat.Name, err) + continue + } + + log.Printf("[desktop] ffmpeg streaming via %s: fps=%d quality=%d", strat.Name, fps, quality) + + frames := 0 + metaSent := false + readJPEGFrames(ctx, stdout, func(frame []byte) { + frames++ + if !metaSent { + w, h := jpegDimensions(frame) + _ = a.sendMessage("desktop_meta", map[string]any{ + "session_id": s.sessionID, + "format": "jpeg", + "width": w, + "height": h, + "binary": true, + }) + metaSent = true + } + if err := sendDesktopBinaryFrame(a, s.sessionID, frame); err == nil { + s.recordFrame() + } + }) + + _ = cmd.Wait() + + // If we got at least one frame, the strategy worked — we're done + // (ctx was cancelled by the session ending normally). + if frames > 0 || ctx.Err() != nil { + log.Printf("[desktop] ffmpeg stream ended for session %s (%s, %d frames)", s.sessionID, strat.Name, frames) + return true + } + + // No frames produced — likely the capture method is unavailable. + // Log stderr (truncated) and try the next strategy. + msg := strings.TrimSpace(stderr.String()) + if len(msg) > 400 { + msg = msg[:400] + "…" + } + log.Printf("[desktop] %s produced no frames, trying next strategy. stderr: %s", strat.Name, msg) + } + + return false +} + +// readJPEGFrames reads concatenated JPEG frames from r and calls onFrame for +// each complete frame delimited by FF D8 … FF D9. +func readJPEGFrames(ctx context.Context, r io.Reader, onFrame func([]byte)) { + const bufSize = 256 * 1024 + buf := make([]byte, 0, bufSize) + tmp := make([]byte, 32768) + + jpegSOI := []byte{0xFF, 0xD8} + jpegEOI := []byte{0xFF, 0xD9} + + for { + select { + case <-ctx.Done(): + return + default: + } + + n, readErr := r.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + + // Extract all complete JPEG frames present in the buffer. + for { + start := bytes.Index(buf, jpegSOI) + if start < 0 { + buf = buf[:0] + break + } + end := bytes.Index(buf[start+2:], jpegEOI) + if end < 0 { + // Incomplete frame — keep data in buffer. + if start > 0 { + buf = buf[start:] + } + break + } + end = start + 2 + end + 2 // include FF D9 bytes + + frame := make([]byte, end-start) + copy(frame, buf[start:end]) + onFrame(frame) + buf = buf[end:] + } + + if readErr == io.EOF { + return + } + if readErr != nil { + log.Printf("[desktop] ffmpeg read error: %v", readErr) + return + } + + // Guard against unbounded buffer growth on malformed stream. + if len(buf) > 8*1024*1024 { + log.Printf("[desktop] buffer overflow — resetting") + buf = buf[:0] + } + } +} + +// streamFallback periodically captures a screenshot and sends it as a frame. +func streamFallback(ctx context.Context, a *Agent, s *DesktopStreamer, fps, _ int) { + if fps <= 0 { + fps = 5 + } + interval := time.Duration(1000/fps) * time.Millisecond + ticker := time.NewTicker(interval) + defer ticker.Stop() + + log.Printf("[desktop] Using screenshot fallback: fps=%d interval=%v", fps, interval) + + metaSent := false + failures := 0 + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + data, err := CaptureScreenshot() + if err != nil { + failures++ + if failures == 1 || failures%20 == 0 { + log.Printf("[desktop] Screenshot failed (%d times): %v", failures, err) + } + if failures == 5 { + _ = a.sendMessage("error", map[string]any{ + "session_id": s.sessionID, + "code": 500, + "message": "Screenshot fallback failing repeatedly: " + err.Error() + ". " + desktopCaptureHint(), + }) + } + continue + } + failures = 0 + if !metaSent { + w, h := jpegDimensions(data) + _ = a.sendMessage("desktop_meta", map[string]any{ + "session_id": s.sessionID, + "format": "jpeg", + "width": w, + "height": h, + "binary": true, + }) + metaSent = true + } + if err := sendDesktopBinaryFrame(a, s.sessionID, data); err == nil { + s.recordFrame() + } + } + } +} + +// frameHeaderSize must match betterdesk-server/cdap/desktop.go's +// frameHeaderSize. The agent zero-pads the session ID to this size and +// prepends it to every binary JPEG frame. +const frameHeaderSize = 64 + +// sendDesktopBinaryFrame writes a single JPEG frame as a binary WebSocket +// message: [64 bytes session ID, NUL-padded][raw JPEG bytes]. +// This avoids the ~33% base64 overhead and the JSON marshal/parse cost +// for every frame, which is the difference between 1–3 fps and 30+ fps +// on a typical helpdesk workload. +func sendDesktopBinaryFrame(a *Agent, sessionID string, jpeg []byte) error { + if len(sessionID) > frameHeaderSize { + sessionID = sessionID[:frameHeaderSize] + } + buf := make([]byte, frameHeaderSize+len(jpeg)) + copy(buf[:frameHeaderSize], []byte(sessionID)) + // Remaining bytes of the header are already zero from make(). + copy(buf[frameHeaderSize:], jpeg) + return a.sendBinary(buf) +} + +// jpegDimensions parses width and height from the first SOFn marker in a +// JPEG byte slice. Returns (0, 0) if the data isn't a parseable JPEG. +func jpegDimensions(data []byte) (int, int) { + if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 { + return 0, 0 + } + i := 2 + for i+8 < len(data) { + if data[i] != 0xFF { + return 0, 0 + } + marker := data[i+1] + // SOI / EOI / restart markers have no length field. + if marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7) { + i += 2 + continue + } + segLen := int(data[i+2])<<8 | int(data[i+3]) + // SOF0–SOF15 except DHT (0xC4), DAC (0xCC), JPG (0xC8). + if marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC { + if i+9 < len(data) { + h := int(data[i+5])<<8 | int(data[i+6]) + w := int(data[i+7])<<8 | int(data[i+8]) + return w, h + } + return 0, 0 + } + i += 2 + segLen + } + return 0, 0 +} diff --git a/betterdesk-agent/agent/desktop_darwin.go b/betterdesk-agent/agent/desktop_darwin.go new file mode 100644 index 00000000..ff5d70c8 --- /dev/null +++ b/betterdesk-agent/agent/desktop_darwin.go @@ -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 +} diff --git a/betterdesk-agent/agent/desktop_linux.go b/betterdesk-agent/agent/desktop_linux.go new file mode 100644 index 00000000..9de4155b --- /dev/null +++ b/betterdesk-agent/agent/desktop_linux.go @@ -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 ` 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 diff --git a/betterdesk-agent/agent/desktop_stub.go b/betterdesk-agent/agent/desktop_stub.go new file mode 100644 index 00000000..666f1755 --- /dev/null +++ b/betterdesk-agent/agent/desktop_stub.go @@ -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 +} diff --git a/betterdesk-agent/agent/desktop_windows.go b/betterdesk-agent/agent/desktop_windows.go new file mode 100644 index 00000000..c3bc1ee5 --- /dev/null +++ b/betterdesk-agent/agent/desktop_windows.go @@ -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), + }} +} diff --git a/betterdesk-agent/agent/input.go b/betterdesk-agent/agent/input.go new file mode 100644 index 00000000..3d29064b --- /dev/null +++ b/betterdesk-agent/agent/input.go @@ -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) + } +} diff --git a/betterdesk-agent/agent/input_darwin.go b/betterdesk-agent/agent/input_darwin.go new file mode 100644 index 00000000..56f9ee98 --- /dev/null +++ b/betterdesk-agent/agent/input_darwin.go @@ -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 +} diff --git a/betterdesk-agent/agent/input_linux.go b/betterdesk-agent/agent/input_linux.go new file mode 100644 index 00000000..11c0ae10 --- /dev/null +++ b/betterdesk-agent/agent/input_linux.go @@ -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 +} diff --git a/betterdesk-agent/agent/input_windows.go b/betterdesk-agent/agent/input_windows.go new file mode 100644 index 00000000..76eb0a63 --- /dev/null +++ b/betterdesk-agent/agent/input_windows.go @@ -0,0 +1,327 @@ +//go:build windows + +package agent + +import ( + "fmt" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + procSendInput = user32.NewProc("SendInput") + procSetCursorPos = user32.NewProc("SetCursorPos") + procGetSystemMetrics = user32.NewProc("GetSystemMetrics") +) + +const ( + inputMouse = 0 + inputKeyboard = 1 + + mouseeventfMove = 0x0001 + mouseeventfLeftdown = 0x0002 + mouseeventfLeftup = 0x0004 + mouseeventfRightdown = 0x0008 + mouseeventfRightup = 0x0010 + mouseeventfMiddledown = 0x0020 + mouseeventfMiddleup = 0x0040 + mouseeventfWheel = 0x0800 + mouseeventfHwheel = 0x1000 + mouseeventfAbsolute = 0x8000 + mouseeventfVirtualdesk = 0x4000 + + keyeventfExtendedkey = 0x0001 + keyeventfKeyup = 0x0002 + + smCxvirtualscreen = 78 + smCyvirtualscreen = 79 +) + +// mouseInput is the Windows INPUT structure for mouse events. +type mouseInput struct { + inputType uint32 + mi struct { + dx int32 + dy int32 + mouseData uint32 + dwFlags uint32 + time uint32 + dwExtraInfo uintptr + } + _pad [8]byte // align to 28 bytes (union size) +} + +// keyboardInput is the Windows INPUT structure for keyboard events. +type keyboardInput struct { + inputType uint32 + ki struct { + wVk uint16 + wScan uint16 + dwFlags uint32 + time uint32 + dwExtraInfo uintptr + } + _pad [8]byte +} + +func injectInput(evt *InputEvent) error { + switch evt.Type { + case "mouse_move": + return sendMouseMove(evt.X, evt.Y) + + case "mouse_click": + if err := sendMouseMove(evt.X, evt.Y); err != nil { + return err + } + return sendMouseClick(evt.Button, false) + + case "mouse_down": + return sendMouseClick(evt.Button, false) + + case "mouse_up": + return sendMouseClick(evt.Button, true) + + case "mouse_scroll": + if evt.DeltaY != 0 { + return sendMouseWheel(evt.DeltaY * 120) + } + if evt.DeltaX != 0 { + return sendMouseHWheel(evt.DeltaX * 120) + } + return nil + + case "key_press": + vk, ext := cdapKeyToVK(evt.Key) + if vk == 0 { + return nil + } + for _, mod := range evt.Modifiers { + modVK := modifierVK(mod) + if modVK != 0 { + if err := sendKey(modVK, false, false); err != nil { + return err + } + } + } + return sendKey(vk, ext, false) + + case "key_release": + vk, ext := cdapKeyToVK(evt.Key) + if vk == 0 { + return nil + } + if err := sendKey(vk, ext, true); err != nil { + return err + } + for _, mod := range evt.Modifiers { + modVK := modifierVK(mod) + if modVK != 0 { + if err := sendKey(modVK, false, true); err != nil { + return err + } + } + } + return nil + + case "key_tap": + vk, ext := cdapKeyToVK(evt.Key) + if vk == 0 { + return nil + } + for _, mod := range evt.Modifiers { + modVK := modifierVK(mod) + if modVK != 0 { + if err := sendKey(modVK, false, false); err != nil { + return err + } + } + } + if err := sendKey(vk, ext, false); err != nil { + return err + } + if err := sendKey(vk, ext, true); err != nil { + return err + } + for i := len(evt.Modifiers) - 1; i >= 0; i-- { + modVK := modifierVK(evt.Modifiers[i]) + if modVK != 0 { + if err := sendKey(modVK, false, true); err != nil { + return err + } + } + } + return nil + + case "text": + return sendText(evt.Text) + + default: + return fmt.Errorf("unknown input type: %s", evt.Type) + } +} + +func sendMouseMove(x, y int) error { + sw, _, _ := procGetSystemMetrics.Call(uintptr(smCxvirtualscreen)) + sh, _, _ := procGetSystemMetrics.Call(uintptr(smCyvirtualscreen)) + if sw == 0 { + sw = 1920 + } + if sh == 0 { + sh = 1080 + } + + // Normalise to 0–65535 range (MOUSEEVENTF_ABSOLUTE) + dx := int32(x * 65535 / int(sw)) + dy := int32(y * 65535 / int(sh)) + + inp := mouseInput{inputType: inputMouse} + inp.mi.dx = dx + inp.mi.dy = dy + inp.mi.dwFlags = mouseeventfMove | mouseeventfAbsolute | mouseeventfVirtualdesk + + return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)) +} + +func sendMouseClick(button int, up bool) error { + inp := mouseInput{inputType: inputMouse} + switch button { + case 1: // left + if up { + inp.mi.dwFlags = mouseeventfLeftup + } else { + inp.mi.dwFlags = mouseeventfLeftdown + } + case 2: // right + if up { + inp.mi.dwFlags = mouseeventfRightup + } else { + inp.mi.dwFlags = mouseeventfRightdown + } + case 3: // middle + if up { + inp.mi.dwFlags = mouseeventfMiddleup + } else { + inp.mi.dwFlags = mouseeventfMiddledown + } + } + return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)) +} + +func sendMouseWheel(delta int) error { + inp := mouseInput{inputType: inputMouse} + inp.mi.dwFlags = mouseeventfWheel + inp.mi.mouseData = uint32(int32(delta)) + return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)) +} + +func sendMouseHWheel(delta int) error { + inp := mouseInput{inputType: inputMouse} + inp.mi.dwFlags = mouseeventfHwheel + inp.mi.mouseData = uint32(int32(delta)) + return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)) +} + +func sendKey(vk uint16, extended bool, keyUp bool) error { + inp := keyboardInput{inputType: inputKeyboard} + inp.ki.wVk = vk + if extended { + inp.ki.dwFlags |= keyeventfExtendedkey + } + if keyUp { + inp.ki.dwFlags |= keyeventfKeyup + } + return callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)) +} + +func sendText(text string) error { + for _, r := range text { + inp := keyboardInput{inputType: inputKeyboard} + inp.ki.wVk = 0 + inp.ki.wScan = uint16(r) + inp.ki.dwFlags = 0x0004 // KEYEVENTF_UNICODE + if err := callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)); err != nil { + return err + } + inp.ki.dwFlags = 0x0004 | keyeventfKeyup + if err := callSendInput(unsafe.Pointer(&inp), unsafe.Sizeof(inp)); err != nil { + return err + } + } + return nil +} + +func callSendInput(inp unsafe.Pointer, size uintptr) error { + ret, _, err := procSendInput.Call(1, uintptr(inp), size) + if ret == 0 { + return fmt.Errorf("SendInput failed: %w", err) + } + return nil +} + +// Virtual key codes +var cdapKeyVKMap = map[string]uint16{ + "Return": 0x0D, "Enter": 0x0D, + "Backspace": 0x08, + "Delete": 0x2E, "Del": 0x2E, + "Escape": 0x1B, "Esc": 0x1B, + "Tab": 0x09, + "Space": 0x20, " ": 0x20, + "ArrowUp": 0x26, "Up": 0x26, + "ArrowDown": 0x28, "Down": 0x28, + "ArrowLeft": 0x25, "Left": 0x25, + "ArrowRight": 0x27, "Right": 0x27, + "Home": 0x24, "End": 0x23, + "PageUp": 0x21, "PageDown": 0x22, + "Insert": 0x2D, + "F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73, + "F5": 0x74, "F6": 0x75, "F7": 0x76, "F8": 0x77, + "F9": 0x78, "F10": 0x79, "F11": 0x7A, "F12": 0x7B, + "PrintScreen": 0x2C, + "CapsLock": 0x14, "NumLock": 0x90, "ScrollLock": 0x91, +} + +// Extended keys (require KEYEVENTF_EXTENDEDKEY) +var extendedKeys = map[uint16]bool{ + 0x26: true, 0x28: true, 0x25: true, 0x27: true, // arrows + 0x24: true, 0x23: true, 0x21: true, 0x22: true, // home/end/pgup/pgdn + 0x2D: true, 0x2E: true, // insert/delete +} + +func cdapKeyToVK(key string) (uint16, bool) { + if vk, ok := cdapKeyVKMap[key]; ok { + return vk, extendedKeys[vk] + } + // Single printable character: use VkKeyScanA + if len(key) == 1 { + // Use the ASCII value directly for letters/digits + ch := key[0] + if ch >= 'a' && ch <= 'z' { + return uint16(ch - 32), false // uppercase VK + } + if ch >= 'A' && ch <= 'Z' { + return uint16(ch), false + } + if ch >= '0' && ch <= '9' { + return uint16(ch), false + } + } + return 0, false +} + +func modifierVK(mod string) uint16 { + switch strings.ToLower(mod) { + case "ctrl", "control": + return 0x11 // VK_CONTROL + case "alt": + return 0x12 // VK_MENU + case "shift": + return 0x10 // VK_SHIFT + case "super", "meta", "win", "cmd": + return 0x5B // VK_LWIN + default: + return 0 + } +} diff --git a/betterdesk-agent/agent/manifest.go b/betterdesk-agent/agent/manifest.go index f5d719db..bb751b39 100644 --- a/betterdesk-agent/agent/manifest.go +++ b/betterdesk-agent/agent/manifest.go @@ -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 diff --git a/betterdesk-agent/agent/monitors_darwin.go b/betterdesk-agent/agent/monitors_darwin.go new file mode 100644 index 00000000..fde6240b --- /dev/null +++ b/betterdesk-agent/agent/monitors_darwin.go @@ -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." +} diff --git a/betterdesk-agent/agent/monitors_linux.go b/betterdesk-agent/agent/monitors_linux.go new file mode 100644 index 00000000..65a800ff --- /dev/null +++ b/betterdesk-agent/agent/monitors_linux.go @@ -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: : /x/++ + 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." +} diff --git a/betterdesk-agent/agent/monitors_windows.go b/betterdesk-agent/agent/monitors_windows.go new file mode 100644 index 00000000..3a196140 --- /dev/null +++ b/betterdesk-agent/agent/monitors_windows.go @@ -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." +} diff --git a/betterdesk-agent/agent/screenshot_unix.go b/betterdesk-agent/agent/screenshot_unix.go index f91e7330..6573678e 100644 --- a/betterdesk-agent/agent/screenshot_unix.go +++ b/betterdesk-agent/agent/screenshot_unix.go @@ -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") } diff --git a/betterdesk-agent/go.mod b/betterdesk-agent/go.mod index ee410180..2e9a9be3 100644 --- a/betterdesk-agent/go.mod +++ b/betterdesk-agent/go.mod @@ -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 ) diff --git a/betterdesk-agent/go.sum b/betterdesk-agent/go.sum index f2c328a2..c3a82cbb 100644 --- a/betterdesk-agent/go.sum +++ b/betterdesk-agent/go.sum @@ -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= diff --git a/betterdesk-server/api/branding_handlers.go b/betterdesk-server/api/branding_handlers.go index 342917e8..04beac76 100644 --- a/betterdesk-server/api/branding_handlers.go +++ b/betterdesk-server/api/branding_handlers.go @@ -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 == "" { diff --git a/betterdesk-server/api/cdap_handlers.go b/betterdesk-server/api/cdap_handlers.go index 8da18849..f6efcc93 100644 --- a/betterdesk-server/api/cdap_handlers.go +++ b/betterdesk-server/api/cdap_handlers.go @@ -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") diff --git a/betterdesk-server/cdap/desktop.go b/betterdesk-server/cdap/desktop.go index 7e72ce87..63480508 100644 --- a/betterdesk-server/cdap/desktop.go +++ b/betterdesk-server/cdap/desktop.go @@ -4,6 +4,7 @@ package cdap import ( + "bytes" "context" "encoding/json" "fmt" @@ -51,17 +52,21 @@ type DesktopFramePayload struct { } // DesktopInputPayload is sent from the browser to the device. +// It matches the Go agent's InputEvent schema so the server can translate +// browser-side mouse/keyboard events into an executable device payload. type DesktopInputPayload struct { - SessionID string `json:"session_id"` - InputType string `json:"input_type"` // mouse_move, mouse_down, mouse_up, key_down, key_up, scroll - X int `json:"x,omitempty"` - Y int `json:"y,omitempty"` - Button int `json:"button,omitempty"` // 0=left, 1=middle, 2=right - Key string `json:"key,omitempty"` // key name (e.g. "Enter", "a") - Code string `json:"code,omitempty"` // key code (e.g. "KeyA") - Modifiers int `json:"modifiers,omitempty"` - DeltaX int `json:"delta_x,omitempty"` // scroll delta - DeltaY int `json:"delta_y,omitempty"` + SessionID string `json:"session_id"` + Type string `json:"type"` + X int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + Button int `json:"button,omitempty"` + Key string `json:"key,omitempty"` + Code string `json:"code,omitempty"` + Text string `json:"text,omitempty"` + Modifiers []string `json:"modifiers,omitempty"` + DeltaX int `json:"delta_x,omitempty"` + DeltaY int `json:"delta_y,omitempty"` + Pressed bool `json:"pressed,omitempty"` } // DesktopResizePayload is sent when the browser viewport resizes. @@ -231,6 +236,53 @@ func (g *Gateway) HandleDesktopFrame(ctx context.Context, sessionID string, fram return ds.browser.Write(ctx, websocket.MessageText, outData) } +// frameHeaderSize is the fixed-size session-ID prefix on every binary +// desktop frame from the agent. The agent zero-pads sessionID to this +// length; the server uses it to route the frame to the correct browser +// without parsing JSON. +const frameHeaderSize = 64 + +// HandleDesktopFrameBinary is the binary fast-path for desktop frames. +// The payload format is: [frameHeaderSize bytes session ID, NUL-padded][raw JPEG bytes]. +// The raw JPEG is forwarded to the browser as a single binary WS frame — +// no base64, no JSON. This is the difference between 1–3 fps and 30+ fps. +func (g *Gateway) handleDesktopFrameBinary(ctx context.Context, _ *DeviceConn, data []byte) { + if len(data) < frameHeaderSize { + return + } + // Extract zero-padded session ID. + hdr := data[:frameHeaderSize] + end := bytes.IndexByte(hdr, 0) + if end < 0 { + end = frameHeaderSize + } + sessionID := string(hdr[:end]) + if sessionID == "" { + return + } + + val, ok := g.desktopSessions.Load(sessionID) + if !ok { + return + } + ds := val.(*DesktopSession) + if ds.closed.Load() { + return + } + + frame := data[frameHeaderSize:] + if len(frame) == 0 { + return + } + + ds.mu.Lock() + err := ds.browser.Write(ctx, websocket.MessageBinary, frame) + ds.mu.Unlock() + if err != nil && ctx.Err() == nil { + log.Printf("[cdap] desktop binary frame write failed for session %s: %v", sessionID, err) + } +} + // EndDesktopSession terminates a desktop session. func (g *Gateway) EndDesktopSession(ctx context.Context, sessionID, reason string) { val, ok := g.desktopSessions.LoadAndDelete(sessionID) diff --git a/betterdesk-server/cdap/gateway.go b/betterdesk-server/cdap/gateway.go index 142bd044..4016e239 100644 --- a/betterdesk-server/cdap/gateway.go +++ b/betterdesk-server/cdap/gateway.go @@ -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) diff --git a/betterdesk-server/cdap/messages.go b/betterdesk-server/cdap/messages.go index 6c528929..b382ac7b 100644 --- a/betterdesk-server/cdap/messages.go +++ b/betterdesk-server/cdap/messages.go @@ -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) diff --git a/betterdesk-server/main.go b/betterdesk-server/main.go index 17324949..7b35b1d4 100644 --- a/betterdesk-server/main.go +++ b/betterdesk-server/main.go @@ -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("========================================") diff --git a/docs/AGENT_CLIENT_ROADMAP_2026-04-10.md b/docs/AGENT_CLIENT_ROADMAP_2026-04-10.md new file mode 100644 index 00000000..ba4a5555 --- /dev/null +++ b/docs/AGENT_CLIENT_ROADMAP_2026-04-10.md @@ -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).* diff --git a/docs/AGENT_CLIENT_ROADMAP_2026-04-21.md b/docs/AGENT_CLIENT_ROADMAP_2026-04-21.md new file mode 100644 index 00000000..ecf325f9 --- /dev/null +++ b/docs/AGENT_CLIENT_ROADMAP_2026-04-21.md @@ -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 ` + - `monitor_loop()` — tokio task, poll co 5s, exponential backoff (5s×2^n, max 5min) + - `terminate_child()` — SIGTERM + 5s grace + force kill + - `Clone` przez `Arc` — 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).* diff --git a/docs/RDCLIENT_VS_RUSTDESK_AUDIT.md b/docs/RDCLIENT_VS_RUSTDESK_AUDIT.md new file mode 100644 index 00000000..5e2898b3 --- /dev/null +++ b/docs/RDCLIENT_VS_RUSTDESK_AUDIT.md @@ -0,0 +1,88 @@ +# RDClient ↔ RustDesk feature parity audit + +Date: 2026-04-25 +Scope: web-based remote desktop client at `web-nodejs/public/js/rdclient/` (5,449 LOC) compared to upstream RustDesk client. + +## Summary + +RDClient is the **browser viewer** that talks to a peer device through the BetterDesk relay using a RustDesk-compatible signal/relay protocol. It is **not** a full RustDesk port — it focuses on browser-feasible features. The native CDAP/bd-signal channel covers some gaps (services, processes, events, files, screenshot, terminal) but does not yet provide a continuous video pipeline. + +Symbols below: ✅ working, ⚠️ partial / behind a feature flag, ❌ missing. + +## Capability matrix + +| Capability | RustDesk client | RDClient (web) | bd-signal / agent | Status | +|---|---|---|---|---| +| Video stream (H.264/VP9 hw-decode) | ✅ libyuv + native | ⚠️ WebCodecs when HTTPS, JMuxer fallback over HTTP | ❌ | partial | +| Video stream (AV1) | ✅ on capable peers | ⚠️ negotiated only when WebCodecs reports support | ❌ | partial | +| Audio stream (Opus) | ✅ | ⚠️ Opus via WebCodecs, raw-PCM fallback | ❌ (no audio in agent) | partial | +| Microphone capture (operator → peer) | ✅ | ❌ | ❌ | **missing** | +| Mouse input (move/buttons/wheel) | ✅ | ✅ | ❌ → being added (Phase 58) | partial | +| Keyboard input (modifiers, function keys) | ✅ | ✅ | ❌ → being added (Phase 58) | partial | +| Clipboard sync (text + image) | ✅ bidirectional | ✅ text only via WebSocket | ⚠️ text only via bd-signal `clipboard.*` (planned) | partial | +| File transfer (upload/download/resume) | ✅ | ⚠️ basic upload/download, no resume | ⚠️ `files.read` only (no write) | partial | +| Multi-monitor | ✅ | ✅ peer-side monitor select | ❌ | partial | +| Session recording (.mp4 / WebM) | ❌ in client | ✅ WebM via canvas.captureStream | ❌ | RDClient-only | +| Quality presets (speed/balanced/best) | ✅ | ✅ runtime switch | ❌ | partial | +| Screenshot (one-shot) | ✅ | ✅ button | ✅ via `screenshot.capture` | done | +| Connect via PIN / 2FA | ✅ | ✅ | n/a | done | +| Permissions prompt on peer | ✅ | n/a (operator-driven) | ❌ no consent UI yet | **gap** | +| TCP-over-WebSocket relay | ✅ | ✅ | n/a | done | +| Direct hole-punch | ✅ | ❌ (browser limitation) | ❌ | unattainable in browser | +| LAN discovery (mDNS) | ✅ | ❌ | ❌ | n/a in browser | +| Terminal (PTY) | ✅ in 1.3+ | ❌ | ⚠️ `terminal.execute` one-shot only (no PTY) | partial | +| Services / processes / event log inspection | ❌ | ❌ | ✅ via bd-signal | bd-signal exclusive | +| File browser (read-only) | ✅ | ✅ inside file transfer | ✅ via bd-signal `files.browse/read` | done | +| File write / move / rename | ✅ | ⚠️ upload only | ❌ | **gap** | +| Wake-on-LAN | ❌ | n/a | n/a | server-side feature | +| Auto-update | ✅ | n/a (browser) | ❌ | **gap (agent)** | +| TOTP / 2FA login | ✅ | ✅ | n/a | done | +| End-to-end encryption (NaCl) | ✅ | ✅ via crypto.js | ⚠️ token-only on bd-signal channel | partial | + +## Concrete gaps and severity + +| Severity | Item | Where to fix | +|---|---|---| +| **CRITICAL** | No remote-desktop pipeline through bd-signal — operators only get JPEG snapshots when the RustDesk relay is unavailable. | `betterdesk-agent-client/src-tauri/src/bd_signal.rs` + new `remote-cdap.ejs` | +| **HIGH** | No input injection in agent (mouse/keyboard) — agent is read-only. | `bd_signal.rs` (`input.mouse`, `input.key` handlers) + `enigo` crate | +| **HIGH** | No file write/delete/rename in agent. | `bd_signal.rs` add `files.write`, `files.delete`, `files.rename` | +| **HIGH** | RDClient over plain HTTP cannot use WebCodecs → falls back to JMuxer (single-codec H.264, no AV1, no hw decode). | Force HTTPS in deployment; documented in DEPLOY.md | +| **MEDIUM** | No bidirectional clipboard sync via bd-signal. | `bd_signal.rs` add `clipboard.get/set` | +| **MEDIUM** | Permission prompt on peer for unattended operator sessions. | Tauri agent UI + new bd-signal `consent.request` | +| **MEDIUM** | No auto-update mechanism in Tauri agent. | `tauri-plugin-updater` integration | +| **LOW** | No microphone forwarding from operator to peer. | RDClient `audio.js` capture path + protocol | +| **LOW** | No file-transfer resume after interruption. | RDClient `filetransfer.js` checkpoint state | + +## Phase 58 deliverable (this iteration) + +This iteration extends bd-signal so the JPEG-polling viewer (`/remote-cdap/:id`) becomes interactive: + +- `input.mouse` handler — accepts `{x_rel, y_rel, button, action, wheel_dx, wheel_dy}` where coordinates are normalised 0..1 against the most recent screenshot, button∈{left,right,middle}, action∈{move,down,up,click,wheel}. +- `input.key` handler — accepts `{key, code, action, modifiers}` where action∈{down,up,press}, modifiers∈{ctrl,shift,alt,meta}. +- `input.text` handler — accepts `{text}` for safe Unicode typing. +- Extended `screenshot.capture` reply with `width` and `height` so viewer can compute exact pixel coords without JPEG parsing. +- Viewer extension: keyboard listener, mouse listener, focus & pointer-lock toggles. Throttled to ≤30 events/sec to keep the bd-signal channel responsive. + +This does **not** replace the RustDesk relay-based pipeline — it provides a *fallback control path* when the relay is unavailable or for low-bandwidth environments where JPEG snapshots are sufficient (kiosk, low-FPS monitoring, single-shot administration). + +## Phase 59+ (future, scoped) + +| Phase | Goal | Estimated effort | +|---|---|---| +| 59 | Continuous JPEG streaming through bd-signal (push events from agent) | 1 day | +| 60 | H.264 capture + encode in agent (via `xcap` + `openh264`) → WebCodecs path | 1–2 weeks | +| 61 | Audio capture in agent → operator playback | 1 week | +| 62 | Microphone forwarding (operator → peer) | 3 days | +| 63 | File write / delete / rename in `bd_signal.rs` | 1 day | +| 64 | Bi-directional clipboard via bd-signal | 1 day | +| 65 | Consent / permission prompt on peer | 2 days | +| 66 | Auto-update via `tauri-plugin-updater` + signed releases | 3 days | +| 67 | RDClient: file-transfer resume + microphone capture | 1 week | + +## References + +- `web-nodejs/public/js/rdclient/client.js` — main browser client +- `web-nodejs/public/js/rdclient/protocol.js` — RustDesk protocol layer +- `betterdesk-agent-client/src-tauri/src/bd_signal.rs` — agent signal/control channel +- `betterdesk-agent-client/src-tauri/src/cdap_client.rs` — CDAP gateway client +- Upstream RustDesk: diff --git a/docs/REMOTE_CLIENT_UNIFICATION_PLAN.md b/docs/REMOTE_CLIENT_UNIFICATION_PLAN.md new file mode 100644 index 00000000..abb371a2 --- /dev/null +++ b/docs/REMOTE_CLIENT_UNIFICATION_PLAN.md @@ -0,0 +1,253 @@ +# Web Remote Client Unification Plan + +> Status: Phase 1, 2.1, 3.1 (partial), 3.2, 3.3 (partial), 3.4, 3.5, 3.6, 3.8 deployed (2026-04-25). Remaining phases pending. +> +> Goal: merge the two browser remote desktop clients (`/remote/:id` RustDesk +> client + `/remote-cdap/:id` CDAP agent viewer) into a single unified UI that +> auto-detects the available transport, unlocks richer features when the +> device runs the BetterDesk CDAP agent, and forwards the full RustDesk +> feature set (mouse, keyboard, clipboard, audio, multi-monitor, file +> transfer, recording). + +--- + +## Phase 1 — Tab-close auto-disconnect ✅ DONE (2026-04-25) + +Both web clients now wire `pagehide` and `beforeunload` listeners that close +every active session immediately when the operator closes the tab, navigates +away, or the browser evicts the page from bfcache. Saves bandwidth + CPU on +the remote endpoint. + +**Files changed** +- `web-nodejs/public/js/cdap-desktop.js` — `closeAllDesktops()` + lifecycle + hooks. +- `web-nodejs/public/js/remote.js` — iterates `sessions` and calls + `client.disconnect()`. + +Server-side teardown chain was already correct: WS drop → +`api.handleCDAPDesktop` → `cdap.Gateway.EndDesktopSession` → `desktop_end` +forwarded to agent → `DesktopStreamer.Stop()`. + +--- + +## Phase 2 — Single entry point + transport router + +**Goal:** one URL (`/remote/:id`), one EJS shell, two pluggable transports. + +### Architecture + +``` +remote.ejs (unified shell) + ├── TransportRouter + │ ├── probe: GET /api/peers/:id → { device_type, cdap_connected, online } + │ ├── decision tree: + │ │ • cdap_connected=true → CDAPTransport + │ │ • device_type=os_agent && offline → wait + RustDesk fallback + │ │ • else → RustDeskTransport + │ └── unified event surface: + │ videoFrame, cursorUpdate, clipboardUpdate, monitorList, + │ audioFrame, fileMeta, terminalChunk, ready, end, error + │ + ├── SessionView + │ ├── toolbar (capability pills lit/dimmed by transport): + │ │ 🎥 Video • 🔊 Audio • 📋 Clipboard • 📁 Files + │ │ • 🖥️ Multi-monitor • 💻 Terminal • 💬 Chat • 🔴 Record + │ ├── canvas + overlay + │ ├── side dock (only shown if transport supports it): + │ │ • file transfer panel + │ │ • terminal panel (CDAP only) + │ │ • live metrics panel (CDAP only) + │ │ • chat panel + │ └── InputDispatcher: encodes mouse/kbd once, sends to active transport + │ + └── lifecycle: pagehide / beforeunload → transport.end() +``` + +### PR breakdown + +#### PR 2.1 — Single route ✅ DONE (2026-04-25) +- `routes/remote.routes.js`: `/remote/:id` is now the canonical entry. Probes + `/api/peers/:id` once on the server, sets `transport='cdap'|'rd'`, attaches + a `capabilities` object, and renders the matching template. `?transport=` + query param overrides auto-detection. `/remote-cdap/:id` kept as a 302 + redirect for legacy bookmarks and the existing `devices.js` Connect button. +- Templates still split (`remote.ejs` vs `remote-cdap.ejs`); will be merged + in PR 2.2 / 2.3. + +#### PR 2.2 — Shared UI shell +- Extract toolbar / sidebar / status bar into `views/partials/remote-shell.ejs`. +- Move CSS into `web-nodejs/public/css/remote.css` (currently inline). +- Delete `views/remote-cdap.ejs` once the unified shell handles both flows. + +#### PR 2.3 — Transport adapters +- `public/js/rdclient/transport-rd.js` — wraps existing rdclient `client.js`, + emits unified events. +- `public/js/rdclient/transport-cdap.js` — wraps current `cdap-desktop.js` + WebSocket loop, emits the same unified events. +- Both expose: `start()`, `end()`, `sendMouse()`, `sendKey()`, + `sendClipboard()`, `requestKeyframe()`, `selectMonitor()`, + `getCapabilities()`. + +#### PR 2.4 — Input dispatcher +- Move mouse / keyboard encoding into `public/js/rdclient/input.js` (already + the canonical source). +- Replace ad-hoc encoding inside `cdap-desktop.js` (`MOUSE_TYPE_*`, + `MOUSE_BUTTON_*`) with calls into the shared dispatcher. +- Clipboard hook: `navigator.clipboard.readText/writeText` with + `execCommand('copy')` fallback. Permission prompt handled once on + session start. + +#### PR 2.5 — Side panels (CDAP-only) +- Terminal panel: reuse `public/js/cdap-terminal.js`, dock as resizable + right-hand panel, gated on `capabilities.terminal`. +- File browser panel: reuse `public/js/cdap-filebrowser.js`, drag-and-drop + upload. +- Live metrics: small CPU/RAM/disk strip in the bottom toolbar from + `cdap.deviceStateChanged` events. + +#### PR 2.6 — Chat dock +- Reuse `public/js/chat.js` + `chatRelay`, mount as collapsible right-edge + drawer. Available on every transport. + +--- + +## Phase 3 — Full RustDesk feature parity for CDAP + +When the unified shell is in place, the CDAP transport becomes the place to +add the small remaining gaps so it matches the RustDesk client byte-for-byte. + +### 3.1 Keyboard map parity ✅ PARTIAL DONE (2026-04-25) +- `cdap-desktop.js::sendKeyEvent` now drops OS-level auto-repeat + (`e.repeat`) and routes single non-ASCII / non-alphanumeric printable + characters (á, €, @, etc.) through the `text` input path so the + agent's OS-level layout handles them. Modifier-laden combinations + (Ctrl/Alt/Meta) keep the legacy keyboard path because the agent must + see modifier presses, not the resolved character. +- Pending: F13–F24, exotic media keys, dead-key composition, full + RustDesk-equivalent modifier flag bit-mask (would land alongside + PR 2.4 input dispatcher). + +### 3.2 Virtual paste fallback ✅ DONE (2026-04-25) +- Operator toolbar button `#bd-remote-paste` in `remote-cdap.ejs` + reads the browser clipboard via `navigator.clipboard.readText()` + and types it into the remote session as a single `input_type:"text"` + event. Public API: `CDAPDesktop.pasteFromClipboard(deviceId, widgetId)` + + `CDAPDesktop.sendText(deviceId, widgetId, text)` for canned-text + injection from future side panels. +- Used when the device side refuses incoming clipboard sync, when the + page lacks `clipboard-write` permission to the remote, or when the + operator wants to inject text that never touches the device's + clipboard history. + +### 3.3 Pointer Lock + Keyboard Lock ✅ PARTIAL DONE (2026-04-25) +- Fullscreen + Keyboard Lock implemented in `cdap-desktop.js::toggleFullscreen`. + Captures `Escape`, `Tab`, `Meta`, `Alt`, `Ctrl`, `PrintScreen` via + `navigator.keyboard.lock()` while in fullscreen. `setDisconnected` + releases both fullscreen and the keyboard lock. +- Toolbar button `#bd-remote-fullscreen` in `remote-cdap.ejs` toggles the + state and swaps the icon based on `document.fullscreenchange`. +- Pointer Lock NOT yet wired — the CDAP fast path encodes mouse coords + as absolute canvas positions; pointer lock would require a separate + relative-motion input pipeline. Tracked for a future phase together + with PR 2.4 (input dispatcher). + +### 3.4 Session recording ✅ DONE (2026-04-25) +- `cdap-desktop.js` exposes `startRecording`, `stopRecording`, + `downloadRecording`, `isRecording` on `window.CDAPDesktop`. Captures + the canvas at 15 fps via `captureStream`, encodes to WebM with VP9 + + Opus when supported (graceful fallback to VP8 → default WebM). +- Toolbar `#bd-remote-record` button toggles between start/download. + Recorder is auto-stopped when the session disconnects so abrupt + closes still produce a downloadable blob (next click on Record). +- Output: `cdap_session__.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.* diff --git a/package.json b/package.json new file mode 100644 index 00000000..9e78243e --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "vitest": "^3.2.4" + } +} diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 3851fb1a..1d92339e 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -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.", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 254274c6..531725e2 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -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.", diff --git a/web-nodejs/public/js/cdap-desktop.js b/web-nodejs/public/js/cdap-desktop.js index 0c06cf23..8823cc42 100644 --- a/web-nodejs/public/js/cdap-desktop.js +++ b/web-nodejs/public/js/cdap-desktop.js @@ -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 }; })(); diff --git a/web-nodejs/public/js/devices.js b/web-nodejs/public/js/devices.js index 8ac578af..2dc6a5dd 100644 --- a/web-nodejs/public/js/devices.js +++ b/web-nodejs/public/js/devices.js @@ -392,6 +392,10 @@ screen_share ${_('actions.web_remote') || 'Web Remote'} + + + + + + arrow_backBack + + + +
+
+
+ + BetterDesk native desktop session over CDAP WebSocket. +
+
+ +
+ hourglass_top + Waiting for desktop session... +
+
+
+ +
+
+ + +
+ + + + ` +}) %> diff --git a/web-nodejs/views/remote.ejs b/web-nodejs/views/remote.ejs index 66b929b6..2c8f6c22 100644 --- a/web-nodejs/views/remote.ejs +++ b/web-nodejs/views/remote.ejs @@ -196,6 +196,10 @@ refresh ${_('remote.reconnect')} + @@ -297,6 +301,7 @@ `