+
+ 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. */}
+
+
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.