Fix file-transfer
build-windows / build-hello-agent-x64 (push) Successful in 6m7s
build-windows / sign-hello-agent-x64 (push) Successful in 6s
build-windows / validate-hello-agent-x64 (push) Successful in 7s

This commit is contained in:
2026-05-09 10:46:51 +02:00
parent b59be25a16
commit e815776329
5 changed files with 131 additions and 181 deletions
+6 -3
View File
@@ -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" }
+8 -4
View File
@@ -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,
}
+109 -172
View File
@@ -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<Mutex<HashMap<i32, (String, String)>>>,
}
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")]
+7 -1
View File
@@ -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;
+1 -1
View File
@@ -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";