diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index 665f8b1..8ea2258 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -2,13 +2,13 @@ name: build-windows on: push: - branches: [main, master] + branches: [pro-features] workflow_dispatch: inputs: version_suffix: description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla." type: string - default: "" + default: "cst" # Workflow-level env is visible to every job. Runner-specific paths # (VCPKG_ROOT, LLVM_HOME, …) live on the build-x64 job instead, since the @@ -125,7 +125,10 @@ jobs: id: version shell: pwsh env: - VERSION_SUFFIX: ${{ inputs.version_suffix }} + # On push events `inputs.*` is empty — the workflow_dispatch default + # ("cst") doesn't apply. Fall back to "cst" in-script so push and + # dispatch produce the same default tag shape. + VERSION_SUFFIX: ${{ inputs.version_suffix || 'cst' }} run: | $base = (Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"').Matches[0].Groups[1].Value if (-not $base) { throw "could not parse version from Cargo.toml" } diff --git a/src/cli.rs b/src/cli.rs index c307d01..b4ac78d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,10 +22,14 @@ pub enum Action { ConfigOnly, /// No flags. Foreground dev mode. None, - /// `--cm`. Connection-manager popup mode. Spawned as a USER-token child - /// by the SYSTEM-token `--server` worker (via librustdesk's - /// `run_as_user`) when a peer needs interactive approval. Binds the - /// `_cm` IPC pipe, shows MessageBoxW, replies, exits. + /// `--cm`. Connection-manager process. Spawned as a USER-token child of + /// the SYSTEM-token `--server` worker (either pre-emptively by hello-agent's + /// own `spawn_cm_into_user_desktop`, or as a fallback by librustdesk's + /// `run_as_user` when its first `ipc::connect("_cm")` fails). Binds the + /// `_cm` named pipe, runs upstream's `IpcTaskRunner` for each incoming + /// `--server` connection, and lives for as long as the user session does + /// — every `Data::FS(...)` frame the server sends is executed here, in + /// the user's security context. Cm, } diff --git a/src/cm_popup.rs b/src/cm_popup.rs index 3dd787d..eb64b59 100644 --- a/src/cm_popup.rs +++ b/src/cm_popup.rs @@ -1,4 +1,5 @@ -// Approval popup, run in a dedicated `--cm` child process. +// Approval popup + connection-manager process body, run in a dedicated +// `--cm` child process. // // Architecture (matches stock rustdesk): // @@ -8,29 +9,34 @@ // --server (user session, SYSTEM token) --- screen capture, rendezvous, … // │ on incoming peer requiring approval, librustdesk's start_ipc // │ tries `ipc::connect("_cm")`, fails (no listener), then falls -// │ back to `run_as_user(["--cm"])`: +// │ back to `run_as_user(["--cm"])`. Either way, a `--cm` process +// │ must be holding the `_cm` named pipe in the user's session: // ▼ // --cm (user session, USER token) --- this module -// │ binds `_cm`, accepts one connection from the parent's start_ipc, -// │ reads frames until it sees Data::Login{authorized:false, …}, -// │ shows MessageBoxW (works cleanly because USER token + interactive -// │ desktop), replies Data::Authorize / Data::Close, drains the -// │ stream until the server closes it, exits. +// │ binds `_cm`, accepts connections from `--server`, hands each one +// │ to upstream's `IpcTaskRunner` (via `start_ipc`). Our only role +// │ on top of that is to plug in an `InvokeUiCM` impl that renders +// │ the approval popup with `MessageBoxW` and forwards the user's +// │ decision back via `authorize(id)` / `close(id)`. // -// The previous design (run cm_popup as a thread inside the SYSTEM-token -// --server worker) hit Windows' UI-isolation rules — `MessageBoxW` from a -// SYSTEM-token process technically returns successfully but draws on a -// desktop the logged-in user can't see, so the popup was invisible. -// Spawning as a USER child sidesteps the whole class of issues. +// Why this delegates to upstream's `start_ipc` instead of running its own +// frame loop: on Windows the `--server` process forwards *every* filesystem +// operation (ReadDir, ReadFile, WriteBlock, …) over the `_cm` pipe and +// expects the CM to execute them in the user's security context. Reading +// only the Login frame and discarding the rest — what an earlier version of +// this module did — meant the supporter could open a file-transfer session +// and get the request approved, but the directory listing never arrived +// because the `Data::FS(ReadDir)` frame was being silently dropped. The +// upstream `IpcTaskRunner` implements all of that machinery (handle_fs + +// the file_timer for streaming read jobs); we just provide the popup. -use anyhow::Result; -use librustdesk::ipc; +use librustdesk::ui_cm_interface::{self, Client, ConnectionManager, InvokeUiCM}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; #[cfg(target_os = "windows")] use std::os::windows::ffi::OsStrExt; -const POSTFIX: &str = "_cm"; - /// Diagnostic trace: writes to stderr AND a debug log file. /// Bypasses `log` so we still see output even when env_logger / flexi_logger /// init went wrong. Drop these calls once the popup mechanism is stable. @@ -56,174 +62,105 @@ fn trace(msg: &str) { } } -/// Run the popup loop forever on a freshly-created Tokio runtime. -/// Safe to call from a `std::thread::spawn` body. +/// Connection-manager process entry point: bind `_cm`, accept connections +/// from the `--server` worker forever, run upstream's IpcTaskRunner on each. +/// +/// `start_ipc` is `#[tokio::main(flavor = "current_thread")]` — it builds +/// its own runtime internally — so this is callable from sync context. pub fn run_blocking() { trace("run_blocking entered"); + let cm = ConnectionManager { + ui_handler: HeadlessCm::default(), + }; + // Returns only on listener error (e.g. another --cm already holds the + // pipe) or process shutdown. Either way there's nothing to do after. + ui_cm_interface::start_ipc(cm); + trace("start_ipc returned"); +} - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - trace(&format!("build runtime: {e}")); +/// `InvokeUiCM` adapter for hello-agent. Stateless except for a small map +/// of `(connection id) -> (peer_id, name)` we keep so we can render a +/// "session ended" notification at remove time (the `Client` is dropped +/// from upstream's `CLIENTS` registry before our `remove_connection` hook +/// is called, so we can't fish the peer info out of there). +#[derive(Clone, Default)] +struct HeadlessCm { + /// Tracks peers we approved. Connections that the user denied are not + /// inserted here, so they don't trigger a "session ended" banner the + /// user has no context for. + approved: Arc>>, +} + +impl InvokeUiCM for HeadlessCm { + /// Called by upstream's IPC loop the moment a peer's Login frame is + /// received and the client has been registered in the global CLIENTS + /// map. We must NOT block here — the same task that called us is + /// also the one that pumps the `_cm` pipe, so blocking on a user + /// click would prevent the IPC loop from ever delivering the + /// `Data::Authorize` we send back. + fn add_connection(&self, client: &Client) { + trace(&format!( + "add_connection: id={} peer_id={} name={} authorized={}", + client.id, client.peer_id, client.name, client.authorized + )); + + if client.authorized { + // Already authorized (e.g. password-based auth). No popup, + // but track so remove_connection can show "session ended". + self.approved + .lock() + .unwrap() + .insert(client.id, (client.peer_id.clone(), client.name.clone())); return; } - }; - trace("runtime built; entering serve()"); - if let Err(e) = rt.block_on(serve()) { - trace(&format!("serve exited: {e:#}")); - } else { - trace("serve returned cleanly"); + // Render the approval MessageBox on a fresh OS thread so the IPC + // task that called us stays responsive. On Yes we register the + // peer in `approved` and call `authorize(id)` which sends + // `Data::Authorize` back to `--server`; on No we call `close(id)` + // which sends `Data::Close` and the server tears the session down. + let id = client.id; + let peer_id = client.peer_id.clone(); + let name = client.name.clone(); + let approved_map = self.approved.clone(); + std::thread::spawn(move || { + let approved = show_messagebox(&peer_id, &name); + trace(&format!("add_connection: MessageBox approved={approved}")); + if approved { + approved_map + .lock() + .unwrap() + .insert(id, (peer_id, name)); + ui_cm_interface::authorize(id); + } else { + ui_cm_interface::close(id); + } + }); } -} -/// Bind `_cm`, accept connections from `--server`'s `start_ipc` for as -/// long as the user session lasts. Each connection corresponds to one -/// peer requesting approval; we handle them concurrently. -async fn serve() -> Result<()> { - trace(&format!("calling new_listener({POSTFIX})")); - let mut incoming = match ipc::new_listener(POSTFIX).await { - Ok(i) => { - trace("new_listener succeeded"); - i - } - Err(e) => { - trace(&format!("new_listener failed: {e}")); - return Err(anyhow::anyhow!("new_listener({POSTFIX}): {e}")); - } - }; - - trace("entering accept loop"); - while let Some(result) = incoming.next().await { - match result { - Ok(stream) => { - trace("accepted incoming connection"); - let conn = ipc::Connection::new(stream); - tokio::spawn(async move { - if let Err(e) = handle_one(conn).await { - trace(&format!("handle_one error: {e:#}")); - } - }); - } - Err(e) => { - trace(&format!("accept error: {e}")); - } - } - } - trace("accept loop exited"); - Ok(()) -} - -async fn handle_one(mut conn: ipc::Connection) -> Result<()> { - // Frame ordering on the `_cm` pipe is NOT "Login first, then chatter". - // For an installed/portable controlled side, the server first emits - // `Data::DataPortableService(CmShowElevation(...))` so the Flutter CM - // can render its elevation banner. The `Data::Login` we care about - // arrives a moment later. We loop through frames, ignore everything - // until we see Login{authorized:false}, decide once, and from then on - // just drain the stream so the server's `tx_to_cm.send()` calls don't - // back up. - // - // We use `conn.next()` (no timeout). A long active session can sit - // quiet for tens of minutes — `tx_to_cm` only fires on Login, FS - // transfers, and connection-close — so a short read timeout would - // false-positive into "session ended" UX during normal use. - trace("handle_one: entering frame loop"); - let mut decided = false; - // Set when the user clicks Yes on the approval popup. Carries the - // peer's id / name for the matching "session ended" notification we - // fire after the server tears the connection down. - let mut approved_peer: Option<(String, String)> = None; - - loop { - match conn.next().await { - Ok(Some(ipc::Data::Login { - peer_id, - name, - authorized: false, - .. - })) if !decided => { - trace(&format!( - "handle_one: Login peer_id={peer_id} name={name} authorized=false" - )); - decided = true; - - let approved = ask_user_blocking(&peer_id, &name).await; - trace(&format!( - "handle_one: MessageBox returned approved={approved}" - )); - - if approved { - let _ = conn.send(&ipc::Data::Authorize).await; - trace("handle_one: sent Authorize"); - approved_peer = Some((peer_id, name)); - } else { - let _ = conn.send(&ipc::Data::Close).await; - trace("handle_one: sent Close — exiting handler"); - return Ok(()); - } - } - Ok(Some(ipc::Data::Close)) | Ok(Some(ipc::Data::Disconnected)) => { - // Server signals the supporter has left (or the - // connection failed). Fall through to the post-loop - // notification path. - trace("handle_one: server sent Close/Disconnected"); - break; - } - Ok(Some(other)) => { - // Pre-login chatter (CmShowElevation), or post-Authorize - // chatter (chat, file transfer events, voice call). We - // don't act on any of it — the Flutter CM would, we just - // need to consume frames so the server's send buffer - // drains. - trace(&format!("handle_one: ignoring frame: {other:?}")); - continue; - } - Ok(None) => { - trace("handle_one: stream closed by peer"); - break; - } - Err(e) => { - trace(&format!("handle_one: stream error: {e}")); - break; - } + fn remove_connection(&self, id: i32, _close: bool) { + trace(&format!("remove_connection: id={id}")); + let entry = self.approved.lock().unwrap().remove(&id); + if let Some((peer_id, name)) = entry { + std::thread::spawn(move || show_session_ended(&peer_id, &name)); } } - // Tell the user the supporter is gone. Only fires when we approved - // the connection — denied/cancelled connections already returned - // above, and pre-approval Close from the server (e.g., auth failure - // before the popup even fired) shouldn't show a "session ended" - // banner the user has no context for. - if let Some((peer_id, name)) = approved_peer { - notify_session_ended(&peer_id, &name).await; + // The remaining InvokeUiCM hooks fire for chat / theme / voice-call / + // file-transfer-log / privacy-mode-elevation events. Hello-agent + // doesn't surface any of them — file transfers complete silently in + // the background (the supporter's UI shows progress on their end), + // chat is unsupported, voice call is unsupported. Stubs only. + fn new_message(&self, _id: i32, _text: String) {} + fn change_theme(&self, _dark: String) {} + fn change_language(&self) {} + fn show_elevation(&self, _show: bool) {} + fn update_voice_call_state(&self, _client: &Client) {} + fn file_transfer_log(&self, action: &str, log: &str) { + // Useful breadcrumb for debugging file-transfer failures, gated + // behind trace() to keep production stderr quiet. + trace(&format!("file_transfer_log: action={action} log={log}")); } - trace("handle_one: returning"); - Ok(()) -} - -/// Show a native MessageBox in the calling (user) session. Runs the dialog -/// on tokio's blocking thread pool so we don't park the reactor while it -/// waits for the user to click. -async fn ask_user_blocking(peer_id: &str, name: &str) -> bool { - let peer_id = peer_id.to_string(); - let name = name.to_string(); - tokio::task::spawn_blocking(move || show_messagebox(&peer_id, &name)) - .await - .unwrap_or(false) -} - -/// Inform the user that the remote support session has ended. Best-effort: -/// errors out of the OS dialog APIs are logged (via `trace`) and otherwise -/// ignored — failing to show the post-session banner shouldn't block the -/// handler from cleaning up. -async fn notify_session_ended(peer_id: &str, name: &str) { - let peer_id = peer_id.to_string(); - let name = name.to_string(); - let _ = tokio::task::spawn_blocking(move || show_session_ended(&peer_id, &name)).await; } #[cfg(target_os = "windows")] diff --git a/vendor/rustdesk/src/lib.rs b/vendor/rustdesk/src/lib.rs index c28da09..77cfd68 100644 --- a/vendor/rustdesk/src/lib.rs +++ b/vendor/rustdesk/src/lib.rs @@ -62,7 +62,13 @@ mod whiteboard; #[cfg(not(any(target_os = "android", target_os = "ios")))] mod updater; -mod ui_cm_interface; +// `ui_cm_interface` exposes the full Connection-Manager IPC loop used by the +// Flutter UI (`start_ipc` + the `InvokeUiCM` trait + `authorize`/`close`/...). +// Made pub so hello-agent's headless `--cm` process can plug in its own +// MessageBoxW-based `InvokeUiCM` impl and inherit upstream's file-transfer, +// chat, and clipboard handling instead of having to re-implement `handle_fs` +// and the read-job timer. +pub mod ui_cm_interface; mod ui_interface; mod ui_session_interface; diff --git a/vendor/rustdesk/src/version.rs b/vendor/rustdesk/src/version.rs index 6f20785..5018e1d 100644 --- a/vendor/rustdesk/src/version.rs +++ b/vendor/rustdesk/src/version.rs @@ -1,3 +1,3 @@ pub const VERSION: &str = "1.4.6"; #[allow(dead_code)] -pub const BUILD_DATE: &str = "2026-05-08 14:54"; +pub const BUILD_DATE: &str = "2026-05-09 10:43";