From 9ace276bbc4f52deaa43925a52756b00ff9fa2a0 Mon Sep 17 00:00:00 2001 From: GT610 Date: Mon, 4 May 2026 11:47:57 +0800 Subject: [PATCH 1/2] fix: restore built-in aria2 session handling and default downloads --- lib/models/settings.dart | 51 +++++++++++++----- lib/services/aria2_rpc_client.dart | 38 ++++++++++---- lib/services/builtin_instance_service.dart | 61 +++++++++++++++++----- lib/services/download_data_service.dart | 45 ++++++++++++++-- lib/utils/default_download_directory.dart | 27 ++++++++++ 5 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 lib/utils/default_download_directory.dart diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 70fcf79..6065ca9 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,9 +1,13 @@ +import 'dart:convert' show jsonDecode, jsonEncode; import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import '../utils/logging.dart'; +import 'package:path/path.dart' as p; + import '../utils/app_data_dir.dart'; -import 'dart:convert' show jsonDecode, jsonEncode; +import '../utils/default_download_directory.dart'; +import '../utils/logging.dart'; enum AppRunMode { standard, tray, hideTray } @@ -90,7 +94,11 @@ class Settings extends ChangeNotifier with Loggable { return getAppDataDirectory(); } - void _assignDefaultSettings() { + Future _defaultDownloadDirectory() async { + return getDefaultDownloadDirectory(); + } + + void _assignDefaultSettings({required String defaultDownloadDir}) { _autoStart = false; _minimizeToTray = true; _runMode = AppRunMode.tray; @@ -119,7 +127,7 @@ class Settings extends ChangeNotifier with Loggable { _maxConnectionPerServer = 16; _split = 16; _continueDownloads = true; - _downloadDir = ''; + _downloadDir = defaultDownloadDir; // Speed settings _maxOverallDownloadLimit = 0; @@ -158,11 +166,11 @@ class Settings extends ChangeNotifier with Loggable { /// Get settings file path String _getSettingsFilePath() { final dataDir = _getDataDirectory(); - final configDir = Directory('${dataDir.path}/config'); + final configDir = Directory(p.join(dataDir.path, 'config')); if (!configDir.existsSync()) { configDir.createSync(recursive: true); } - return '${configDir.path}/$_settingsFileName'; + return p.join(configDir.path, _settingsFileName); } String _normalizeBtTracker(String trackers) { @@ -244,6 +252,7 @@ class Settings extends ChangeNotifier with Loggable { if (file.existsSync()) { final jsonString = await file.readAsString(); final settingsMap = jsonDecode(jsonString); + var needsSave = false; // Global settings _autoStart = settingsMap['autoStart'] ?? false; @@ -306,7 +315,14 @@ class Settings extends ChangeNotifier with Loggable { _maxConnectionPerServer = settingsMap['maxConnectionPerServer'] ?? 16; _split = settingsMap['split'] ?? 16; _continueDownloads = settingsMap['continueDownloads'] ?? true; - _downloadDir = settingsMap['downloadDir'] ?? ''; + final configuredDownloadDir = + (settingsMap['downloadDir'] as String? ?? '').trim(); + if (configuredDownloadDir.isEmpty) { + _downloadDir = await _defaultDownloadDirectory(); + needsSave = true; + } else { + _downloadDir = p.normalize(configuredDownloadDir); + } // Speed settings _maxOverallDownloadLimit = settingsMap['maxOverallDownloadLimit'] ?? 0; @@ -344,8 +360,12 @@ class Settings extends ChangeNotifier with Loggable { _userAgent = settingsMap['userAgent'] ?? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + + if (needsSave) { + await _saveAllSettings(); + } } else { - _applyDefaultSettings(); + await _applyDefaultSettings(save: true); } _isLoaded = true; @@ -357,14 +377,19 @@ class Settings extends ChangeNotifier with Loggable { } catch (e) { this.e('Failed to load settings', error: e); // Apply default settings - _applyDefaultSettings(); + await _applyDefaultSettings(save: true); _isLoaded = true; } } // Apply default settings - void _applyDefaultSettings() { - _assignDefaultSettings(); + Future _applyDefaultSettings({bool save = false}) async { + _assignDefaultSettings( + defaultDownloadDir: await _defaultDownloadDirectory(), + ); + if (save) { + await _saveAllSettings(); + } // Schedule notifyListeners to run after the current frame is built SchedulerBinding.instance.addPostFrameCallback((_) { notifyListeners(); @@ -448,7 +473,9 @@ class Settings extends ChangeNotifier with Loggable { } Future resetToDefaults() async { - _assignDefaultSettings(); + _assignDefaultSettings( + defaultDownloadDir: await _defaultDownloadDirectory(), + ); _isLoaded = true; notifyListeners(); await _saveAllSettings(); diff --git a/lib/services/aria2_rpc_client.dart b/lib/services/aria2_rpc_client.dart index 3c44cd2..cf7f7bc 100644 --- a/lib/services/aria2_rpc_client.dart +++ b/lib/services/aria2_rpc_client.dart @@ -352,16 +352,16 @@ class Aria2RpcClient with Loggable { } on ConnectionFailedException catch (e) { w('Connection test failed: $e'); return false; - } on UnauthorizedException { - rethrow; - } catch (err, stackTrace) { - e( - 'Unexpected error during connection test', - error: err, - stackTrace: stackTrace, - ); - rethrow; - } + } on UnauthorizedException { + rethrow; + } catch (err, stackTrace) { + e( + 'Unexpected error during connection test', + error: err, + stackTrace: stackTrace, + ); + rethrow; + } } /// Pause a download task @@ -547,6 +547,24 @@ class Aria2RpcClient with Loggable { } } + /// Shut down aria2 through RPC so it can flush its session state. + Future shutdown({bool force = false}) async { + try { + final response = await callRpc( + force ? 'aria2.forceShutdown' : 'aria2.shutdown', + [], + ); + return response['result'] == 'OK'; + } catch (e, stackTrace) { + this.e( + 'Failed to shut down ${instance.name} through RPC', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Purge all stopped download results from aria2. Future purgeDownloadResult() async { try { diff --git a/lib/services/builtin_instance_service.dart b/lib/services/builtin_instance_service.dart index 9dbe485..bf71736 100644 --- a/lib/services/builtin_instance_service.dart +++ b/lib/services/builtin_instance_service.dart @@ -3,11 +3,14 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; import '../models/aria2_instance.dart'; import '../utils/app_data_dir.dart'; -import 'builtin_upnp_service.dart'; +import '../utils/default_download_directory.dart'; import '../utils/logging.dart'; +import 'aria2_rpc_client.dart'; +import 'builtin_upnp_service.dart'; enum BuiltinInstanceApplyMode { none, liveApply, restartRequired } @@ -36,7 +39,7 @@ class BuiltinInstanceService with Loggable { void _initializePaths() { final dataDir = getAppDataDirectory(); - final coreDirPath = '${dataDir.path}/core'; + final coreDirPath = p.join(dataDir.path, 'core'); final coreDir = Directory(coreDirPath); if (!coreDir.existsSync()) { @@ -44,19 +47,22 @@ class BuiltinInstanceService with Loggable { coreDir.createSync(recursive: true); } - _aria2cPath = '$coreDirPath/aria2c${Platform.isWindows ? '.exe' : ''}'; - _aria2ConfPath = '$coreDirPath/aria2.conf'; - _sessionPath = '$coreDirPath/aria2.session'; - _logPath = '$coreDirPath/aria2.log'; + _aria2cPath = p.join( + coreDirPath, + 'aria2c${Platform.isWindows ? '.exe' : ''}', + ); + _aria2ConfPath = p.join(coreDirPath, 'aria2.conf'); + _sessionPath = p.join(coreDirPath, 'aria2.session'); + _logPath = p.join(coreDirPath, 'aria2.log'); } String _getSettingsFilePath() { final dataDir = getAppDataDirectory(); - final configDir = Directory('${dataDir.path}/config'); + final configDir = Directory(p.join(dataDir.path, 'config')); if (!configDir.existsSync()) { configDir.createSync(recursive: true); } - return '${configDir.path}/settings.json'; + return p.join(configDir.path, 'settings.json'); } Map _readSettingsSnapshot() { @@ -101,8 +107,7 @@ class BuiltinInstanceService with Loggable { } String _defaultDownloadDir() { - final dataDir = getAppDataDirectory(); - return '${dataDir.path}/downloads'; + return getDefaultDownloadDirectorySync(); } String _resolveEffectiveBtListenPort(Map settings) { @@ -328,6 +333,8 @@ class BuiltinInstanceService with Loggable { '--enable-dht6=${settings['enableDht6'] ?? true}', '--conf-path=$_aria2ConfPath', '--save-session=$sessionPath', + '--save-session-interval=30', + '--force-save=false', '--log-level=info', '--log=$logPath', ]; @@ -427,8 +434,19 @@ class BuiltinInstanceService with Loggable { await _stdoutSubscription?.cancel(); await _stderrSubscription?.cancel(); - _aria2Process!.kill(); - await _aria2Process!.exitCode.timeout(const Duration(seconds: 5)); + await _shutdownThroughRpcIfPossible(); + + if (_aria2Process != null) { + try { + await _aria2Process!.exitCode.timeout(const Duration(seconds: 5)); + } on TimeoutException { + this.w( + 'Built-in Aria2 did not exit after RPC shutdown, terminating process', + ); + _aria2Process!.kill(); + await _aria2Process!.exitCode.timeout(const Duration(seconds: 5)); + } + } _aria2Process = null; _isConnected = false; @@ -448,6 +466,25 @@ class BuiltinInstanceService with Loggable { return _aria2Process != null; } + int? get pid => _aria2Process?.pid; + + Future _shutdownThroughRpcIfPossible() async { + final client = Aria2RpcClient(getBuiltinInstanceConfig()); + try { + await client.saveSession(); + await client.shutdown(force: true); + } catch (e, stackTrace) { + this.w( + 'Failed to stop built-in Aria2 through RPC; falling back to process termination', + error: e, + stackTrace: stackTrace, + ); + _aria2Process?.kill(); + } finally { + client.close(); + } + } + void _monitorProcessOutput() { if (_aria2Process == null) return; diff --git a/lib/services/download_data_service.dart b/lib/services/download_data_service.dart index fefb2cb..dc78743 100644 --- a/lib/services/download_data_service.dart +++ b/lib/services/download_data_service.dart @@ -101,8 +101,15 @@ class DownloadDataService extends ChangeNotifier with Loggable { final newTasks = taskGroups.expand((tasks) => tasks).toList() ..sort(_compareTasks); - _collectTaskNotifications(previousTasks, newTasks); + final terminalTransitionInstanceIds = _collectTaskNotifications( + previousTasks, + newTasks, + ); _tasks = newTasks; + _saveSessionsForTerminalTransitions( + connectedInstances, + terminalTransitionInstanceIds, + ); notifyListeners(); } catch (e, stackTrace) { _lastError = e.toString(); @@ -236,12 +243,13 @@ class DownloadDataService extends ChangeNotifier with Loggable { } } - void _collectTaskNotifications( + Set _collectTaskNotifications( List previousTasks, List newTasks, ) { + final terminalTransitionInstanceIds = {}; if (previousTasks.isEmpty || newTasks.isEmpty) { - return; + return terminalTransitionInstanceIds; } final previousByKey = { @@ -262,6 +270,7 @@ class DownloadDataService extends ChangeNotifier with Loggable { } if (task.taskStatus == 'complete') { + terminalTransitionInstanceIds.add(task.instanceId); _pendingNotifications.add( DownloadTaskNotification( taskId: task.id, @@ -271,6 +280,7 @@ class DownloadDataService extends ChangeNotifier with Loggable { ), ); } else if (task.taskStatus == 'error') { + terminalTransitionInstanceIds.add(task.instanceId); _pendingNotifications.add( DownloadTaskNotification( taskId: task.id, @@ -282,6 +292,35 @@ class DownloadDataService extends ChangeNotifier with Loggable { ); } } + + return terminalTransitionInstanceIds; + } + + void _saveSessionsForTerminalTransitions( + List instances, + Set instanceIds, + ) { + if (instanceIds.isEmpty) { + return; + } + + for (final instance in instances) { + if (!instanceIds.contains(instance.id)) { + continue; + } + + final client = _getClient(instance); + unawaited( + client.saveSession().catchError((Object error, StackTrace stackTrace) { + this.w( + 'Failed to save session after terminal task transition for ${instance.name}', + error: error, + stackTrace: stackTrace, + ); + return false; + }), + ); + } } @override diff --git a/lib/utils/default_download_directory.dart b/lib/utils/default_download_directory.dart new file mode 100644 index 0000000..e0076ac --- /dev/null +++ b/lib/utils/default_download_directory.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'app_data_dir.dart'; + +String getDefaultDownloadDirectorySync() { + if (Platform.isWindows) { + final userProfile = Platform.environment['USERPROFILE']?.trim(); + if (userProfile != null && userProfile.isNotEmpty) { + return p.normalize(p.join(userProfile, 'Downloads')); + } + } + + final home = + Platform.environment['HOME']?.trim() ?? + Platform.environment['USERPROFILE']?.trim(); + if (home != null && home.isNotEmpty) { + return p.normalize(p.join(home, 'Downloads')); + } + + return p.normalize(p.join(getAppDataDirectory().path, 'downloads')); +} + +Future getDefaultDownloadDirectory() async { + return getDefaultDownloadDirectorySync(); +} From 99ff65c90a726272ce53caca523dae60dd3c7292 Mon Sep 17 00:00:00 2001 From: GT610 Date: Mon, 4 May 2026 12:36:19 +0800 Subject: [PATCH 2/2] fix: add timeout to built-in aria2 RPC shutdown --- lib/app.dart | 4 +--- lib/services/builtin_instance_service.dart | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 868f65a..ca4f7b9 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -948,9 +948,7 @@ class _MainWindowState extends State with WindowListener, Loggable { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Chip( - label: Text( - l10n.totalSpeed(formatSpeed(totalDownloadSpeed)), - ), + label: Text(l10n.totalSpeed(formatSpeed(totalDownloadSpeed))), avatar: const Icon(Icons.speed, size: 16), backgroundColor: colorScheme.surfaceContainerHighest, padding: const EdgeInsets.symmetric( diff --git a/lib/services/builtin_instance_service.dart b/lib/services/builtin_instance_service.dart index bf71736..5e40df8 100644 --- a/lib/services/builtin_instance_service.dart +++ b/lib/services/builtin_instance_service.dart @@ -16,6 +16,8 @@ enum BuiltinInstanceApplyMode { none, liveApply, restartRequired } /// Service class for managing the built-in Aria2 instance class BuiltinInstanceService with Loggable { + static const Duration _rpcShutdownTimeout = Duration(seconds: 5); + static BuiltinInstanceService? _instance; Process? _aria2Process; String? _aria2cPath; @@ -434,7 +436,14 @@ class BuiltinInstanceService with Loggable { await _stdoutSubscription?.cancel(); await _stderrSubscription?.cancel(); - await _shutdownThroughRpcIfPossible(); + try { + await _shutdownThroughRpcIfPossible().timeout(_rpcShutdownTimeout); + } on TimeoutException { + this.w( + 'Timed out waiting for built-in Aria2 RPC shutdown, terminating process', + ); + _aria2Process?.kill(); + } if (_aria2Process != null) { try { @@ -471,8 +480,13 @@ class BuiltinInstanceService with Loggable { Future _shutdownThroughRpcIfPossible() async { final client = Aria2RpcClient(getBuiltinInstanceConfig()); try { - await client.saveSession(); - await client.shutdown(force: true); + await client.saveSession().timeout(_rpcShutdownTimeout); + await client.shutdown(force: true).timeout(_rpcShutdownTimeout); + } on TimeoutException { + this.w( + 'Timed out during built-in Aria2 RPC shutdown; falling back to process termination', + ); + _aria2Process?.kill(); } catch (e, stackTrace) { this.w( 'Failed to stop built-in Aria2 through RPC; falling back to process termination',