mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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:
+5
-2
@@ -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/
|
||||
|
||||
@@ -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>
|
||||
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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.
|
||||
Reference in New Issue
Block a user