chore: gitignore fix, enrollment token re-issue, desktop session events

.gitignore: scope /lib/ and /lib64/ to repo root only — was matching Tauri src/lib directories. Go server: re-issue device_token on re-registration so agents that lost config can recover auth. Go agent: emit SESSION_START/SESSION_END stdout events for overlay integration, handle DESKTOP_STOP stdin command.

This commit was made possible thanks to Insolve.
This commit is contained in:
UNITRONIX
2026-05-28 01:14:09 +02:00
parent 97d228545f
commit 8558f4cf4d
22 changed files with 1469 additions and 33 deletions
+5 -2
View File
@@ -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/
+3 -3
View File
@@ -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"
}
}
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BetterDesk Session</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'" />
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: transparent !important;
overflow: hidden;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
.border {
position: fixed;
inset: 0;
border-style: solid;
border-width: 6px;
box-sizing: border-box;
pointer-events: none;
animation: pulse 2s ease-in-out infinite;
}
.border.supervised {
border-color: #d97706; /* amber-600 */
box-shadow: inset 0 0 32px rgba(217, 119, 6, 0.55);
}
.border.unattended {
border-color: #dc2626; /* red-600 */
box-shadow: inset 0 0 36px rgba(220, 38, 38, 0.65);
}
@keyframes pulse {
0%, 100% { opacity: 0.85; }
50% { opacity: 1.0; }
}
</style>
</head>
<body>
<div id="b" class="border supervised" aria-hidden="true"></div>
<script>
// Pick supervised/unattended class from `?mode=...` so the same
// overlay window can be reused for both colours by Rust without
// reloading the page (we toggle via location.replace if needed).
(function () {
var params = new URLSearchParams(window.location.search);
var mode = (params.get("mode") || "supervised").toLowerCase();
var el = document.getElementById("b");
if (!el) return;
el.classList.remove("supervised", "unattended");
el.classList.add(mode === "unattended" ? "unattended" : "supervised");
})();
</script>
</body>
</html>
@@ -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);
}
@@ -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::<serde_json::Value>(&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,
}
@@ -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");
@@ -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<i32> = *m.position();
let ps: PhysicalSize<u32> = *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");
}
}
}
@@ -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::<crate::commands::AgentState>() {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&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::<crate::commands::AgentState>() {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&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);
+2
View File
@@ -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<RegisteredShellProps> = (props) => {
<div class="app-layout app-layout-tray">
<NavigationListener />
<ConsentDialog />
<SessionOverlay />
<main class="app-main app-main-full">{routerProps.children}</main>
<BottomNav isAdmin={props.isAdmin} onQuit={props.onQuit} />
</div>
@@ -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<ActiveSession[]>([]);
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<ActiveSession[]>("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<string>("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<string>("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 (
<Show when={sessions().length > 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. */}
<div
class="session-overlay-widget"
classList={{
"session-overlay-widget--collapsed": collapsed(),
"session-overlay-widget--unattended": primaryMode() === "unattended",
}}
>
<button
type="button"
class="session-overlay-toggle"
onClick={() => setCollapsed((v) => !v)}
aria-label={
collapsed() ? t("session.expand") : t("session.collapse")
}
>
<span class="material-symbols-rounded">
{collapsed() ? "chevron_left" : "chevron_right"}
</span>
</button>
<Show when={!collapsed()}>
<div class="session-overlay-body">
<div class="session-overlay-header">
<span class="material-symbols-rounded session-overlay-icon">
screen_share
</span>
<span class="session-overlay-title">
{t("session.active_title")}
</span>
</div>
<For each={sessions()}>
{(s) => (
<div class="session-overlay-row">
<div class="session-overlay-info">
<div class="session-overlay-operator">
<strong>{s.operator || "—"}</strong>
</div>
<div class="session-overlay-meta">
<span class={`session-overlay-mode session-overlay-mode--${s.mode}`}>
{s.mode === "unattended"
? t("session.unattended")
: t("session.supervised")}
</span>
<span class="session-overlay-elapsed">
{t("session.elapsed")} {formatElapsed(s.started_at)}
</span>
</div>
</div>
<button
type="button"
class="btn btn-danger btn-sm session-overlay-disconnect"
onClick={() => disconnect(s.session_id)}
>
<span class="material-symbols-rounded">link_off</span>
{t("session.disconnect")}
</button>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
);
};
export default SessionOverlay;
@@ -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<SettingsPanelProps> = (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<SettingsPanelProps> = (props) => {
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-label">{t("settings.require_consent")}</div>
<div class="settings-toggle-hint">{t("settings.require_consent_hint")}</div>
<div class="settings-toggle-label">{t("access_mode.label")}</div>
<div class="settings-toggle-hint">{t("access_mode.description")}</div>
</div>
<label class="toggle-switch">
<input
type="checkbox"
checked={settings().require_consent}
onChange={(e) => updateSetting("require_consent", e.currentTarget.checked)}
/>
<span class="toggle-slider" />
</label>
</div>
<div class="settings-access-mode">
{(["supervised", "unattended", "disabled"] as const).map((mode) => (
<label class="settings-access-mode-option">
<input
type="radio"
name="access_mode"
value={mode}
checked={settings().access_mode === mode}
onChange={() => {
updateSetting("access_mode", mode);
updateSetting("require_consent", mode === "supervised");
}}
/>
<div class="settings-access-mode-text">
<div class="settings-access-mode-title">
{t(`access_mode.${mode}`)}
</div>
<div class="settings-access-mode-desc">
{t(`access_mode.${mode}_desc`)}
</div>
</div>
</label>
))}
</div>
<div class="settings-toggle-row">
+114
View File
@@ -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<string, unknown>;
const BUNDLES: Record<string, Bundle> = {
en: en as Bundle,
pl: pl as Bundle,
"zh-TW": zhTW as Bundle,
};
const DISPLAY_NAMES: Record<string, string> = {
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<string, unknown>)) {
node = (node as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return typeof node === "string" ? node : undefined;
}
function interpolate(template: string, params?: Record<string, string | number>): 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, string | number>): 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);
}
+88
View File
@@ -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,
});
});
}
@@ -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."
}
}
@@ -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."
}
}
@@ -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;
}
+11
View File
@@ -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
}
+25
View File
@@ -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.
@@ -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
+14 -14
View File
@@ -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
+362
View File
@@ -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<SessionInfo>`
### 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.<device_id>`. 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 17 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.