Fix file-transfer
This commit is contained in:
@@ -2,13 +2,13 @@ name: build-windows
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, master]
|
branches: [pro-features]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version_suffix:
|
version_suffix:
|
||||||
description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla."
|
description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla."
|
||||||
type: string
|
type: string
|
||||||
default: ""
|
default: "cst"
|
||||||
|
|
||||||
# Workflow-level env is visible to every job. Runner-specific paths
|
# Workflow-level env is visible to every job. Runner-specific paths
|
||||||
# (VCPKG_ROOT, LLVM_HOME, …) live on the build-x64 job instead, since the
|
# (VCPKG_ROOT, LLVM_HOME, …) live on the build-x64 job instead, since the
|
||||||
@@ -125,7 +125,10 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
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: |
|
run: |
|
||||||
$base = (Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"').Matches[0].Groups[1].Value
|
$base = (Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"').Matches[0].Groups[1].Value
|
||||||
if (-not $base) { throw "could not parse version from Cargo.toml" }
|
if (-not $base) { throw "could not parse version from Cargo.toml" }
|
||||||
|
|||||||
+8
-4
@@ -22,10 +22,14 @@ pub enum Action {
|
|||||||
ConfigOnly,
|
ConfigOnly,
|
||||||
/// No flags. Foreground dev mode.
|
/// No flags. Foreground dev mode.
|
||||||
None,
|
None,
|
||||||
/// `--cm`. Connection-manager popup mode. Spawned as a USER-token child
|
/// `--cm`. Connection-manager process. Spawned as a USER-token child of
|
||||||
/// by the SYSTEM-token `--server` worker (via librustdesk's
|
/// the SYSTEM-token `--server` worker (either pre-emptively by hello-agent's
|
||||||
/// `run_as_user`) when a peer needs interactive approval. Binds the
|
/// own `spawn_cm_into_user_desktop`, or as a fallback by librustdesk's
|
||||||
/// `_cm` IPC pipe, shows MessageBoxW, replies, exits.
|
/// `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,
|
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):
|
// Architecture (matches stock rustdesk):
|
||||||
//
|
//
|
||||||
@@ -8,29 +9,34 @@
|
|||||||
// --server (user session, SYSTEM token) --- screen capture, rendezvous, …
|
// --server (user session, SYSTEM token) --- screen capture, rendezvous, …
|
||||||
// │ on incoming peer requiring approval, librustdesk's start_ipc
|
// │ on incoming peer requiring approval, librustdesk's start_ipc
|
||||||
// │ tries `ipc::connect("_cm")`, fails (no listener), then falls
|
// │ 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
|
// --cm (user session, USER token) --- this module
|
||||||
// │ binds `_cm`, accepts one connection from the parent's start_ipc,
|
// │ binds `_cm`, accepts connections from `--server`, hands each one
|
||||||
// │ reads frames until it sees Data::Login{authorized:false, …},
|
// │ to upstream's `IpcTaskRunner` (via `start_ipc`). Our only role
|
||||||
// │ shows MessageBoxW (works cleanly because USER token + interactive
|
// │ on top of that is to plug in an `InvokeUiCM` impl that renders
|
||||||
// │ desktop), replies Data::Authorize / Data::Close, drains the
|
// │ the approval popup with `MessageBoxW` and forwards the user's
|
||||||
// │ stream until the server closes it, exits.
|
// │ decision back via `authorize(id)` / `close(id)`.
|
||||||
//
|
//
|
||||||
// The previous design (run cm_popup as a thread inside the SYSTEM-token
|
// Why this delegates to upstream's `start_ipc` instead of running its own
|
||||||
// --server worker) hit Windows' UI-isolation rules — `MessageBoxW` from a
|
// frame loop: on Windows the `--server` process forwards *every* filesystem
|
||||||
// SYSTEM-token process technically returns successfully but draws on a
|
// operation (ReadDir, ReadFile, WriteBlock, …) over the `_cm` pipe and
|
||||||
// desktop the logged-in user can't see, so the popup was invisible.
|
// expects the CM to execute them in the user's security context. Reading
|
||||||
// Spawning as a USER child sidesteps the whole class of issues.
|
// 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::ui_cm_interface::{self, Client, ConnectionManager, InvokeUiCM};
|
||||||
use librustdesk::ipc;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
|
||||||
const POSTFIX: &str = "_cm";
|
|
||||||
|
|
||||||
/// Diagnostic trace: writes to stderr AND a debug log file.
|
/// Diagnostic trace: writes to stderr AND a debug log file.
|
||||||
/// Bypasses `log` so we still see output even when env_logger / flexi_logger
|
/// 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.
|
/// 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.
|
/// Connection-manager process entry point: bind `_cm`, accept connections
|
||||||
/// Safe to call from a `std::thread::spawn` body.
|
/// 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() {
|
pub fn run_blocking() {
|
||||||
trace("run_blocking entered");
|
trace("run_blocking entered");
|
||||||
|
let cm = ConnectionManager {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
ui_handler: HeadlessCm::default(),
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(rt) => rt,
|
|
||||||
Err(e) => {
|
|
||||||
trace(&format!("build runtime: {e}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
trace("runtime built; entering serve()");
|
// Returns only on listener error (e.g. another --cm already holds the
|
||||||
|
// pipe) or process shutdown. Either way there's nothing to do after.
|
||||||
if let Err(e) = rt.block_on(serve()) {
|
ui_cm_interface::start_ipc(cm);
|
||||||
trace(&format!("serve exited: {e:#}"));
|
trace("start_ipc returned");
|
||||||
} else {
|
|
||||||
trace("serve returned cleanly");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bind `_cm`, accept connections from `--server`'s `start_ipc` for as
|
/// `InvokeUiCM` adapter for hello-agent. Stateless except for a small map
|
||||||
/// long as the user session lasts. Each connection corresponds to one
|
/// of `(connection id) -> (peer_id, name)` we keep so we can render a
|
||||||
/// peer requesting approval; we handle them concurrently.
|
/// "session ended" notification at remove time (the `Client` is dropped
|
||||||
async fn serve() -> Result<()> {
|
/// from upstream's `CLIENTS` registry before our `remove_connection` hook
|
||||||
trace(&format!("calling new_listener({POSTFIX})"));
|
/// is called, so we can't fish the peer info out of there).
|
||||||
let mut incoming = match ipc::new_listener(POSTFIX).await {
|
#[derive(Clone, Default)]
|
||||||
Ok(i) => {
|
struct HeadlessCm {
|
||||||
trace("new_listener succeeded");
|
/// Tracks peers we approved. Connections that the user denied are not
|
||||||
i
|
/// inserted here, so they don't trigger a "session ended" banner the
|
||||||
}
|
/// user has no context for.
|
||||||
Err(e) => {
|
approved: Arc<Mutex<HashMap<i32, (String, String)>>>,
|
||||||
trace(&format!("new_listener failed: {e}"));
|
}
|
||||||
return Err(anyhow::anyhow!("new_listener({POSTFIX}): {e}"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
trace("entering accept loop");
|
impl InvokeUiCM for HeadlessCm {
|
||||||
while let Some(result) = incoming.next().await {
|
/// Called by upstream's IPC loop the moment a peer's Login frame is
|
||||||
match result {
|
/// received and the client has been registered in the global CLIENTS
|
||||||
Ok(stream) => {
|
/// map. We must NOT block here — the same task that called us is
|
||||||
trace("accepted incoming connection");
|
/// also the one that pumps the `_cm` pipe, so blocking on a user
|
||||||
let conn = ipc::Connection::new(stream);
|
/// click would prevent the IPC loop from ever delivering the
|
||||||
tokio::spawn(async move {
|
/// `Data::Authorize` we send back.
|
||||||
if let Err(e) = handle_one(conn).await {
|
fn add_connection(&self, client: &Client) {
|
||||||
trace(&format!("handle_one error: {e:#}"));
|
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<()> {
|
fn remove_connection(&self, id: i32, _close: bool) {
|
||||||
// Frame ordering on the `_cm` pipe is NOT "Login first, then chatter".
|
trace(&format!("remove_connection: id={id}"));
|
||||||
// For an installed/portable controlled side, the server first emits
|
let entry = self.approved.lock().unwrap().remove(&id);
|
||||||
// `Data::DataPortableService(CmShowElevation(...))` so the Flutter CM
|
if let Some((peer_id, name)) = entry {
|
||||||
// can render its elevation banner. The `Data::Login` we care about
|
std::thread::spawn(move || show_session_ended(&peer_id, &name));
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the user the supporter is gone. Only fires when we approved
|
// The remaining InvokeUiCM hooks fire for chat / theme / voice-call /
|
||||||
// the connection — denied/cancelled connections already returned
|
// file-transfer-log / privacy-mode-elevation events. Hello-agent
|
||||||
// above, and pre-approval Close from the server (e.g., auth failure
|
// doesn't surface any of them — file transfers complete silently in
|
||||||
// before the popup even fired) shouldn't show a "session ended"
|
// the background (the supporter's UI shows progress on their end),
|
||||||
// banner the user has no context for.
|
// chat is unsupported, voice call is unsupported. Stubs only.
|
||||||
if let Some((peer_id, name)) = approved_peer {
|
fn new_message(&self, _id: i32, _text: String) {}
|
||||||
notify_session_ended(&peer_id, &name).await;
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
Vendored
+7
-1
@@ -62,7 +62,13 @@ mod whiteboard;
|
|||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
mod updater;
|
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_interface;
|
||||||
mod ui_session_interface;
|
mod ui_session_interface;
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
pub const VERSION: &str = "1.4.6";
|
pub const VERSION: &str = "1.4.6";
|
||||||
#[allow(dead_code)]
|
#[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