Fix file-transfer
This commit is contained in:
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+107
-170
@@ -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 rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
trace(&format!("build runtime: {e}"));
|
||||
return;
|
||||
}
|
||||
let cm = ConnectionManager {
|
||||
ui_handler: HeadlessCm::default(),
|
||||
};
|
||||
trace("runtime built; entering serve()");
|
||||
|
||||
if let Err(e) = rt.block_on(serve()) {
|
||||
trace(&format!("serve exited: {e:#}"));
|
||||
} else {
|
||||
trace("serve returned cleanly");
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
|
||||
/// 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}"));
|
||||
}
|
||||
};
|
||||
/// `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)>>>,
|
||||
}
|
||||
|
||||
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:#}"));
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
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")]
|
||||
|
||||
Vendored
+7
-1
@@ -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;
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user