diff --git a/flutter/assets/file_transfer.svg b/flutter/assets/file_transfer.svg new file mode 100644 index 000000000..e1d8ccbec --- /dev/null +++ b/flutter/assets/file_transfer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index ce7b26b58..2ef04b244 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/consts.dart'; @@ -9,12 +10,14 @@ import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/utils/platform_channel.dart'; import 'package:get/get.dart'; +import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../common.dart'; import '../../common/widgets/chat_page.dart'; +import '../../models/file_model.dart'; import '../../models/platform_model.dart'; import '../../models/server_model.dart'; @@ -32,6 +35,7 @@ class _DesktopServerPageState extends State void initState() { gFFI.ffiModel.updateEventListener(gFFI.sessionId, ""); windowManager.addListener(this); + Get.put(tabController); tabController.onRemoved = (_, id) { onRemoveId(id); }; @@ -111,6 +115,7 @@ class ConnectionManagerState extends State { }); } windowManager.setTitle(getWindowNameWithId(client.peerId)); + gFFI.cmFileModel.updateCurrentClientId(client.id); } } }; @@ -173,7 +178,7 @@ class ConnectionManagerState extends State { pageViewBuilder: (pageView) => Row( children: [ Consumer( - builder: (_, model, child) => model.isShowCMChatPage + builder: (_, model, child) => model.isShowCMSidePage ? Expanded( child: buildRemoteBlock( child: Container( @@ -182,8 +187,7 @@ class ConnectionManagerState extends State { right: BorderSide( color: Theme.of(context) .dividerColor))), - child: - ChatPage(type: ChatPageType.desktopCM)), + child: buildSidePage()), ), flex: (kConnectionManagerWindowSizeOpenChat.width - kConnectionManagerWindowSizeClosedChat @@ -204,6 +208,19 @@ class ConnectionManagerState extends State { ); } + Widget buildSidePage() { + final selected = gFFI.serverModel.tabController.state.value.selected; + if (selected < 0 || selected >= gFFI.serverModel.clients.length) { + return Offstage(); + } + final clientType = gFFI.serverModel.clients[selected].type_(); + if (clientType == ClientType.file) { + return _FileTransferLogPage(); + } else { + return ChatPage(type: ChatPageType.desktopCM); + } + } + Widget buildTitleBar() { return SizedBox( height: kDesktopRemoteTabBarHeight, @@ -447,14 +464,21 @@ class _CmHeaderState extends State<_CmHeader> ), ), Offstage( - offstage: !client.authorized || client.type_() != ClientType.remote, + offstage: !client.authorized || + (client.type_() != ClientType.remote && + client.type_() != ClientType.file), child: IconButton( - onPressed: () => checkClickTime( - client.id, - () => gFFI.chatModel - .toggleCMChatPage(MessageKey(client.peerId, client.id)), - ), - icon: SvgPicture.asset('assets/chat2.svg'), + onPressed: () => checkClickTime(client.id, () { + if (client.type_() != ClientType.file) { + gFFI.chatModel.toggleCMSidePage(); + } else { + gFFI.chatModel + .toggleCMChatPage(MessageKey(client.peerId, client.id)); + } + }), + icon: SvgPicture.asset(client.type_() == ClientType.file + ? 'assets/file_transfer.svg' + : 'assets/chat2.svg'), splashRadius: kDesktopIconButtonSplashRadius, ), ) @@ -912,3 +936,182 @@ void checkClickTime(int id, Function() callback) async { if (d > 120) callback(); }); } + +class _FileTransferLogPage extends StatefulWidget { + _FileTransferLogPage({Key? key}) : super(key: key); + + @override + State<_FileTransferLogPage> createState() => __FileTransferLogPageState(); +} + +class __FileTransferLogPageState extends State<_FileTransferLogPage> { + @override + Widget build(BuildContext context) { + return statusList(); + } + + Widget generateCard(Widget child) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.all( + Radius.circular(15.0), + ), + ), + child: child, + ); + } + + Widget statusList() { + return PreferredSize( + preferredSize: const Size(200, double.infinity), + child: Container( + margin: const EdgeInsets.only(top: 16.0, bottom: 16.0, right: 16.0), + padding: const EdgeInsets.all(8.0), + child: Obx( + () { + final jobTable = gFFI.cmFileModel.currentJobTable; + statusListView(List jobs) => ListView.builder( + controller: ScrollController(), + itemBuilder: (BuildContext context, int index) { + final item = jobs[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: generateCard( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 50, + child: Column( + children: [ + Transform.rotate( + angle: item.isRemoteToLocal ? 0 : pi, + child: SvgPicture.asset( + "assets/arrow.svg", + color: Theme.of(context) + .tabBarTheme + .labelColor, + ), + ), + Text(item.isRemoteToLocal + ? translate('Send') + : translate('Receive')) + ], + ), + ).paddingOnly(left: 15), + const SizedBox( + width: 16.0, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + item.fileName, + ).paddingSymmetric(vertical: 10), + if (item.totalSize > 0) + Text( + '${translate("Total")} ${readableFileSize(item.totalSize.toDouble())}', + style: TextStyle( + fontSize: 12, + color: MyTheme.darkGray, + ), + ), + if (item.totalSize > 0) + Offstage( + offstage: item.state != + JobState.inProgress, + child: Text( + '${translate("Speed")} ${readableFileSize(item.speed)}/s', + style: TextStyle( + fontSize: 12, + color: MyTheme.darkGray, + ), + ), + ), + Offstage( + offstage: + item.state == JobState.inProgress, + child: Text( + translate( + item.display(), + ), + style: TextStyle( + fontSize: 12, + color: MyTheme.darkGray, + ), + ), + ), + if (item.totalSize > 0) + Offstage( + offstage: item.state != + JobState.inProgress, + child: LinearPercentIndicator( + padding: + EdgeInsets.only(right: 15), + animateFromLastPercent: true, + center: Text( + '${(item.finishedSize / item.totalSize * 100).toStringAsFixed(0)}%', + ), + barRadius: Radius.circular(15), + percent: item.finishedSize / + item.totalSize, + progressColor: MyTheme.accent, + backgroundColor: + Theme.of(context).hoverColor, + lineHeight: + kDesktopFileTransferRowHeight, + ).paddingSymmetric(vertical: 15), + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [], + ), + ], + ), + ], + ).paddingSymmetric(vertical: 10), + ), + ); + }, + itemCount: jobTable.length, + ); + + return jobTable.isEmpty + ? generateCard( + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + "assets/transfer.svg", + color: Theme.of(context).tabBarTheme.labelColor, + height: 40, + ).paddingOnly(bottom: 10), + Text( + translate("No transfers in progress"), + textAlign: TextAlign.center, + textScaleFactor: 1.20, + style: TextStyle( + color: + Theme.of(context).tabBarTheme.labelColor), + ), + ], + ), + ), + ) + : statusListView(jobTable); + }, + )), + ); + } +} diff --git a/flutter/lib/models/chat_model.dart b/flutter/lib/models/chat_model.dart index 898427351..487f57e4d 100644 --- a/flutter/lib/models/chat_model.dart +++ b/flutter/lib/models/chat_model.dart @@ -86,13 +86,13 @@ class ChatModel with ChangeNotifier { late final Map _messages = {}; MessageKey _currentKey = MessageKey('', -2); // -2 is invalid value - late bool _isShowCMChatPage = false; + late bool _isShowCMSidePage = false; Map get messages => _messages; MessageKey get currentKey => _currentKey; - bool get isShowCMChatPage => _isShowCMChatPage; + bool get isShowCMSidePage => _isShowCMSidePage; void setOverlayState(BlockableOverlayState blockableOverlayState) { _blockableOverlayState = blockableOverlayState; @@ -255,7 +255,7 @@ class ChatModel with ChangeNotifier { showChatPage(MessageKey key) async { if (isDesktop) { if (isConnManager) { - if (!_isShowCMChatPage) { + if (!_isShowCMSidePage) { await toggleCMChatPage(key); } } else { @@ -272,12 +272,26 @@ class ChatModel with ChangeNotifier { } } + showSidePage() async { + if (isDesktop) { + if (isConnManager) { + if (!_isShowCMSidePage) { + await toggleCMSidePage(); + } + } + } + } + toggleCMChatPage(MessageKey key) async { if (gFFI.chatModel.currentKey != key) { gFFI.chatModel.changeCurrentKey(key); } - if (_isShowCMChatPage) { - _isShowCMChatPage = !_isShowCMChatPage; + await toggleCMSidePage(); + } + + toggleCMSidePage() async { + if (_isShowCMSidePage) { + _isShowCMSidePage = !_isShowCMSidePage; notifyListeners(); await windowManager.show(); await windowManager.setSizeAlignment( @@ -287,7 +301,7 @@ class ChatModel with ChangeNotifier { await windowManager.show(); await windowManager.setSizeAlignment( kConnectionManagerWindowSizeOpenChat, Alignment.topRight); - _isShowCMChatPage = !_isShowCMChatPage; + _isShowCMSidePage = !_isShowCMSidePage; notifyListeners(); } } diff --git a/flutter/lib/models/cm_file_model.dart b/flutter/lib/models/cm_file_model.dart new file mode 100644 index 000000000..064ce9d53 --- /dev/null +++ b/flutter/lib/models/cm_file_model.dart @@ -0,0 +1,154 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/server_model.dart'; +import 'package:get/get.dart'; +import 'file_model.dart'; + +class CmFileModel { + final WeakReference parent; + final currentJobTable = RxList(); + final _jobTables = HashMap>.fromEntries([]); + Stopwatch stopwatch = Stopwatch(); + int _lastElapsed = 0; + bool _jobAdded = false; + bool _showing = false; + + CmFileModel(this.parent); + + void updateCurrentClientId(int id) { + if (_jobTables[id] == null) { + _jobTables[id] = RxList(); + } + Future.delayed(Duration.zero, () { + currentJobTable.value = _jobTables[id]!; + }); + } + + onFileTransferLog(dynamic log) { + try { + dynamic d = jsonDecode(log); + if (!stopwatch.isRunning) stopwatch.start(); + bool calcSpeed = stopwatch.elapsedMilliseconds - _lastElapsed >= 1000; + if (calcSpeed) { + _lastElapsed = stopwatch.elapsedMilliseconds; + } + if (d is List) { + for (var l in d) { + _dealOneJob(l, calcSpeed); + } + } else { + _dealOneJob(d, calcSpeed); + } + currentJobTable.refresh(); + Future.delayed(Duration.zero, () async { + if (_jobAdded) { + _jobAdded = false; + if (!_showing) { + _showing = true; + await gFFI.chatModel.showSidePage(); + _showing = false; + } + } + }); + } catch (e) { + debugPrint("onFileTransferLog:$e"); + } + } + + _dealOneJob(dynamic l, bool calcSpeed) { + final data = TransferJobSerdeData.fromJson(l); + Client? client = + gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == data.connId); + var jobTable = _jobTables[data.connId]; + if (jobTable == null) { + debugPrint("jobTable should not be null"); + return; + } + JobProgress? job = jobTable.firstWhereOrNull((e) => e.id == data.id); + if (job == null) { + job = JobProgress(); + jobTable.add(job); + _jobAdded = true; + final currentSelectedTab = + gFFI.serverModel.tabController.state.value.selectedTabInfo; + if (currentSelectedTab.key != data.connId.toString()) { + client?.unreadChatMessageCount.value += 1; + } + } + job.id = data.id; + job.isRemoteToLocal = data.isRemote; + job.fileName = data.path; + job.totalSize = data.totalSize; + job.finishedSize = data.finishedSize; + if (job.finishedSize > data.totalSize) { + job.finishedSize = data.totalSize; + } + job.isRemoteToLocal = data.isRemote; + + if (job.finishedSize > 0) { + if (job.finishedSize < job.totalSize) { + job.state = JobState.inProgress; + } else { + job.state = JobState.done; + } + } + if (data.done) { + job.state = JobState.done; + } else if (data.cancel || data.error == 'skipped') { + job.state = JobState.done; + job.err = 'skipped'; + } else if (data.error.isNotEmpty) { + job.state = JobState.error; + job.err = data.error; + } + if (calcSpeed) { + job.speed = (data.transferred - job.lastTransferredSize) * 1.0; + job.lastTransferredSize = data.transferred; + } + jobTable.refresh(); + } +} + +class TransferJobSerdeData { + int connId; + int id; + String path; + bool isRemote; + int totalSize; + int finishedSize; + int transferred; + bool done; + bool cancel; + String error; + + TransferJobSerdeData({ + required this.connId, + required this.id, + required this.path, + required this.isRemote, + required this.totalSize, + required this.finishedSize, + required this.transferred, + required this.done, + required this.cancel, + required this.error, + }); + + TransferJobSerdeData.fromJson(dynamic d) + : this( + connId: d['connId'] ?? 0, + id: int.tryParse(d['id'].toString()) ?? 0, + path: d['path'] ?? '', + isRemote: d['isRemote'] ?? false, + totalSize: d['totalSize'] ?? 0, + finishedSize: d['finishedSize'] ?? 0, + transferred: d['transferred'] ?? 0, + done: d['done'] ?? false, + cancel: d['cancel'] ?? false, + error: d['error'] ?? '', + ); +} diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index 35c0d020d..108c76f1e 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -1029,6 +1029,7 @@ class JobProgress { var to = ""; var showHidden = false; var err = ""; + int lastTransferredSize = 0; clear() { state = JobState.none; diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 14b6b4df6..a07a0d5e7 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -11,6 +11,7 @@ import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/generated_bridge.dart'; import 'package:flutter_hbb/models/ab_model.dart'; import 'package:flutter_hbb/models/chat_model.dart'; +import 'package:flutter_hbb/models/cm_file_model.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:flutter_hbb/models/group_model.dart'; import 'package:flutter_hbb/models/peer_tab_model.dart'; @@ -317,6 +318,10 @@ class FfiModel with ChangeNotifier { } } } + } else if (name == "cm_file_transfer_log") { + if (isDesktop) { + gFFI.cmFileModel.onFileTransferLog(evt['log']); + } } else { debugPrint('Unknown event name: $name'); } @@ -1696,6 +1701,7 @@ class FFI { late final RecordingModel recordingModel; // session late final InputModel inputModel; // session late final ElevationModel elevationModel; // session + late final CmFileModel cmFileModel; // cm FFI(SessionID? sId) { sessionId = sId ?? (isDesktop ? Uuid().v4obj() : _constSessionId); @@ -1714,6 +1720,7 @@ class FFI { recordingModel = RecordingModel(WeakReference(this)); inputModel = InputModel(WeakReference(this)); elevationModel = ElevationModel(WeakReference(this)); + cmFileModel = CmFileModel(WeakReference(this)); } /// Mobile reuse FFI diff --git a/libs/hbb_common/protos/message.proto b/libs/hbb_common/protos/message.proto index 82206cbf2..47de31e45 100644 --- a/libs/hbb_common/protos/message.proto +++ b/libs/hbb_common/protos/message.proto @@ -397,6 +397,7 @@ message FileTransferReceiveRequest { string path = 2; // path written to repeated FileEntry files = 3; int32 file_num = 4; + uint64 total_size = 5; } message FileRemoveDir { diff --git a/libs/hbb_common/src/fs.rs b/libs/hbb_common/src/fs.rs index 33902e575..b7ea836b7 100644 --- a/libs/hbb_common/src/fs.rs +++ b/libs/hbb_common/src/fs.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde_derive::{Deserialize, Serialize}; +use serde_json::json; use tokio::{fs::File, io::*}; use crate::{anyhow::anyhow, bail, get_version_number, message_proto::*, ResultType, Stream}; @@ -194,7 +195,8 @@ pub fn can_enable_overwrite_detection(version: i64) -> bool { version >= get_version_number("1.1.10") } -#[derive(Default)] +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] pub struct TransferJob { pub id: i32, pub remote: String, @@ -203,10 +205,13 @@ pub struct TransferJob { pub is_remote: bool, pub is_last_job: bool, pub file_num: i32, + #[serde(skip_serializing)] pub files: Vec, + pub conn_id: i32, // server only + #[serde(skip_serializing)] file: Option, - total_size: u64, + pub total_size: u64, finished_size: u64, transferred: u64, enable_overwrite_detection: bool, @@ -695,13 +700,20 @@ pub fn new_send_confirm(r: FileTransferSendConfirmRequest) -> Message { } #[inline] -pub fn new_receive(id: i32, path: String, file_num: i32, files: Vec) -> Message { +pub fn new_receive( + id: i32, + path: String, + file_num: i32, + files: Vec, + total_size: u64, +) -> Message { let mut action = FileAction::new(); action.set_receive(FileTransferReceiveRequest { id, path, files, file_num, + total_size, ..Default::default() }); let mut msg_out = Message::new(); @@ -748,10 +760,16 @@ pub fn get_job(id: i32, jobs: &mut [TransferJob]) -> Option<&mut TransferJob> { jobs.iter_mut().find(|x| x.id() == id) } +#[inline] +pub fn get_job_immutable(id: i32, jobs: &[TransferJob]) -> Option<&TransferJob> { + jobs.iter().find(|x| x.id() == id) +} + pub async fn handle_read_jobs( jobs: &mut Vec, stream: &mut crate::Stream, -) -> ResultType<()> { +) -> ResultType { + let mut job_log = Default::default(); let mut finished = Vec::new(); for job in jobs.iter_mut() { if job.is_last_job { @@ -768,9 +786,11 @@ pub async fn handle_read_jobs( } Ok(None) => { if job.job_completed() { + job_log = serialize_transfer_job(job, true, false, ""); finished.push(job.id()); match job.job_error() { Some(err) => { + job_log = serialize_transfer_job(job, false, false, &err); stream .send(&new_error(job.id(), err, job.file_num())) .await? @@ -786,7 +806,7 @@ pub async fn handle_read_jobs( for id in finished { remove_job(id, jobs); } - Ok(()) + Ok(job_log) } pub fn remove_all_empty_dir(path: &PathBuf) -> ResultType<()> { @@ -861,3 +881,20 @@ pub fn is_write_need_confirmation( Ok(DigestCheckResult::NoSuchFile) } } + +pub fn serialize_transfer_jobs(jobs: &[TransferJob]) -> String { + let mut v = vec![]; + for job in jobs { + let value = serde_json::to_value(job).unwrap_or_default(); + v.push(value); + } + serde_json::to_string(&v).unwrap_or_default() +} + +pub fn serialize_transfer_job(job: &TransferJob, done: bool, cancel: bool, error: &str) -> String { + let mut value = serde_json::to_value(job).unwrap_or_default(); + value["done"] = json!(done); + value["cancel"] = json!(cancel); + value["error"] = json!(error); + serde_json::to_string(&value).unwrap_or_default() +} diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index aaf426e28..ddf4b64ac 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -483,9 +483,13 @@ impl Remote { // peer is not windows, need transform \ to / fs::transform_windows_path(&mut files); } + let total_size = job.total_size(); self.read_jobs.push(job); self.timer = time::interval(MILLI1); - allow_err!(peer.send(&fs::new_receive(id, to, file_num, files)).await); + allow_err!( + peer.send(&fs::new_receive(id, to, file_num, files, total_size)) + .await + ); } } } @@ -568,7 +572,8 @@ impl Remote { id, job.path.to_string_lossy().to_string(), job.file_num, - job.files.clone() + job.files.clone(), + job.total_size(), )) .await ); diff --git a/src/flutter.rs b/src/flutter.rs index af9580587..817503c68 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -907,6 +907,10 @@ pub mod connection_manager { let client_json = serde_json::to_string(&client).unwrap_or("".into()); self.push_event("update_voice_call_state", vec![("client", &client_json)]); } + + fn file_transfer_log(&self, log: String) { + self.push_event("cm_file_transfer_log", vec![("log", &log.to_string())]); + } } impl FlutterHandler { diff --git a/src/ipc.rs b/src/ipc.rs index 1d0c99ea3..1cbd994bb 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -70,6 +70,8 @@ pub enum FS { file_num: i32, files: Vec<(String, u64)>, overwrite_detection: bool, + total_size: u64, + conn_id: i32, }, CancelWrite { id: i32, @@ -231,6 +233,7 @@ pub enum Data { Plugin(Plugin), #[cfg(windows)] SyncWinCpuUsage(Option), + FileTransferLog(String), } #[tokio::main(flavor = "current_thread")] diff --git a/src/server/connection.rs b/src/server/connection.rs index a0f05c43a..c9f07eb78 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -188,6 +188,7 @@ pub struct Connection { lr: LoginRequest, last_recv_time: Arc>, chat_unanswered: bool, + file_transferred: bool, #[cfg(windows)] portable: PortableState, from_switch: bool, @@ -320,6 +321,7 @@ impl Connection { lr: Default::default(), last_recv_time: Arc::new(Mutex::new(Instant::now())), chat_unanswered: false, + file_transferred: false, #[cfg(windows)] portable: Default::default(), from_switch: false, @@ -399,6 +401,7 @@ impl Connection { } ipc::Data::Close => { conn.chat_unanswered = false; // seen + conn.file_transferred = false; //seen conn.send_close_reason_no_retry("").await; conn.on_close("connection manager", true).await; break; @@ -536,9 +539,17 @@ impl Connection { }, _ = conn.file_timer.tick() => { if !conn.read_jobs.is_empty() { - if let Err(err) = fs::handle_read_jobs(&mut conn.read_jobs, &mut conn.stream).await { - conn.on_close(&err.to_string(), false).await; - break; + conn.send_to_cm(ipc::Data::FileTransferLog(fs::serialize_transfer_jobs(&conn.read_jobs))); + match fs::handle_read_jobs(&mut conn.read_jobs, &mut conn.stream).await { + Ok(log) => { + if !log.is_empty() { + conn.send_to_cm(ipc::Data::FileTransferLog(log)); + } + } + Err(err) => { + conn.on_close(&err.to_string(), false).await; + break; + } } } else { conn.file_timer = time::interval_at(Instant::now() + SEC30, SEC30); @@ -1717,6 +1728,7 @@ impl Connection { } } Some(file_action::Union::Send(s)) => { + // server to client let id = s.id; let od = can_enable_overwrite_detection(get_version_number( &self.lr.version, @@ -1734,10 +1746,12 @@ impl Connection { Err(err) => { self.send(fs::new_error(id, err, 0)).await; } - Ok(job) => { + Ok(mut job) => { self.send(fs::new_dir(id, path, job.files().to_vec())) .await; let mut files = job.files().to_owned(); + job.is_remote = true; + job.conn_id = self.inner.id(); self.read_jobs.push(job); self.file_timer = time::interval(MILLI1); self.post_file_audit( @@ -1751,8 +1765,10 @@ impl Connection { ); } } + self.file_transferred = true; } Some(file_action::Union::Receive(r)) => { + // client to server // note: 1.1.10 introduced identical file detection, which breaks original logic of send/recv files // whenever got send/recv request, check peer version to ensure old version of rustdesk let od = can_enable_overwrite_detection(get_version_number( @@ -1769,6 +1785,8 @@ impl Connection { .map(|f| (f.name, f.modified_time)) .collect(), overwrite_detection: od, + total_size: r.total_size, + conn_id: self.inner.id(), }); self.post_file_audit( FileAuditType::RemoteReceive, @@ -1780,6 +1798,7 @@ impl Connection { .collect(), json!({}), ); + self.file_transferred = true; } Some(file_action::Union::RemoveDir(d)) => { self.send_fs(ipc::FS::RemoveDir { @@ -1803,6 +1822,11 @@ impl Connection { } Some(file_action::Union::Cancel(c)) => { self.send_fs(ipc::FS::CancelWrite { id: c.id }); + if let Some(job) = fs::get_job_immutable(c.id, &self.read_jobs) { + self.send_to_cm(ipc::Data::FileTransferLog( + fs::serialize_transfer_job(job, false, true, ""), + )); + } fs::remove_job(c.id, &mut self.read_jobs); } Some(file_action::Union::SendConfirm(r)) => { @@ -2254,7 +2278,7 @@ impl Connection { lock_screen().await; } #[cfg(not(any(target_os = "android", target_os = "ios")))] - let data = if self.chat_unanswered { + let data = if self.chat_unanswered || self.file_transferred { ipc::Data::Disconnected } else { ipc::Data::Close diff --git a/src/ui/cm.rs b/src/ui/cm.rs index a574b5e88..b827b76b1 100644 --- a/src/ui/cm.rs +++ b/src/ui/cm.rs @@ -59,13 +59,11 @@ impl InvokeUiCM for SciterHandler { fn update_voice_call_state(&self, client: &crate::ui_cm_interface::Client) { self.call( "updateVoiceCallState", - &make_args!( - client.id, - client.in_voice_call, - client.incoming_voice_call - ), + &make_args!(client.id, client.in_voice_call, client.incoming_voice_call), ); } + + fn file_transfer_log(&self, _log: String) {} } impl SciterHandler { diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 16fa59631..ceb185443 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -99,6 +99,8 @@ pub trait InvokeUiCM: Send + Clone + 'static + Sized { fn show_elevation(&self, show: bool); fn update_voice_call_state(&self, client: &Client); + + fn file_transfer_log(&self, log: String); } impl Deref for ConnectionManager { @@ -357,6 +359,7 @@ impl IpcTaskRunner { ); } } + let (tx_log, mut rx_log) = mpsc::unbounded_channel::(); self.running = false; loop { @@ -403,11 +406,16 @@ impl IpcTaskRunner { if let ipc::FS::WriteBlock { id, file_num, data: _, compressed } = fs { if let Ok(bytes) = self.stream.next_raw().await { fs = ipc::FS::WriteBlock{id, file_num, data:bytes.into(), compressed}; - handle_fs(fs, &mut write_jobs, &self.tx).await; + handle_fs(fs, &mut write_jobs, &self.tx, Some(&tx_log)).await; } } else { - handle_fs(fs, &mut write_jobs, &self.tx).await; + handle_fs(fs, &mut write_jobs, &self.tx, Some(&tx_log)).await; } + let log = fs::serialize_transfer_jobs(&write_jobs); + self.cm.ui_handler.file_transfer_log(log); + } + Data::FileTransferLog(log) => { + self.cm.ui_handler.file_transfer_log(log); } #[cfg(not(any(target_os = "android", target_os = "ios")))] Data::ClipboardFile(_clip) => { @@ -509,6 +517,9 @@ impl IpcTaskRunner { // } }, + Some(job_log) = rx_log.recv() => { + self.cm.ui_handler.file_transfer_log(job_log); + } } } } @@ -632,7 +643,7 @@ pub async fn start_listen( cm.new_message(current_id, text); } Some(Data::FS(fs)) => { - handle_fs(fs, &mut write_jobs, &tx).await; + handle_fs(fs, &mut write_jobs, &tx, None).await; } Some(Data::Close) => { break; @@ -647,7 +658,14 @@ pub async fn start_listen( } #[cfg(not(any(target_os = "ios")))] -async fn handle_fs(fs: ipc::FS, write_jobs: &mut Vec, tx: &UnboundedSender) { +async fn handle_fs( + fs: ipc::FS, + write_jobs: &mut Vec, + tx: &UnboundedSender, + tx_log: Option<&UnboundedSender>, +) { + use hbb_common::fs::serialize_transfer_job; + match fs { ipc::FS::ReadDir { dir, @@ -674,10 +692,12 @@ async fn handle_fs(fs: ipc::FS, write_jobs: &mut Vec, tx: &Unbo file_num, mut files, overwrite_detection, + total_size, + conn_id, } => { // cm has no show_hidden context // dummy remote, show_hidden, is_remote - write_jobs.push(fs::TransferJob::new_write( + let mut job = fs::TransferJob::new_write( id, "".to_string(), path, @@ -693,11 +713,17 @@ async fn handle_fs(fs: ipc::FS, write_jobs: &mut Vec, tx: &Unbo }) .collect(), overwrite_detection, - )); + ); + job.total_size = total_size; + job.conn_id = conn_id; + write_jobs.push(job); } ipc::FS::CancelWrite { id } => { if let Some(job) = fs::get_job(id, write_jobs) { job.remove_download_file(); + tx_log.map(|tx: &UnboundedSender| { + tx.send(serialize_transfer_job(job, false, true, "")) + }); fs::remove_job(id, write_jobs); } } @@ -705,11 +731,13 @@ async fn handle_fs(fs: ipc::FS, write_jobs: &mut Vec, tx: &Unbo if let Some(job) = fs::get_job(id, write_jobs) { job.modify_time(); send_raw(fs::new_done(id, file_num), tx); + tx_log.map(|tx| tx.send(serialize_transfer_job(job, true, false, ""))); fs::remove_job(id, write_jobs); } } ipc::FS::WriteError { id, file_num, err } => { if let Some(job) = fs::get_job(id, write_jobs) { + tx_log.map(|tx| tx.send(serialize_transfer_job(job, false, false, &err))); send_raw(fs::new_error(job.id(), err, file_num), tx); fs::remove_job(job.id(), write_jobs); }