1313using System . Text . Json ;
1414using System . Threading ;
1515using System . Threading . Tasks ;
16+ using Avalonia . Controls . Notifications ;
1617using Core . Services . Config ;
1718using PluginCore ;
1819
@@ -47,6 +48,9 @@ public class DeviceCommunicationService : IDeviceCommunication, IDisposable
4748 private readonly ConcurrentDictionary < Guid , UdpReassemblySession > _udpSessions = new ( ) ;
4849 private readonly ConcurrentDictionary < string , string > _pendingDownloads = new ( ) ; // RequestId -> SavePath
4950 private readonly ConcurrentDictionary < string , bool > _discoveredDeviceQuicCapabilities = new ( ) ; // DeviceId -> SupportsQuic
51+ private readonly ConcurrentDictionary < string , IToastProgressHandle > _sendingTransferToasts = new ( ) ;
52+ private readonly ConcurrentDictionary < string , IToastProgressHandle > _receivingTransferToasts = new ( ) ;
53+ private static readonly TimeSpan TransferToastUpdateInterval = TimeSpan . FromMilliseconds ( 200 ) ;
5054
5155 public ObservableCollection < DeviceModel > DiscoveredDevices { get ; } = new ( ) ;
5256
@@ -106,6 +110,7 @@ private static string GetLocalDisplayName()
106110
107111 private void NotifyTransferInterrupted ( string requestId , string reason , bool isSending )
108112 {
113+ FailTransferToast ( requestId , reason , isSending ) ;
109114 if ( ! string . IsNullOrEmpty ( requestId ) )
110115 {
111116 _ = Avalonia . Threading . Dispatcher . UIThread . InvokeAsync ( ( ) =>
@@ -216,14 +221,55 @@ public async Task RequestFileTransferAsync(DeviceModel target, string filePath)
216221 SenderId = _myId . ToString ( ) ,
217222 SenderName = GetLocalDisplayName ( )
218223 } ;
219- await SendStreamAsync ( target , fs , JsonSerializer . Serialize ( fileMeta ) ) ;
224+ var targetName = GetDeviceDisplayName ( target ) ;
225+ StartTransferToast ( requestId , true , fileInfo . Name , fileInfo . Length , targetName ) ;
226+
227+ long transferredBytes = 0 ;
228+ int lastPercent = - 1 ;
229+ var lastUpdate = DateTime . MinValue ;
230+ var progressLock = new object ( ) ;
231+ Action < long > ? onProgress = fileInfo . Length > 0
232+ ? bytes =>
233+ {
234+ var copied = Interlocked . Add ( ref transferredBytes , bytes ) ;
235+ var percent = ( int ) Math . Min ( 100 , copied * 100d / fileInfo . Length ) ;
236+ var now = DateTime . UtcNow ;
237+
238+ lock ( progressLock )
239+ {
240+ if ( percent == lastPercent && now - lastUpdate < TransferToastUpdateInterval )
241+ {
242+ return ;
243+ }
244+
245+ lastPercent = percent ;
246+ lastUpdate = now ;
247+ }
248+
249+ UpdateTransferToastProgress (
250+ requestId ,
251+ true ,
252+ fileInfo . Name ,
253+ copied ,
254+ fileInfo . Length ,
255+ targetName ) ;
256+ }
257+ : null ;
258+
259+ await SendStreamInternalAsync ( target , fs , JsonSerializer . Serialize ( fileMeta ) , onProgress ) ;
260+ CompleteTransferToast ( requestId , true , fileInfo . Name , fileInfo . Length , targetName ) ;
220261 }
221262 else
222263 {
223264 _pendingFileRequests . TryRemove ( requestId , out _ ) ;
224265 if ( completedTask != tcs . Task ) throw new TimeoutException ( "User did not respond in time." ) ;
225266 }
226267 }
268+ catch ( Exception ex )
269+ {
270+ FailTransferToast ( requestId , ex . Message , true ) ;
271+ throw ;
272+ }
227273 finally
228274 {
229275 _pendingFileRequests . TryRemove ( requestId , out _ ) ;
@@ -251,9 +297,15 @@ public async Task RespondToFileRequestAsync(DeviceModel target, string requestId
251297 }
252298
253299 public async Task SendStreamAsync ( DeviceModel target , Stream stream , string ? metaData = null )
300+ {
301+ await SendStreamInternalAsync ( target , stream , metaData ) ;
302+ }
303+
304+ private async Task SendStreamInternalAsync ( DeviceModel target , Stream stream , string ? metaData = null ,
305+ Action < long > ? onProgress = null )
254306 {
255307 // Prioritize QUIC
256- if ( ShouldTryQuic ( target ) && await TrySendQuicAsync ( target , stream , metaData ) )
308+ if ( ShouldTryQuic ( target ) && await TrySendQuicAsync ( target , stream , metaData , onProgress ) )
257309 return ;
258310
259311 // Check if we should fallback to UDP
@@ -266,7 +318,7 @@ public async Task SendStreamAsync(DeviceModel target, Stream stream, string? met
266318 // Fallback to UDP
267319 try
268320 {
269- await SendUdpAsync ( target , stream , metaData ) ;
321+ await SendUdpAsync ( target , stream , metaData , onProgress ) ;
270322 }
271323 catch ( Exception ex )
272324 {
@@ -301,7 +353,8 @@ private bool ShouldTryQuic(DeviceModel target)
301353 return true ;
302354 }
303355
304- private async Task < bool > TrySendQuicAsync ( DeviceModel target , Stream stream , string ? metaData )
356+ private async Task < bool > TrySendQuicAsync ( DeviceModel target , Stream stream , string ? metaData ,
357+ Action < long > ? onProgress = null )
305358 {
306359 try
307360 {
@@ -332,7 +385,7 @@ private async Task<bool> TrySendQuicAsync(DeviceModel target, Stream stream, str
332385 await WriteMetaDataAsync ( quicStream , metaData , TimeSpan . FromSeconds ( 2 ) ) ;
333386
334387 // Send Data with timeout
335- await CopyStreamWithTimeoutAsync ( stream , quicStream , TimeSpan . FromSeconds ( 10 ) ) ;
388+ await CopyStreamWithTimeoutAsync ( stream , quicStream , TimeSpan . FromSeconds ( 10 ) , onProgress : onProgress ) ;
336389
337390 return true ;
338391 }
@@ -342,7 +395,8 @@ private async Task<bool> TrySendQuicAsync(DeviceModel target, Stream stream, str
342395 }
343396 }
344397
345- private async Task CopyStreamWithTimeoutAsync ( Stream source , Stream destination , TimeSpan timeout , int bufferSize = 8192 )
398+ private async Task CopyStreamWithTimeoutAsync ( Stream source , Stream destination , TimeSpan timeout , int bufferSize = 8192 ,
399+ Action < long > ? onProgress = null )
346400 {
347401 var buffer = new byte [ bufferSize ] ;
348402 int bytesRead ;
@@ -358,6 +412,7 @@ private async Task CopyStreamWithTimeoutAsync(Stream source, Stream destination,
358412
359413 cts . CancelAfter ( timeout ) ;
360414 await destination . WriteAsync ( buffer , 0 , bytesRead , cts . Token ) ;
415+ onProgress ? . Invoke ( bytesRead ) ;
361416 }
362417 catch ( OperationCanceledException )
363418 {
@@ -366,7 +421,7 @@ private async Task CopyStreamWithTimeoutAsync(Stream source, Stream destination,
366421 }
367422 }
368423
369- private async Task SendUdpAsync ( DeviceModel target , Stream stream , string ? metaData )
424+ private async Task SendUdpAsync ( DeviceModel target , Stream stream , string ? metaData , Action < long > ? onProgress = null )
370425 {
371426 // Simple UDP impl with chunking and reassembly support
372427 var targetEndPoint = CreateTargetEndPoint ( target . Address , target . Port + 1 ) ;
@@ -393,6 +448,7 @@ private async Task SendUdpAsync(DeviceModel target, Stream stream, string? metaD
393448 {
394449 await SendUdpPacket ( tempClient , targetEndPoint , sessionId , offset , 1 , buffer . AsSpan ( 0 , read ) . ToArray ( ) , false ) ;
395450 offset += read ;
451+ onProgress ? . Invoke ( read ) ;
396452
397453 // Throttle: Reduce to 1ms delay every 10 packets to improve speed
398454 packetCount ++ ;
@@ -522,17 +578,53 @@ private async Task DispatchPacketAsync(PacketMetadata packet, Stream dataStream,
522578 if ( _pendingDownloads . TryRemove ( packet . RequestId , out var savePath ) )
523579 {
524580 bool success = false ;
581+ var senderName = GetDeviceDisplayName ( sender ) ;
582+ StartTransferToast ( packet . RequestId , false , packet . FileName , packet . Size , senderName ) ;
583+
584+ long transferredBytes = 0 ;
585+ int lastPercent = - 1 ;
586+ var lastUpdate = DateTime . MinValue ;
587+ var progressLock = new object ( ) ;
588+ Action < long > ? onProgress = packet . Size > 0
589+ ? bytes =>
590+ {
591+ var copied = Interlocked . Add ( ref transferredBytes , bytes ) ;
592+ var percent = ( int ) Math . Min ( 100 , copied * 100d / packet . Size ) ;
593+ var now = DateTime . UtcNow ;
594+
595+ lock ( progressLock )
596+ {
597+ if ( percent == lastPercent && now - lastUpdate < TransferToastUpdateInterval )
598+ {
599+ return ;
600+ }
601+
602+ lastPercent = percent ;
603+ lastUpdate = now ;
604+ }
605+
606+ UpdateTransferToastProgress (
607+ packet . RequestId ,
608+ false ,
609+ packet . FileName ,
610+ copied ,
611+ packet . Size ,
612+ senderName ) ;
613+ }
614+ : null ;
615+
525616 try
526617 {
527618 // Stream directly to file
528619 await using var fs = new FileStream ( savePath , FileMode . Create , FileAccess . Write ) ;
529- await CopyStreamWithTimeoutAsync ( dataStream , fs , TimeSpan . FromSeconds ( 10 ) ) ;
620+ await CopyStreamWithTimeoutAsync ( dataStream , fs , TimeSpan . FromSeconds ( 10 ) , onProgress : onProgress ) ;
530621
531622 if ( packet . Size > 0 && fs . Length != packet . Size )
532623 {
533624 throw new IOException ( $ "File size mismatch. Expected { packet . Size } , got { fs . Length } ") ;
534625 }
535626 success = true ;
627+ CompleteTransferToast ( packet . RequestId , false , packet . FileName , packet . Size , senderName ) ;
536628 }
537629 catch ( Exception ex )
538630 {
@@ -1192,6 +1284,153 @@ private X509Certificate2 GenerateCertificate()
11921284 // Export/Import as PFX to ensure the private key is properly associated and accessible for SChannel/MsQuic on Windows
11931285 return new X509Certificate2 ( cert . Export ( X509ContentType . Pfx ) ) ;
11941286 }
1287+
1288+ private static string GetDeviceDisplayName ( DeviceModel ? device )
1289+ {
1290+ if ( device is null )
1291+ {
1292+ return "未知设备" ;
1293+ }
1294+
1295+ if ( ! string . IsNullOrWhiteSpace ( device . CustomName ) )
1296+ {
1297+ return device . CustomName . Trim ( ) ;
1298+ }
1299+
1300+ if ( ! string . IsNullOrWhiteSpace ( device . Name ) )
1301+ {
1302+ return device . Name . Trim ( ) ;
1303+ }
1304+
1305+ return device . Address . ToString ( ) ;
1306+ }
1307+
1308+ private static string FormatFileSize ( long bytes )
1309+ {
1310+ if ( bytes <= 0 )
1311+ {
1312+ return "0 B" ;
1313+ }
1314+
1315+ string [ ] units = [ "B" , "KB" , "MB" , "GB" , "TB" , "PB" ] ;
1316+ var unitIndex = 0 ;
1317+ double value = bytes ;
1318+ while ( value >= 1024 && unitIndex < units . Length - 1 )
1319+ {
1320+ value /= 1024 ;
1321+ unitIndex ++ ;
1322+ }
1323+
1324+ if ( unitIndex == 0 )
1325+ {
1326+ return $ "{ bytes : N0} B";
1327+ }
1328+
1329+ return $ "{ value : 0.##} { units [ unitIndex ] } ";
1330+ }
1331+
1332+ private static IToastService ? GetToastService ( )
1333+ {
1334+ try
1335+ {
1336+ return ServiceManager . Services ? . GetService ( typeof ( IToastService ) ) as IToastService ;
1337+ }
1338+ catch
1339+ {
1340+ return null ;
1341+ }
1342+ }
1343+
1344+ private void StartTransferToast ( string requestId , bool isSending , string fileName , long totalBytes , string remoteName )
1345+ {
1346+ if ( string . IsNullOrWhiteSpace ( requestId ) )
1347+ {
1348+ return ;
1349+ }
1350+
1351+ var toastService = GetToastService ( ) ;
1352+ if ( toastService is null )
1353+ {
1354+ return ;
1355+ }
1356+
1357+ var action = isSending ? "发送" : "接收" ;
1358+ var direction = isSending ? "到" : "从" ;
1359+ var detail = totalBytes > 0
1360+ ? $ "{ FormatFileSize ( 0 ) } / { FormatFileSize ( totalBytes ) } "
1361+ : "准备中..." ;
1362+ var handle = toastService . ShowProgress (
1363+ isSending ? "文件发送" : "文件接收" ,
1364+ $ "{ action } { fileName } { direction } { remoteName } ({ detail } )",
1365+ NotificationType . Information ,
1366+ initialProgress : 0 ,
1367+ isIndeterminate : totalBytes <= 0 ) ;
1368+
1369+ var map = isSending ? _sendingTransferToasts : _receivingTransferToasts ;
1370+ map [ requestId ] = handle ;
1371+ }
1372+
1373+ private void UpdateTransferToastProgress ( string requestId , bool isSending , string fileName , long transferredBytes ,
1374+ long totalBytes , string remoteName )
1375+ {
1376+ if ( string . IsNullOrWhiteSpace ( requestId ) )
1377+ {
1378+ return ;
1379+ }
1380+
1381+ var map = isSending ? _sendingTransferToasts : _receivingTransferToasts ;
1382+ if ( ! map . TryGetValue ( requestId , out var handle ) )
1383+ {
1384+ return ;
1385+ }
1386+
1387+ var action = isSending ? "发送" : "接收" ;
1388+ var direction = isSending ? "到" : "从" ;
1389+ if ( totalBytes > 0 )
1390+ {
1391+ var progress = Math . Min ( 100 , transferredBytes * 100d / totalBytes ) ;
1392+ handle . Update (
1393+ progress : progress ,
1394+ text :
1395+ $ "{ action } { fileName } { direction } { remoteName } ({ FormatFileSize ( transferredBytes ) } / { FormatFileSize ( totalBytes ) } )") ;
1396+ return ;
1397+ }
1398+
1399+ handle . Update (
1400+ text : $ "{ action } { fileName } { direction } { remoteName } ({ FormatFileSize ( transferredBytes ) } )",
1401+ isIndeterminate : true ) ;
1402+ }
1403+
1404+ private void CompleteTransferToast ( string requestId , bool isSending , string fileName , long totalBytes ,
1405+ string remoteName )
1406+ {
1407+ var map = isSending ? _sendingTransferToasts : _receivingTransferToasts ;
1408+ if ( ! map . TryRemove ( requestId , out var handle ) )
1409+ {
1410+ return ;
1411+ }
1412+
1413+ var action = isSending ? "已发送" : "已接收" ;
1414+ var direction = isSending ? "到" : "从" ;
1415+ var sizeText = totalBytes > 0 ? $ " ({ FormatFileSize ( totalBytes ) } )" : string . Empty ;
1416+ handle . Complete ( $ "{ action } { fileName } { sizeText } { direction } { remoteName } ") ;
1417+ }
1418+
1419+ private void FailTransferToast ( string requestId , string reason , bool isSending )
1420+ {
1421+ if ( string . IsNullOrWhiteSpace ( requestId ) )
1422+ {
1423+ return ;
1424+ }
1425+
1426+ var map = isSending ? _sendingTransferToasts : _receivingTransferToasts ;
1427+ if ( ! map . TryRemove ( requestId , out var handle ) )
1428+ {
1429+ return ;
1430+ }
1431+
1432+ handle . Fail ( $ "传输中断:{ reason } ") ;
1433+ }
11951434
11961435 public void Dispose ( )
11971436 {
@@ -1229,3 +1468,4 @@ private class PacketMetadata
12291468 public string SenderName { get ; set ; } = "" ;
12301469 }
12311470}
1471+
0 commit comments