diff --git a/.gitignore b/.gitignore index cde7c217..f1e6dacb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,11 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +# Python distutils 'lib/' build output (root-level only — must NOT match +# Tauri/SolidJS client src/lib directories like +# betterdesk-agent-client/src/lib or betterdesk-mgmt/src/lib). +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/betterdesk-agent-client/package.json b/betterdesk-agent-client/package.json index b8cc16bd..7b575432 100644 --- a/betterdesk-agent-client/package.json +++ b/betterdesk-agent-client/package.json @@ -17,14 +17,14 @@ }, "dependencies": { "@solidjs/router": "^0.14.0", - "@tauri-apps/api": "^2.10.1", + "@tauri-apps/api": "~2.10", "solid-js": "^1.9.0" }, "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" + "vite-plugin-solid": "^2.10.0", + "vitest": "^3.2.4" } } diff --git a/betterdesk-agent-client/public/session-border.html b/betterdesk-agent-client/public/session-border.html new file mode 100644 index 00000000..900ef2d9 --- /dev/null +++ b/betterdesk-agent-client/public/session-border.html @@ -0,0 +1,58 @@ + + + + + BetterDesk Session + + + + + + + + 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 index 4b833876..88cab942 100755 Binary files a/betterdesk-agent-client/src-tauri/binaries/betterdesk-agent-x86_64-unknown-linux-gnu and b/betterdesk-agent-client/src-tauri/binaries/betterdesk-agent-x86_64-unknown-linux-gnu differ diff --git a/betterdesk-agent-client/src-tauri/src/commands.rs b/betterdesk-agent-client/src-tauri/src/commands.rs index 1a8a24e1..ea286652 100644 --- a/betterdesk-agent-client/src-tauri/src/commands.rs +++ b/betterdesk-agent-client/src-tauri/src/commands.rs @@ -405,6 +405,23 @@ pub async fn register_device( .await .map_err(|e| e.to_string())?; + // Re-registration of a device that already exists on the server returns + // `approved` but does NOT emit a fresh device_token (the server reuses the + // existing one). If we lost the local copy (e.g. user reset agent-config), + // recover it from the OS keyring before persisting state. Without this, + // the post-registration sidecar auto-start fails with + // "CDAP sidecar requires a valid API key … or a server-issued device token". + if config_clone.registered + && config_clone.auth_token.is_empty() + && config_clone.api_key.is_empty() + && !config_clone.device_id.is_empty() + { + if let Some(stored) = crate::config::AgentConfig::load_token_secure(&config_clone.device_id) { + info!("Recovered auth token from OS keyring for {}", config_clone.device_id); + config_clone.auth_token = stored; + } + } + // Apply mutations back to shared state (pending saves partial state too). { let mut config = state.config.lock().map_err(|e| e.to_string())?; @@ -452,6 +469,14 @@ pub async fn poll_enrollment_status( let mut config = state.config.lock().map_err(|e| e.to_string())?; config.registered = true; config.device_id = enrollment.device_id.clone(); + // If neither api_key nor auth_token is set (server did not emit a + // fresh device_token on re-approval), recover from OS keyring. + if config.auth_token.is_empty() && config.api_key.is_empty() { + if let Some(stored) = crate::config::AgentConfig::load_token_secure(&config.device_id) { + info!("Recovered auth token from OS keyring for {}", config.device_id); + config.auth_token = stored; + } + } if let Err(e) = config.save() { info!("Config save after approval: {}", e); } diff --git a/betterdesk-agent-client/src-tauri/src/config.rs b/betterdesk-agent-client/src-tauri/src/config.rs index c0836d51..a8c03082 100644 --- a/betterdesk-agent-client/src-tauri/src/config.rs +++ b/betterdesk-agent-client/src-tauri/src/config.rs @@ -3,6 +3,46 @@ use log::{info, warn}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Remote-desktop access policy enforced by the agent. +/// +/// `Supervised` (default) shows a consent dialog before every session. +/// `Unattended` starts sessions immediately without prompting the user. +/// `Disabled` rejects every inbound desktop session locally; the operator +/// sees a clear "remote desktop disabled by user policy" error. +/// +/// The legacy `require_consent` boolean is derived from this value at config +/// load and write time to keep wire compatibility with the Go sidecar's JSON +/// config until the sidecar gains a native `access_mode` field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AccessMode { + Supervised, + Unattended, + Disabled, +} + +impl Default for AccessMode { + fn default() -> Self { + AccessMode::Supervised + } +} + +impl AccessMode { + /// Whether the agent should prompt the user before starting a session. + pub fn requires_consent(self) -> bool { + matches!(self, AccessMode::Supervised) + } + + /// Whether the agent should refuse desktop sessions outright. + pub fn is_disabled(self) -> bool { + matches!(self, AccessMode::Disabled) + } +} + +fn default_access_mode() -> AccessMode { + AccessMode::Supervised +} + /// Persistent agent configuration stored as JSON on disk. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfig { @@ -50,7 +90,20 @@ pub struct AgentConfig { #[serde(default = "default_true")] pub allow_screen_capture: bool, + /// Remote-desktop access policy. New in 2026-05. + /// + /// Backward compatible: configs written by older builds only have + /// `require_consent`; the loader migrates that into `access_mode` and the + /// `require_consent` setter mirrors changes back to keep the sidecar + /// JSON unchanged until it learns the new field. + #[serde(default = "default_access_mode")] + pub access_mode: AccessMode, + /// Require explicit user consent dialog before a remote session starts. + /// + /// Treated as a derived mirror of `access_mode == Supervised`. Kept as a + /// separate field so the Go sidecar JSON contract is unchanged and so + /// old configs continue to load without losing user intent. #[serde(default = "default_true")] pub require_consent: bool, @@ -102,6 +155,7 @@ impl Default for AgentConfig { auth_token: String::new(), registered: false, allow_screen_capture: true, + access_mode: AccessMode::Supervised, require_consent: true, allow_terminal: true, allow_file_browser: true, @@ -132,10 +186,39 @@ impl AgentConfig { } let content = std::fs::read_to_string(&path)?; - let config: Self = serde_json::from_str(&content)?; + // Detect legacy configs that lack `access_mode` so we can derive it + // from the older `require_consent` field. serde_json::Value gives us a + // cheap way to inspect the raw JSON before strongly typing it. + let had_access_mode = serde_json::from_str::(&content) + .ok() + .and_then(|v| v.get("access_mode").cloned()) + .is_some(); + + let mut config: Self = serde_json::from_str(&content)?; + if !had_access_mode { + config.access_mode = if config.require_consent { + AccessMode::Supervised + } else { + AccessMode::Unattended + }; + info!( + "Migrated legacy config to access_mode={:?} (from require_consent={})", + config.access_mode, config.require_consent + ); + } + // Always keep require_consent in sync with access_mode so the sidecar + // JSON written next reflects the new policy correctly. + config.sync_access_mode(); Ok(config) } + /// Mirror `access_mode` into the derived `require_consent` field. Call + /// after every mutation of `access_mode` so the sidecar JSON written next + /// reflects user intent. + pub fn sync_access_mode(&mut self) { + self.require_consent = self.access_mode.requires_consent(); + } + /// Repair stale configs produced by the legacy fake-registration flow. /// /// Older clients marked the device as registered after a heartbeat ACK and @@ -213,6 +296,13 @@ impl AgentConfig { .map(|d| d.data_dir().to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); + // When the policy is Disabled we force `allow_screen_capture=false` + // on the wire so the Go sidecar refuses `desktop_start` outright, + // even if the user toggled the capability gate on. The Tauri layer + // also enforces this, but two-layer defense keeps the contract honest + // if the sidecar config file is read directly. + let screen_capture = self.allow_screen_capture && !self.access_mode.is_disabled(); + crate::sidecar::SidecarConfig { server_address: self.server_address.clone(), device_id: self.device_id.clone(), @@ -222,8 +312,8 @@ impl AgentConfig { allow_terminal: self.allow_terminal, allow_file_browser: self.allow_file_browser, allow_clipboard: self.allow_clipboard, - allow_screen_capture: self.allow_screen_capture, - require_consent: self.require_consent, + allow_screen_capture: screen_capture, + require_consent: self.access_mode.requires_consent(), data_dir, cdap_port: self.cdap_port, } diff --git a/betterdesk-agent-client/src-tauri/src/lib.rs b/betterdesk-agent-client/src-tauri/src/lib.rs index f83e0cc1..e8916766 100644 --- a/betterdesk-agent-client/src-tauri/src/lib.rs +++ b/betterdesk-agent-client/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ pub mod commands; pub mod config; pub mod privileges; pub mod registration; +pub mod session_overlay; pub mod sidecar; pub mod sysinfo_collect; @@ -311,6 +312,7 @@ pub fn run() { chat_history: Mutex::new(Vec::new()), cdap: cdap_client, sidecar: sidecar_manager, + active_sessions: Mutex::new(Vec::new()), }) .invoke_handler(tauri::generate_handler![ // Status & lifecycle @@ -352,6 +354,11 @@ pub fn run() { commands::unregister_device, commands::authenticate_sudo, commands::log_frontend_event, + // Access mode + active sessions (Phase 1) + commands::get_access_mode, + commands::set_access_mode, + commands::get_active_sessions, + commands::disconnect_active_session, ]) .setup(move |app| { info!("Tauri setup complete"); diff --git a/betterdesk-agent-client/src-tauri/src/session_overlay.rs b/betterdesk-agent-client/src-tauri/src/session_overlay.rs new file mode 100644 index 00000000..8e2fd076 --- /dev/null +++ b/betterdesk-agent-client/src-tauri/src/session_overlay.rs @@ -0,0 +1,129 @@ +//! Session border overlay — native click-through window pinned to the primary +//! monitor while a remote session is active. +//! +//! This is intentionally separate from the in-app `SessionOverlay` Solid +//! component: the in-app component still owns the collapsible disconnect +//! widget, but the *coloured border* is now drawn by a dedicated borderless, +//! transparent, always-on-top, cursor-transparent webview window that covers +//! the entire primary monitor — so the user sees the warning frame around +//! their screen, not just around the agent window. + +use log::{info, warn}; +use tauri::{ + AppHandle, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize, + WebviewUrl, WebviewWindowBuilder, +}; + +const OVERLAY_LABEL: &str = "session-border"; + +/// Show (or reuse) the full-screen border overlay window on the primary +/// monitor. `mode` is either `"supervised"` or `"unattended"` and controls +/// the colour (amber vs red). +pub fn show(app: &AppHandle, mode: &str) { + let mode = if mode == "unattended" { + "unattended" + } else { + "supervised" + }; + + // Already showing → just navigate to refresh the colour and bail. + if let Some(existing) = app.get_webview_window(OVERLAY_LABEL) { + let url = format!("session-border.html?mode={}", mode); + if let Err(e) = existing.eval(&format!( + "window.location.replace({:?});", + url + )) { + warn!("[session-overlay] reload failed: {}", e); + } + let _ = existing.show(); + let _ = existing.set_always_on_top(true); + let _ = existing.set_ignore_cursor_events(true); + return; + } + + // Resolve primary monitor geometry. Tauri returns *physical* pixels; we + // convert to logical so multi-DPI displays still get a full-screen + // overlay regardless of scale factor. + let (logical_pos, logical_size) = match app.primary_monitor() { + Ok(Some(m)) => { + let scale = m.scale_factor(); + let pp: PhysicalPosition = *m.position(); + let ps: PhysicalSize = *m.size(); + ( + LogicalPosition::new(pp.x as f64 / scale, pp.y as f64 / scale), + LogicalSize::new(ps.width as f64 / scale, ps.height as f64 / scale), + ) + } + Ok(None) => { + warn!("[session-overlay] No primary monitor reported — falling back to 1920x1080"); + (LogicalPosition::new(0.0, 0.0), LogicalSize::new(1920.0, 1080.0)) + } + Err(e) => { + warn!("[session-overlay] primary_monitor() failed: {} — skipping", e); + return; + } + }; + + let url = format!("session-border.html?mode={}", mode); + // NOTE: on Linux/GTK (tao 0.34) several `WebviewWindowBuilder` flags can + // panic in the event loop *after* the window is shown (closable/maximizable + // /minimizable(false), focused(false), shadow(false) combined with + // transparent+decorations(false) trigger `Option::unwrap` on a None value + // inside `event_loop.rs:448`). We therefore keep the builder minimal and + // apply the cursor-pass-through *after* `show()` once the GTK widget is + // realised. + let builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, WebviewUrl::App(url.into())) + .title("BetterDesk Session") + .decorations(false) + .transparent(true) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .visible(false) // show after positioning to avoid flicker + .inner_size(logical_size.width, logical_size.height) + .position(logical_pos.x, logical_pos.y); + + match builder.build() { + Ok(win) => { + let _ = win.set_always_on_top(true); + if let Err(e) = win.show() { + warn!("[session-overlay] show() failed: {}", e); + } + // Click-through: pointer events fall through to whatever is beneath + // the overlay. On Linux/GTK (tao 0.34) `set_ignore_cursor_events` + // calls `gdk_window().unwrap()` which panics if the GTK widget has + // not yet been *realised* — `show()` only queues realisation, it + // does not guarantee a backing GdkWindow exists. We therefore defer + // the call to a background thread which hops back onto the main + // thread after a short delay so realisation has actually happened. + let app_clone = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(300)); + let app_for_main = app_clone.clone(); + let _ = app_clone.run_on_main_thread(move || { + if let Some(w) = app_for_main.get_webview_window(OVERLAY_LABEL) { + if let Err(e) = w.set_ignore_cursor_events(true) { + warn!("[session-overlay] set_ignore_cursor_events failed: {}", e); + } + } + }); + }); + info!( + "[session-overlay] Border shown ({}x{} @ {},{} mode={})", + logical_size.width, logical_size.height, logical_pos.x, logical_pos.y, mode + ); + } + Err(e) => warn!("[session-overlay] Failed to build overlay window: {}", e), + } +} + +/// Hide and destroy the overlay window (no-op if it does not exist). +pub fn hide(app: &AppHandle) { + if let Some(win) = app.get_webview_window(OVERLAY_LABEL) { + if let Err(e) = win.close() { + warn!("[session-overlay] close failed: {}", e); + } else { + info!("[session-overlay] Border hidden"); + } + } +} diff --git a/betterdesk-agent-client/src-tauri/src/sidecar.rs b/betterdesk-agent-client/src-tauri/src/sidecar.rs index 3c615344..cc4f2e3a 100644 --- a/betterdesk-agent-client/src-tauri/src/sidecar.rs +++ b/betterdesk-agent-client/src-tauri/src/sidecar.rs @@ -43,7 +43,7 @@ use std::{ }, time::{Duration, Instant}, }; -use tauri::{async_runtime, Emitter}; +use tauri::{async_runtime, Emitter, Manager}; // ── Public status ───────────────────────────────────────────────────────── @@ -318,6 +318,18 @@ impl SidecarManager { } } + /// Write a desktop-stop request for the given session id. Used by the + /// on-screen "Disconnect" button on the session overlay. + pub fn send_disconnect(&self, session_id: &str) { + let mut guard = self.inner.child_stdin.lock().unwrap(); + if let Some(ref mut stdin) = *guard { + let line = format!("DESKTOP_STOP:{}\n", session_id); + if let Err(e) = stdin.write_all(line.as_bytes()) { + warn!("[sidecar] Failed to write disconnect: {}", 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) { @@ -339,6 +351,67 @@ impl SidecarManager { warn!("[sidecar] Failed to emit consent-request event: {}", e); } } + Ok(l) if l.starts_with("SESSION_START:") => { + // Emitted by `betterdesk-agent/agent/desktop.go` after the + // operator's `desktop_start` is accepted (post-consent in + // supervised mode, immediately in unattended mode). The + // payload carries `session_id`, `operator`, and `mode`. + // SessionOverlay listens for this to draw the per-monitor + // border + the collapsible session widget. + let json_str = l.trim_start_matches("SESSION_START:").to_string(); + // Mirror into AgentState.active_sessions so the + // overlay UI can render even after the Tauri event + // has already fired. + let mut overlay_mode = String::from("supervised"); + if let Some(state) = app.try_state::() { + if let Ok(parsed) = serde_json::from_str::(&json_str) { + overlay_mode = parsed.get("mode").and_then(|v| v.as_str()).unwrap_or("supervised").to_string(); + let session = crate::commands::ActiveSession { + session_id: parsed.get("session_id").and_then(|v| v.as_str()).unwrap_or("").to_string(), + operator: parsed.get("operator").and_then(|v| v.as_str()).unwrap_or("").to_string(), + mode: overlay_mode.clone(), + started_at: chrono::Utc::now().to_rfc3339(), + }; + crate::commands::record_session_start(&state, session); + } + } + // Draw the click-through border on the primary monitor. + // Must run on the main (UI) thread — Tauri window APIs + // are not safe to call from arbitrary worker threads. + let app_for_overlay = app.clone(); + let mode_for_overlay = overlay_mode.clone(); + app.run_on_main_thread(move || { + crate::session_overlay::show(&app_for_overlay, &mode_for_overlay); + }).ok(); + if let Err(e) = app.emit("session-active", json_str) { + warn!("[sidecar] Failed to emit session-active event: {}", e); + } + } + Ok(l) if l.starts_with("SESSION_END:") => { + let json_str = l.trim_start_matches("SESSION_END:").to_string(); + let mut remaining: usize = 0; + if let Some(state) = app.try_state::() { + if let Ok(parsed) = serde_json::from_str::(&json_str) { + if let Some(sid) = parsed.get("session_id").and_then(|v| v.as_str()) { + crate::commands::record_session_end(&state, sid); + } + } + if let Ok(sessions) = state.active_sessions.lock() { + remaining = sessions.len(); + } + } + // Tear down the on-screen border only when *all* sessions + // have ended — there could be concurrent operators. + if remaining == 0 { + let app_for_overlay = app.clone(); + app.run_on_main_thread(move || { + crate::session_overlay::hide(&app_for_overlay); + }).ok(); + } + if let Err(e) = app.emit("session-ended", json_str) { + warn!("[sidecar] Failed to emit session-ended event: {}", e); + } + } Ok(l) => { // Forward other stdout lines to the app log. debug!("[go-agent] {}", l); diff --git a/betterdesk-agent-client/src/App.tsx b/betterdesk-agent-client/src/App.tsx index caf10a97..a2e729e8 100644 --- a/betterdesk-agent-client/src/App.tsx +++ b/betterdesk-agent-client/src/App.tsx @@ -7,6 +7,7 @@ import HelpRequest from "./components/HelpRequest"; import SettingsPanel from "./components/SettingsPanel"; import ConsentDialog from "./components/ConsentDialog"; import SudoAuthDialog from "./components/SudoAuthDialog"; +import SessionOverlay from "./components/SessionOverlay"; import { initI18n, t } from "./lib/i18n"; import { frontendLog } from "./lib/logger"; import { invoke } from "@tauri-apps/api/core"; @@ -162,6 +163,7 @@ const RegisteredShell: Component = (props) => {
+
{routerProps.children}
diff --git a/betterdesk-agent-client/src/components/SessionOverlay.tsx b/betterdesk-agent-client/src/components/SessionOverlay.tsx new file mode 100644 index 00000000..8d60a052 --- /dev/null +++ b/betterdesk-agent-client/src/components/SessionOverlay.tsx @@ -0,0 +1,199 @@ +import { + Component, + createSignal, + onMount, + onCleanup, + Show, + For, +} from "solid-js"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, UnlistenFn } from "@tauri-apps/api/event"; +import { t } from "../lib/i18n"; + +interface ActiveSession { + session_id: string; + operator: string; + mode: string; // "supervised" | "unattended" + started_at: string; // ISO-8601 +} + +/** + * SessionOverlay — TV-style on-screen indicator shown while an operator is + * actively viewing this device. Renders a coloured border across the viewport + * and a collapsible widget in the bottom-right corner with operator name, + * elapsed time, and a Disconnect button. + * + * Border colour: + * - amber → supervised (user accepted via consent dialog) + * - red → unattended (operator connected without consent) + * + * Data source: Tauri events `session-active` / `session-ended` emitted by + * `sidecar.rs` when the Go agent prints `SESSION_START:` / `SESSION_END:` + * to stdout. We also poll `get_active_sessions` on mount to recover from + * page reloads / late mounts. + */ +const SessionOverlay: Component = () => { + const [sessions, setSessions] = createSignal([]); + const [collapsed, setCollapsed] = createSignal(false); + const [now, setNow] = createSignal(Date.now()); + + let unlistenStart: UnlistenFn | undefined; + let unlistenEnd: UnlistenFn | undefined; + let tick: number | undefined; + + const refreshFromBackend = async () => { + try { + const list = await invoke("get_active_sessions"); + setSessions(list); + } catch (e) { + console.warn("[overlay] get_active_sessions failed:", e); + } + }; + + const upsertSession = (s: ActiveSession) => { + setSessions((prev) => { + const filtered = prev.filter((p) => p.session_id !== s.session_id); + return [...filtered, s]; + }); + }; + + const removeSession = (id: string) => { + setSessions((prev) => prev.filter((p) => p.session_id !== id)); + }; + + const disconnect = async (id: string) => { + try { + await invoke("disconnect_active_session", { sessionId: id }); + } catch (e) { + console.error("[overlay] disconnect failed:", e); + } + // Remove optimistically; SESSION_END from sidecar will confirm. + removeSession(id); + }; + + const formatElapsed = (startedAt: string): string => { + const started = Date.parse(startedAt); + if (Number.isNaN(started)) return "—"; + const secs = Math.max(0, Math.floor((now() - started) / 1000)); + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; + }; + + const primaryMode = (): "supervised" | "unattended" => { + return sessions().some((s) => s.mode === "unattended") + ? "unattended" + : "supervised"; + }; + + onMount(async () => { + unlistenStart = await listen("session-active", (event) => { + try { + const data = JSON.parse(event.payload); + upsertSession({ + session_id: String(data.session_id ?? ""), + operator: String(data.operator ?? ""), + mode: String(data.mode ?? "supervised"), + started_at: new Date().toISOString(), + }); + } catch (e) { + console.error("[overlay] bad session-active payload:", e); + } + }); + + unlistenEnd = await listen("session-ended", (event) => { + try { + const data = JSON.parse(event.payload); + if (data.session_id) removeSession(String(data.session_id)); + } catch (e) { + console.error("[overlay] bad session-ended payload:", e); + } + }); + + await refreshFromBackend(); + tick = window.setInterval(() => setNow(Date.now()), 1000); + }); + + onCleanup(() => { + unlistenStart?.(); + unlistenEnd?.(); + if (tick !== undefined) clearInterval(tick); + }); + + return ( + 0}> + {/* The on-screen border around the primary monitor is drawn by a + dedicated native click-through Tauri window (see + `src-tauri/src/session_overlay.rs`). The in-app UI below only + provides the collapsible session widget. */} + +
+ + + +
+
+ + screen_share + + + {t("session.active_title")} + +
+ + + {(s) => ( +
+
+
+ {s.operator || "—"} +
+
+ + {s.mode === "unattended" + ? t("session.unattended") + : t("session.supervised")} + + + {t("session.elapsed")} {formatElapsed(s.started_at)} + +
+
+ +
+ )} +
+
+
+
+
+ ); +}; + +export default SessionOverlay; diff --git a/betterdesk-agent-client/src/components/SettingsPanel.tsx b/betterdesk-agent-client/src/components/SettingsPanel.tsx index 1e2b3250..a550b30b 100644 --- a/betterdesk-agent-client/src/components/SettingsPanel.tsx +++ b/betterdesk-agent-client/src/components/SettingsPanel.tsx @@ -9,6 +9,10 @@ interface AgentSettings { cdap_port: number; allow_screen_capture: boolean; require_consent: boolean; + /** "supervised" | "unattended" | "disabled" — authoritative source for the + * remote-desktop access policy. `require_consent` is kept in sync by the + * Rust backend for legacy code paths. */ + access_mode: "supervised" | "unattended" | "disabled"; allow_terminal: boolean; allow_file_browser: boolean; allow_clipboard: boolean; @@ -39,6 +43,7 @@ const SettingsPanel: Component = (props) => { cdap_port: 21122, allow_screen_capture: true, require_consent: true, + access_mode: "supervised", allow_terminal: true, allow_file_browser: true, allow_clipboard: true, @@ -215,17 +220,33 @@ const SettingsPanel: Component = (props) => {
-
{t("settings.require_consent")}
-
{t("settings.require_consent_hint")}
+
{t("access_mode.label")}
+
{t("access_mode.description")}
- +
+
+ {(["supervised", "unattended", "disabled"] as const).map((mode) => ( + + ))}
diff --git a/betterdesk-agent-client/src/lib/i18n.ts b/betterdesk-agent-client/src/lib/i18n.ts new file mode 100644 index 00000000..7aac1dec --- /dev/null +++ b/betterdesk-agent-client/src/lib/i18n.ts @@ -0,0 +1,114 @@ +// Frontend i18n module for BetterDesk Agent Client. +// +// Loads JSON locale files eagerly at build time (Vite glob import) and +// exposes a small synchronous translation helper that components can call +// during render without awaiting promises. + +import en from "../locales/en.json"; +import pl from "../locales/pl.json"; +import zhTW from "../locales/zh-TW.json"; + +type Bundle = Record; + +const BUNDLES: Record = { + en: en as Bundle, + pl: pl as Bundle, + "zh-TW": zhTW as Bundle, +}; + +const DISPLAY_NAMES: Record = { + en: "English", + pl: "Polski", + "zh-TW": "繁體中文", +}; + +const STORAGE_KEY = "betterdesk-agent-locale"; +const DEFAULT_LOCALE = "en"; + +let currentLocale: string = DEFAULT_LOCALE; +const listeners = new Set<(locale: string) => void>(); + +function detectInitialLocale(): string { + if (typeof window === "undefined") { + return DEFAULT_LOCALE; + } + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored && BUNDLES[stored]) { + return stored; + } + } catch { + // localStorage may be unavailable (private mode, etc.) — fall through. + } + const nav = (window.navigator?.language || "").toLowerCase(); + if (nav.startsWith("pl")) return "pl"; + if (nav.startsWith("zh")) return "zh-TW"; + return DEFAULT_LOCALE; +} + +function resolveKey(bundle: Bundle, key: string): string | undefined { + const parts = key.split("."); + let node: unknown = bundle; + for (const part of parts) { + if (node && typeof node === "object" && part in (node as Record)) { + node = (node as Record)[part]; + } else { + return undefined; + } + } + return typeof node === "string" ? node : undefined; +} + +function interpolate(template: string, params?: Record): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (match, name) => { + const value = params[name]; + return value === undefined || value === null ? match : String(value); + }); +} + +/** Initialize the i18n system. Safe to call multiple times. */ +export function initI18n(): void { + currentLocale = detectInitialLocale(); +} + +/** Translate a dot-separated key. Falls back to English, then to the key itself. */ +export function t(key: string, params?: Record): string { + const bundle = BUNDLES[currentLocale] ?? BUNDLES[DEFAULT_LOCALE]; + const direct = resolveKey(bundle, key); + if (direct !== undefined) return interpolate(direct, params); + const fallback = resolveKey(BUNDLES[DEFAULT_LOCALE], key); + if (fallback !== undefined) return interpolate(fallback, params); + return key; +} + +/** Change the active locale. Persists to localStorage and notifies listeners. */ +export function setLocale(code: string): void { + if (!BUNDLES[code]) return; + if (code === currentLocale) return; + currentLocale = code; + try { + window.localStorage.setItem(STORAGE_KEY, code); + } catch { + // ignore + } + for (const cb of listeners) cb(code); +} + +export function getLocale(): string { + return currentLocale; +} + +export function getAvailableLocales(): string[] { + return Object.keys(BUNDLES); +} + +export function getLocaleDisplayName(code: string): string { + return DISPLAY_NAMES[code] ?? code; +} + +/** Subscribe to locale changes. Returns an unsubscribe handle. */ +export function onLocaleChange(cb: (locale: string) => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} diff --git a/betterdesk-agent-client/src/lib/logger.ts b/betterdesk-agent-client/src/lib/logger.ts new file mode 100644 index 00000000..6598e312 --- /dev/null +++ b/betterdesk-agent-client/src/lib/logger.ts @@ -0,0 +1,88 @@ +// Frontend logger that mirrors important events into the Rust-side log file +// via a Tauri IPC bridge. In dev mode it also echoes to the browser console. + +import { invoke } from "@tauri-apps/api/core"; + +export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"; + +const IS_DEV = typeof import.meta !== "undefined" && Boolean((import.meta as any).env?.DEV); + +function consoleEcho(level: LogLevel, scope: string, message: string, data?: unknown): void { + if (!IS_DEV || typeof console === "undefined") return; + const tag = `[${scope}]`; + const fn = + level === "error" + ? console.error + : level === "warn" + ? console.warn + : level === "debug" || level === "trace" + ? console.debug + : console.log; + if (data !== undefined) { + fn.call(console, tag, message, data); + } else { + fn.call(console, tag, message); + } +} + +/** + * Send a structured log event to the Rust backend. + * + * The Rust side writes it to the normal agent log file via the + * `log_frontend_event` IPC command, so packaged builds can be diagnosed + * without opening browser devtools. + */ +export function frontendLog( + level: LogLevel, + scope: string, + message: string, + data?: unknown, +): void { + consoleEcho(level, scope, message, data); + // Fire-and-forget: never let logging failures crash the UI. + void invoke("log_frontend_event", { + level, + scope, + message, + data: data === undefined ? null : data, + }).catch(() => { + // The Rust command is missing during early boot or in environments + // where the Tauri bridge is unavailable (e.g. plain browser preview). + // Silently ignore — the console echo above is the only fallback. + }); +} + +/** + * Hook global window error and unhandled-rejection handlers so that any + * uncaught failure is forwarded to the Rust log file. + * + * Idempotent — installing twice still installs only one set of handlers. + */ +let handlersInstalled = false; +export function installFrontendErrorLogging(): void { + if (handlersInstalled || typeof window === "undefined") return; + handlersInstalled = true; + + window.addEventListener("error", (event) => { + frontendLog("error", "window", event.message, { + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error?.stack, + }); + }); + + window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason; + const message = + reason instanceof Error + ? reason.message + : typeof reason === "string" + ? reason + : "Unhandled promise rejection"; + frontendLog("error", "window", message, { + stack: reason instanceof Error ? reason.stack : undefined, + reason: reason instanceof Error ? undefined : reason, + }); + }); +} diff --git a/betterdesk-agent-client/src/locales/en.json b/betterdesk-agent-client/src/locales/en.json index 3c1fb60e..30542672 100644 --- a/betterdesk-agent-client/src/locales/en.json +++ b/betterdesk-agent-client/src/locales/en.json @@ -186,5 +186,25 @@ "auto_deny_in": "Auto-deny in", "allow": "Allow", "deny": "Deny" + }, + "session": { + "active_title": "Remote session active", + "operator_label": "Operator", + "elapsed": "Duration:", + "disconnect": "Disconnect", + "supervised": "Supervised", + "unattended": "Unattended", + "expand": "Expand session widget", + "collapse": "Collapse session widget" + }, + "access_mode": { + "label": "Remote-desktop access", + "description": "Controls how operators can connect to this device.", + "supervised": "Supervised", + "supervised_desc": "Ask me before each session (recommended).", + "unattended": "Unattended", + "unattended_desc": "Operators may connect without asking. Use only on trusted servers.", + "disabled": "Disabled", + "disabled_desc": "Block all remote-desktop sessions on this device." } } diff --git a/betterdesk-agent-client/src/locales/pl.json b/betterdesk-agent-client/src/locales/pl.json index 0731fea5..3b8396a3 100644 --- a/betterdesk-agent-client/src/locales/pl.json +++ b/betterdesk-agent-client/src/locales/pl.json @@ -186,5 +186,25 @@ "auto_deny_in": "Automatyczna odmowa za", "allow": "Zezwól", "deny": "Odmów" + }, + "session": { + "active_title": "Trwa sesja zdalna", + "operator_label": "Operator", + "elapsed": "Czas:", + "disconnect": "Rozłącz", + "supervised": "Nadzorowana", + "unattended": "Nienadzorowana", + "expand": "Rozwiń panel sesji", + "collapse": "Zwiń panel sesji" + }, + "access_mode": { + "label": "Dostęp zdalny", + "description": "Określa, w jaki sposób operatorzy mogą łączyć się z tym urządzeniem.", + "supervised": "Nadzorowany", + "supervised_desc": "Pytaj o zgodę przed każdą sesją (zalecane).", + "unattended": "Nienadzorowany", + "unattended_desc": "Operatorzy mogą łączyć się bez pytania. Używaj tylko z zaufanymi serwerami.", + "disabled": "Wyłączony", + "disabled_desc": "Blokuj wszystkie sesje zdalnego pulpitu na tym urządzeniu." } } diff --git a/betterdesk-agent-client/src/styles/global.css b/betterdesk-agent-client/src/styles/global.css index 508dfb12..4a52e7b2 100644 --- a/betterdesk-agent-client/src/styles/global.css +++ b/betterdesk-agent-client/src/styles/global.css @@ -1569,3 +1569,182 @@ a:hover { color: var(--accent-hover); } .sudo-auth-btn-submit:hover:not(:disabled) { background: var(--accent-hover); } + +/* ── SessionOverlay (Phase 1) ─────────────────────────────────────────── */ + +/* TV-style border around the viewport. Always-on-top of regular UI. Lets the + user instantly notice that a remote session is in progress. */ +.session-overlay-border { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9000; + border-style: solid; + border-width: 6px; + box-sizing: border-box; + animation: session-overlay-pulse 2s ease-in-out infinite; +} + +.session-overlay-border--supervised { + border-color: #d97706; /* amber-600 */ + box-shadow: inset 0 0 24px rgba(217, 119, 6, 0.45); +} + +.session-overlay-border--unattended { + border-color: #dc2626; /* red-600 */ + box-shadow: inset 0 0 28px rgba(220, 38, 38, 0.55); +} + +@keyframes session-overlay-pulse { + 0%, 100% { opacity: 0.85; } + 50% { opacity: 1; } +} + +/* Collapsible widget anchored to bottom-right. */ +.session-overlay-widget { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 9001; + display: flex; + align-items: stretch; + gap: 0; + min-width: 280px; + max-width: 360px; + background: var(--bg-elevated, #1f2937); + color: var(--text-primary, #f3f4f6); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(217, 119, 6, 0.6); + overflow: hidden; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.session-overlay-widget--unattended { + border-color: rgba(220, 38, 38, 0.7); +} + +.session-overlay-widget--collapsed { + min-width: 36px; + max-width: 36px; +} + +.session-overlay-toggle { + flex: 0 0 28px; + width: 28px; + border: none; + background: rgba(255, 255, 255, 0.05); + color: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.session-overlay-toggle:hover { + background: rgba(255, 255, 255, 0.1); +} + +.session-overlay-body { + flex: 1; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.session-overlay-header { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + font-size: 0.9rem; +} + +.session-overlay-icon { + font-size: 1.1rem; +} + +.session-overlay-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.session-overlay-row:first-of-type { + border-top: none; + padding-top: 0; +} + +.session-overlay-info { + flex: 1; + min-width: 0; +} + +.session-overlay-operator { + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-overlay-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 0.72rem; + margin-top: 2px; + color: var(--text-secondary, #cbd5e1); +} + +.session-overlay-mode { + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +.session-overlay-mode--supervised { color: #f59e0b; } +.session-overlay-mode--unattended { color: #ef4444; } + +.session-overlay-disconnect { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-size: 0.75rem; +} + +/* SettingsPanel — access-mode radio group (Phase 1) */ +.settings-access-mode { + display: flex; + flex-direction: column; + gap: 8px; + margin: 4px 0 12px 0; +} +.settings-access-mode-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border, rgba(255, 255, 255, 0.1)); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} +.settings-access-mode-option:hover { + border-color: var(--accent, #3b82f6); + background: rgba(59, 130, 246, 0.06); +} +.settings-access-mode-option input[type="radio"] { + margin-top: 3px; +} +.settings-access-mode-text { flex: 1; } +.settings-access-mode-title { font-weight: 600; font-size: 0.9rem; } +.settings-access-mode-desc { + font-size: 0.78rem; + color: var(--text-secondary, #94a3b8); + margin-top: 2px; +} diff --git a/betterdesk-agent/agent/agent.go b/betterdesk-agent/agent/agent.go index 1f0cf3d3..b4c4c8c1 100644 --- a/betterdesk-agent/agent/agent.go +++ b/betterdesk-agent/agent/agent.go @@ -775,6 +775,17 @@ func (a *Agent) stdinConsentReader() { case strings.HasPrefix(line, "CONSENT_DENIED:"): granted = false sessionID = strings.TrimPrefix(line, "CONSENT_DENIED:") + case strings.HasPrefix(line, "DESKTOP_STOP:"): + // User clicked "Disconnect" on the session overlay in the + // Tauri wrapper. Tear down the matching desktop stream. + sessionID = strings.TrimSpace(strings.TrimPrefix(line, "DESKTOP_STOP:")) + if sessionID == "" { + continue + } + if sess, loaded := a.desktopStreams.LoadAndDelete(sessionID); loaded { + sess.(*DesktopStreamer).Stop() + } + continue default: continue } diff --git a/betterdesk-agent/agent/desktop.go b/betterdesk-agent/agent/desktop.go index e18044cf..855495c7 100644 --- a/betterdesk-agent/agent/desktop.go +++ b/betterdesk-agent/agent/desktop.go @@ -165,6 +165,14 @@ func (a *Agent) handleDesktopStart(msg *Message) { streamer := newDesktopStreamer(p.SessionID, cancel) a.desktopStreams.Store(p.SessionID, streamer) + // Notify the Tauri wrapper so it can render the on-screen overlay + // (border around every monitor + collapsible session widget). The + // Tauri sidecar stdout reader translates this into a `session-active` + // event consumed by the SessionOverlay component. + fmt.Fprintf(os.Stdout, + "SESSION_START:{\"session_id\":%q,\"operator\":%q,\"mode\":%q}\n", + p.SessionID, p.OperatorName, sessionModeLabel(a.cfg.RequireConsent)) + // 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 @@ -184,10 +192,27 @@ func (a *Agent) handleDesktopStart(msg *Message) { go func() { defer close(streamer.done) defer a.desktopStreams.Delete(p.SessionID) + // Always emit SESSION_END (matched to the SESSION_START above) when + // the streamer goroutine exits, no matter the reason — stop request, + // operator disconnect, or watchdog failure. The overlay state machine + // in the Tauri wrapper depends on the symmetry of these events. + defer fmt.Fprintf(os.Stdout, + "SESSION_END:{\"session_id\":%q}\n", p.SessionID) a.streamDesktop(ctx, streamer, p.FPS, p.Quality) }() } +// sessionModeLabel converts the consent flag into the human-readable label +// the overlay UI uses to colour its border. The Go side does not yet know +// the full Tauri `access_mode` enum so it reports "supervised" vs +// "unattended" only; the Tauri wrapper can refine the colour if needed. +func sessionModeLabel(requireConsent bool) string { + if requireConsent { + return "supervised" + } + return "unattended" +} + // 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. diff --git a/betterdesk-server/api/branding_handlers.go b/betterdesk-server/api/branding_handlers.go index 04beac76..35707af2 100644 --- a/betterdesk-server/api/branding_handlers.go +++ b/betterdesk-server/api/branding_handlers.go @@ -192,6 +192,16 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { displayName, _ := s.db.GetConfig("device_display_name_" + req.DeviceID) resp := s.buildEnrollmentResponse("approved", req.DeviceID, syncMode, displayName) + // Re-issue a device_token so an agent that lost its local copy + // (e.g. user reset agent-config) can recover authentication for the + // CDAP sidecar without manual intervention. Existing tokens remain + // valid — server stores only hashes so we cannot return the prior one. + if token, err := s.issueEnrollmentDeviceToken(req.DeviceID); err == nil { + resp.DeviceToken = token + log.Printf("[API] Re-issued enrollment device token for %s (len=%d)", req.DeviceID, len(token)) + } else { + log.Printf("[API] Failed to re-issue enrollment device token for %s: %v", req.DeviceID, err) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) return diff --git a/betterdesk-server/api/server.go b/betterdesk-server/api/server.go index a7066f3b..01c89d34 100644 --- a/betterdesk-server/api/server.go +++ b/betterdesk-server/api/server.go @@ -44,24 +44,24 @@ var configKeyRegexp = regexp.MustCompile(`^[A-Za-z0-9_.\-]{1,64}$`) // Server is the HTTP API server. type Server struct { - cfg *config.Config - db db.Database - peers *peer.Map - relay *relay.Server - blocklist *security.Blocklist - bwLimiter *ratelimit.BandwidthLimiter - auditLog *audit.Logger - eventBus *eventsModule.Bus - metrics *metrics.Collector - jwtManager *auth.JWTManager - loginLimiter *ratelimit.IPLimiter - heartbeatLimiter *ratelimit.IPLimiter // BD-2026-001: rate-limit heartbeat/sysinfo + cfg *config.Config + db db.Database + peers *peer.Map + relay *relay.Server + blocklist *security.Blocklist + bwLimiter *ratelimit.BandwidthLimiter + auditLog *audit.Logger + eventBus *eventsModule.Bus + metrics *metrics.Collector + jwtManager *auth.JWTManager + loginLimiter *ratelimit.IPLimiter + heartbeatLimiter *ratelimit.IPLimiter // BD-2026-001: rate-limit heartbeat/sysinfo // SECURITY (audit fix M-07, 2026-04-10): rate-limit public enrollment and // branding endpoints to deter device-ID enumeration and config probing. enrollmentLimiter *ratelimit.IPLimiter brandingLimiter *ratelimit.IPLimiter - keyPair *crypto.KeyPair // Ed25519 keypair for signing - cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled) + keyPair *crypto.KeyPair // Ed25519 keypair for signing + cdapGw *cdap.Gateway // CDAP gateway (nil if CDAP disabled) clientTFASessions *tfaSessionStore httpSrv *http.Server wg sync.WaitGroup diff --git a/docs/AGENT_CLIENT_ROADMAP_2026-05-27.md b/docs/AGENT_CLIENT_ROADMAP_2026-05-27.md new file mode 100644 index 00000000..c05bf30c --- /dev/null +++ b/docs/AGENT_CLIENT_ROADMAP_2026-05-27.md @@ -0,0 +1,362 @@ +# BetterDesk Agent Client — Roadmap 2026-05-27 + +> Owner: BetterDesk core team. Status: planning + phase 1 in progress. +> Driving requirement: deliver a finished agent client that exposes capabilities +> RustDesk's standard agent does not offer, while remaining compatible with +> RustDesk desktop client connections where the protocol allows it. + +This document is the single source of truth for the agent client roadmap. It +supersedes the high-level outline in +[`AGENT_CLIENT_FINALIZATION_PLAN_2026-05-06.md`](AGENT_CLIENT_FINALIZATION_PLAN_2026-05-06.md) +for everything that follows phase A. Each phase below is independently +shippable and includes its own acceptance tests so the user can verify the +build on their own machine before the next phase begins. + +## Non-negotiable invariants + +These hold for every phase. Reviewers must reject any change that violates +them. + +- **Background-first**: the agent must run silently from system boot. The + end user never has to launch the GUI manually for the agent to be + reachable. +- **Tamper resistance**: a standard logged-in user cannot disable CDAP, + unregister the device, change capability gates, edit profile data, or + approve OTA updates. Those actions require either the local OS + administrator credential (root / local Administrator group / sudo) or + the server-wide master password configured by the BetterDesk + administrator. +- **Encrypted at rest**: tokens, master-password verifier hash, profile + contacts, chat history cache, branding configuration, and any cached + credentials are stored encrypted in the OS keyring (preferred) or with + an AES-GCM-wrapped local key as fallback. Plain JSON on disk is + forbidden for these fields. +- **One-way remote**: this agent never initiates outbound remote-desktop + sessions to other agents. It only accepts inbound sessions from + operators or BetterDesk MGMT clients. +- **i18n**: every new user-facing string is added to EN and PL locale + files in the same change. Hardcoded English in components is a build + failure. +- **Audited**: every privileged action (settings unlock, profile change, + OTA approval, supervised consent decision, chat employee-handover) + produces an audit event on the server. + +## Phase map + +| Phase | Title | Status | Tested by user | +|------:|-------|--------|----------------| +| 1 | Supervised/Unattended access + on-screen overlay | in progress | pending | +| 2 | Settings lock (OS admin OR server master password) | not started | pending | +| 3 | Extended user profile (name, position, phone, photo) | not started | pending | +| 4 | Agent-to-agent chat + file transfer + employee handover | not started | pending | +| 5 | On-device branding configurator | not started | pending | +| 6 | "Client Generator" tab in Node.js web console | not started | pending | +| 7 | OTA updates (server-gated) | not started | pending | +| 8 | RustDesk parity (H.264, audio, multi-monitor) | not started | pending | + +Phases ship in order. Phases 5 and 6 are coupled — branding configurator on +the device consumes deployment bundles produced by the web generator. + +--- + +## Phase 1 — Supervised / unattended access + on-screen overlay + +### Goals + +1. Add an `access_mode` policy to agent config with three values: + - `supervised` — every inbound session must be approved via the + consent dialog (current default). + - `unattended` — sessions start immediately without prompting the + user. + - `disabled` — remote desktop sessions are rejected outright. +2. Render an always-on-top overlay during an active session, on **every + monitor**: + - A coloured border frame around each screen (configurable colour; + default amber for supervised, red for unattended). + - A small collapsible "session widget" anchored to the bottom-right + of the primary monitor showing operator name, elapsed time, + "Disconnect" and "Chat" buttons, with a one-click collapse to a + tiny floating badge. +3. Update `require_consent` to be a *derived* field — `access_mode == + "supervised"` implies `require_consent=true`. Keep `require_consent` + only as a wire-compat hint for older config files. + +### Implementation notes + +- `betterdesk-agent-client/src-tauri/src/config.rs` gains + `access_mode: AccessMode { Supervised, Unattended, Disabled }` and + serializes the legacy `require_consent` field automatically based on + the enum value. +- `to_sidecar_config()` continues to pass `require_consent` to the Go + sidecar; the Go side keeps its current consent flow. The "disabled" + mode is enforced both in the Tauri config (rejects sessions before + they reach the sidecar) and on the server side (operators see the + device as `remote_disabled`). +- The overlay is a separate `tauri::WindowBuilder` window per monitor, + flagged `transparent`, `skip_taskbar`, `always_on_top`, with input + pass-through enabled outside the widget area. The widget area is the + only opaque region and traps clicks. +- The overlay subscribes to a new `session-active` Tauri event emitted + by the sidecar stdout reader when `desktop_start` succeeds, and a + matching `session-ended` event on stream close. +- Add Go-side events: emit `SESSION_START:{...}` / `SESSION_END:{...}` + on stdout in `desktop.go` so the Tauri wrapper can drive the overlay + state machine without polling. + +### New IPC commands + +- `get_access_mode() -> AccessMode` +- `set_access_mode(mode)` — gated by phase 2 settings lock once + available; phase 1 keeps the existing admin check as a placeholder. +- `disconnect_active_session(session_id)` +- `get_active_sessions() -> Vec` + +### Acceptance tests (user verifies on their machine) + +1. Set `access_mode=supervised`, trigger an operator session, confirm + the consent dialog appears and the amber border + widget render on + all monitors after the user accepts. +2. Set `access_mode=unattended`, trigger a session, confirm no consent + dialog and the red border + widget render immediately. +3. Set `access_mode=disabled`, attempt a session, confirm the operator + receives a clear "remote disabled by user policy" error and no + overlay appears. +4. Click "Disconnect" in the widget, confirm the session ends within + one second and the overlay disappears on every monitor. +5. Collapse the widget, confirm it shrinks to a floating badge and + restores on click without losing elapsed-time accuracy. + +--- + +## Phase 2 — Settings lock (OS admin OR server master password) + +### Goals + +The agent's Settings, Unregister, and capability-gate panels become a +privileged surface. The user can unlock them in either of two ways: + +1. **Local OS administrator**: the agent prompts for credentials and + verifies them through: + - Linux: PolicyKit (`org.freedesktop.policykit1.exec.allow_any`) + via `pkexec`, or `pam_unix` validation through a small + setuid helper if PolicyKit is unavailable. + - Windows: `LogonUser(LOGON32_LOGON_INTERACTIVE, + LOGON32_PROVIDER_DEFAULT)` against the local Administrators + group. + - macOS (future): `Authorization Services` framework. +2. **Server master password**: a single password configured by the + BetterDesk admin in the web console. The agent sends the candidate + to a new server endpoint (`POST /api/agent/master-auth`) protected + by the agent's CDAP credentials. The server verifies against a + bcrypt hash and returns a short-lived (5 min) settings-unlock + token. + +After unlock, the Settings panel stays open for 10 minutes (configurable +on the server, hard cap 60 min). Any privileged IPC command checks the +in-memory unlock token and rejects requests when it has expired. + +### Implementation notes + +- New module `betterdesk-agent-client/src-tauri/src/settings_lock.rs` + with `SettingsLock::request_unlock`, `validate_token`, `revoke`. +- Token is a random 32-byte value stored only in process memory; never + persisted to disk. +- Audit: every unlock attempt (success or failure) is logged to the + server via the existing audit endpoint, including method (`os_admin` + vs `server_master`), client IP, and operator if available. +- Server-side: new bcrypt-hashed column `agent_master_password_hash` + on `server_config`. Web settings panel exposes "Set / change agent + master password" with the same UX as the existing admin password + reset. +- Brute-force protection: 5 failed attempts within 5 minutes locks the + Settings panel for 15 minutes and emits a `settings_lockout` audit + event. Server endpoint enforces the same rate limit per agent. + +### Acceptance tests + +1. With no server master password set, unlock via OS admin succeeds. +2. With OS admin password incorrect, unlock fails and `settings_unlock_failed` + appears in the server audit log. +3. Set the server master password from the web panel, restart the + agent, confirm unlock via that password works without needing OS + admin. +4. Submit 5 wrong passwords in a row; confirm 15-minute lockout + triggers and audit event appears. +5. Wait 10 minutes after unlock, attempt to change a capability gate, + confirm the agent prompts to unlock again. + +--- + +## Phase 3 — Extended user profile + +### Goals + +A new "Profile" page in the agent collects optional information about the +person using the device: + +- Full name +- Job title / position +- Department (free text) +- Phone number +- Email +- Profile photo (JPEG/PNG ≤ 512 KB; auto-resized to 256×256) +- Free-text "About me" + +The page is editable by the end user (no settings lock required). The +data is sent to the server via a new `POST /api/agent/profile` endpoint +and is visible in the device detail panel of the web console. + +### Implementation notes + +- Profile fields are stored encrypted in the OS keyring under + `betterdesk-agent.profile.`. Photo is base64 in the same + blob. +- Server table `peer_profiles (peer_id, full_name, position, + department, phone, email, photo_bytea, about, updated_at)`. +- Web console adds a read-only "Profile" tab on the device detail + page and renders the photo as a 64×64 avatar in the device list. +- The profile blob is signed by the agent's auth token to prevent + tampering by a malicious sidecar process. + +### Acceptance tests + +1. Fill the profile form, save, confirm the data appears in the web + console without refreshing. +2. Upload a 1 MB photo, confirm the agent rejects with a clear + "photo too large" error. +3. Clear the photo, confirm the avatar in the web console reverts to + the default initials badge. + +--- + +## Phase 4 — Agent-to-agent chat with file transfer + employee handover + +### Goals + +1. **Contact list** in the agent shows every other agent registered on + the same server, grouped by online state. Operators and admins are + highlighted with a distinct colour/icon. Inactive ("sleeping") + profiles created by an employee handover are hidden from the + chooser but their history is preserved. +2. **One-to-one chat** between any two agents, persisted in the server + database (`chat_messages` and `chat_threads` tables, encrypted with + the existing `chatCrypto.js` E2E module from Phase 2 of the chat + system). +3. **File transfer ≤ 100 MB** per file with progress feedback and a + simple antivirus heuristic (extension blacklist, max-size enforced + server-side too). +4. **Employee handover**: + - Settings → Profile → "Hand over this workstation to another + employee" wizard. + - Requires settings unlock (phase 2). + - The current profile is marked `status=sleeping`, hidden from + contact lists, and its chat threads become read-only. + - A new profile is collected (phase 3 fields), assigned a new + identity within the same device record. + - The chat history of the previous employee is preserved on the + server but no peer can post to those threads. + +### Implementation notes + +- File transfer reuses the existing CDAP `file_*` message family with + a new `chat_file_offer` / `chat_file_chunk` extension to keep large + transfers off the desktop streaming path. +- Server enforces the 100 MB limit and the extension blacklist before + forwarding any chunks. +- Employee handover audits: `employee_handover_started`, + `employee_handover_completed`. Both include the outgoing and + incoming profile identifiers. + +### Acceptance tests + +1. Send a 50 MB ZIP between two agents, confirm progress UI updates + and SHA-256 of the received file matches the sender. +2. Attempt to send a 150 MB file, confirm the agent rejects locally + with a clear error before any upload starts. +3. Run an employee handover, confirm the old profile becomes hidden + in the contact list of a third agent, the old threads are + read-only, and the new profile receives messages normally. + +--- + +## Phase 5 — On-device branding configurator + +The agent ships as a single neutral binary. After installation, an +administrator can run `betterdesk-agent --configure` (or use the +Settings → Branding page after unlock) to: + +- Set custom application name, tray icon, primary colour, logo. +- Optionally fetch a deployment bundle (phase 6) from the server. + +Bundle storage: signed JSON wrapped with AES-GCM using a key derived +from the OS keyring. The agent verifies the signature against the +public key embedded in the binary at compile time; bundles signed by +unknown keys are rejected. + +## Phase 6 — Client Generator panel in Node.js web console + +A new top-level navigation entry "Client Generator" in the web console +lets admins produce deployment bundles consumed by phase 5. The bundle +contains: + +- Server address(es), API key, CDAP port. +- Branding (name, colour, logo PNG, tray icon). +- Default capability gates and access mode. +- Optional master-password reset trigger. + +The bundle is downloadable as a `.bdbundle` file and pushed to the +agent through the OTA channel when phase 7 is live. + +## Phase 7 — OTA updates + +Server-side approval workflow: + +1. Admin uploads a new agent build (`.tar.gz`) to the web console. +2. The release is staged behind a "Roll out" toggle, optionally to a + subset of devices via tag filter. +3. Each agent polls `GET /api/agent/update-channel`, downloads the + approved release, verifies signature, applies it on next restart. + +Self-update is gated by either OS admin credentials or the server +master password; an unattended-only flag in the channel definition +allows zero-touch installs in managed environments. + +## Phase 8 — RustDesk parity + +The final phase. Pulled directly from +[`AGENT_CLIENT_FINALIZATION_PLAN_2026-05-06.md` Phase C/D](AGENT_CLIENT_FINALIZATION_PLAN_2026-05-06.md): + +- Linux: X11 / Wayland (PipeWire portal), VAAPI / NVENC / AMF. +- Windows: DXGI / Windows Graphics Capture, Media Foundation / NVENC. +- macOS: ScreenCaptureKit, VideoToolbox. +- CDAP message families: `desktop_*`, `codec_*`, `monitor_*`, + `clipboard_*`, `file_*`, `audio_*`, `consent_*`. + +This phase is gated by the user; we revisit when phases 1–7 are +shipping in production. + +--- + +## Testing protocol between phases + +1. The implementer pushes the change and writes the matching acceptance + tests above. +2. The user runs the build on their workstation (Linux, primary + target) and reports against the test list. +3. Bugs are fixed in the same phase before the next phase starts. +4. Once accepted, the phase is marked complete in this document and a + short "what changed" note is appended to + [`.github/copilot-instructions.md`](../.github/copilot-instructions.md). + +## Open questions + +These are tracked but do not block phase 1 execution. + +- Should the on-screen overlay also pulse when CDAP reconnects after a + drop, or stay quiet? +- Should the employee-handover wizard offer to export the old + employee's chat history as a PDF before sealing it? +- Should the master password support per-device override values, or is + one server-wide value enough? + +Updates to this roadmap go through the same review process as code.