feat(protocol): add FTP, FTPS and FTPES downloads (#1569)

This commit is contained in:
Silas Mariusz authored and GitHub committed 2026-10-30 17:01:46 +08:00
1 parent e0951de270
commit c0b70d319d
54 files changed
+3850 -38

No files matched your search

@@ -64,6 +64,10 @@ class ProtocolConfig {
Ed2kConfig ed2k = Ed2kConfig();
HlsConfig hls = HlsConfig();
/// A server without FTP support sends no `ftp` key; the defaults keep the rest of the config readable.
@JsonKey(fromJson: _ftpConfigFromJson)
FtpConfig ftp = FtpConfig();
ProtocolConfig();
factory ProtocolConfig.fromJson(Map<String, dynamic>? json) =>
@@ -91,6 +95,20 @@ class HlsConfig {
Map<String, dynamic> toJson() => _$HlsConfigToJson(this);
}
FtpConfig _ftpConfigFromJson(Map<String, dynamic>? json) => json == null ? FtpConfig() : FtpConfig.fromJson(json);
/// Global FTP defaults. It mirrors the `ftp` protocol config of the server.
@JsonSerializable()
class FtpConfig {
int connections;
FtpConfig({this.connections = 0});
factory FtpConfig.fromJson(Map<String, dynamic> json) => _$FtpConfigFromJson(json);
Map<String, dynamic> toJson() => _$FtpConfigToJson(this);
}
@JsonSerializable()
class HttpConfig {
String userAgent;
@@ -67,13 +67,15 @@ ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolCo
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)
..ed2k = Ed2kConfig.fromJson(json['ed2k'] as Map<String, dynamic>)
..hls = HlsConfig.fromJson(json['hls'] as Map<String, dynamic>);
..hls = HlsConfig.fromJson(json['hls'] as Map<String, dynamic>)
..ftp = _ftpConfigFromJson(json['ftp'] as Map<String, dynamic>?);
Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) => <String, dynamic>{
'http': instance.http.toJson(),
'bt': instance.bt.toJson(),
'ed2k': instance.ed2k.toJson(),
'hls': instance.hls.toJson(),
'ftp': instance.ftp.toJson(),
};
HlsConfig _$HlsConfigFromJson(Map<String, dynamic> json) => HlsConfig(
@@ -90,6 +92,11 @@ Map<String, dynamic> _$HlsConfigToJson(HlsConfig instance) => <String, dynamic>{
'prefetchContentLength': instance.prefetchContentLength,
};
FtpConfig _$FtpConfigFromJson(Map<String, dynamic> json) =>
FtpConfig(connections: (json['connections'] as num?)?.toInt() ?? 0);
Map<String, dynamic> _$FtpConfigToJson(FtpConfig instance) => <String, dynamic>{'connections': instance.connections};
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig(
userAgent: json['userAgent'] as String? ?? '',
connections: (json['connections'] as num?)?.toInt() ?? 0,
+25
View File
@@ -67,3 +67,28 @@ class OptsExtraHttp {
Map<String, dynamic> toJson() => _$OptsExtraHttpToJson(this);
}
/// Options for an FTP task. It mirrors `pkg/protocol/ftp.OptsExtra`. The TLS
/// mode of an `ftp://` link is on the request, see `ReqExtraFtp`.
@JsonSerializable()
class OptsExtraFtp {
int connections;
bool? autoTorrent;
bool? deleteTorrentAfterDownload;
bool? autoExtract;
String archivePassword;
bool deleteAfterExtract;
OptsExtraFtp({
this.connections = 0,
this.autoTorrent,
this.deleteTorrentAfterDownload,
this.autoExtract,
this.archivePassword = '',
this.deleteAfterExtract = false,
});
factory OptsExtraFtp.fromJson(Map<String, dynamic> json) => _$OptsExtraFtpFromJson(json);
Map<String, dynamic> toJson() => _$OptsExtraFtpToJson(this);
}
+18
View File
@@ -65,3 +65,21 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) =>
'deleteAfterExtract': instance.deleteAfterExtract,
'checksum': ?instance.checksum?.toJson(),
};
OptsExtraFtp _$OptsExtraFtpFromJson(Map<String, dynamic> json) => OptsExtraFtp(
connections: (json['connections'] as num?)?.toInt() ?? 0,
autoTorrent: json['autoTorrent'] as bool?,
deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?,
autoExtract: json['autoExtract'] as bool?,
archivePassword: json['archivePassword'] as String? ?? '',
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
);
Map<String, dynamic> _$OptsExtraFtpToJson(OptsExtraFtp instance) => <String, dynamic>{
'connections': instance.connections,
'autoTorrent': ?instance.autoTorrent,
'deleteTorrentAfterDownload': ?instance.deleteTorrentAfterDownload,
'autoExtract': ?instance.autoExtract,
'archivePassword': instance.archivePassword,
'deleteAfterExtract': instance.deleteAfterExtract,
};
+14
View File
@@ -42,6 +42,20 @@ class ReqExtraBt {
Map<String, dynamic> toJson() => _$ReqExtraBtToJson(this);
}
/// The request extra of an FTP task. It mirrors `pkg/protocol/ftp.ReqExtra`;
/// [tls] only applies to `ftp://` URLs, because `ftps://` always uses
/// implicit TLS and `ftpes://` always uses explicit TLS.
@JsonSerializable()
class ReqExtraFtp {
String tls;
ReqExtraFtp({this.tls = 'none'});
factory ReqExtraFtp.fromJson(Map<String, dynamic> json) => _$ReqExtraFtpFromJson(json);
Map<String, dynamic> toJson() => _$ReqExtraFtpToJson(this);
}
enum RequestProxyMode { follow, none, custom }
@JsonSerializable()
+4
View File
@@ -41,6 +41,10 @@ ReqExtraBt _$ReqExtraBtFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$ReqExtraBtToJson(ReqExtraBt instance) => <String, dynamic>{'trackers': instance.trackers};
ReqExtraFtp _$ReqExtraFtpFromJson(Map<String, dynamic> json) => ReqExtraFtp(tls: json['tls'] as String? ?? 'none');
Map<String, dynamic> _$ReqExtraFtpToJson(ReqExtraFtp instance) => <String, dynamic>{'tls': instance.tls};
RequestProxy _$RequestProxyFromJson(Map<String, dynamic> json) => RequestProxy(
mode: $enumDecodeNullable(_$RequestProxyModeEnumMap, json['mode']) ?? RequestProxyMode.follow,
scheme: json['scheme'] as String? ?? 'http',
+1 -1
View File
@@ -6,7 +6,7 @@ part 'task.g.dart';
enum Status { ready, running, pause, wait, error, done }
enum Protocol { http, bt, ed2k, hls }
enum Protocol { http, bt, ed2k, hls, ftp }
// ExtractStatus enum matching Go backend
enum ExtractStatus {
+7 -1
View File
@@ -40,7 +40,13 @@ const _$StatusEnumMap = {
Status.done: 'done',
};
const _$ProtocolEnumMap = {Protocol.http: 'http', Protocol.bt: 'bt', Protocol.ed2k: 'ed2k', Protocol.hls: 'hls'};
const _$ProtocolEnumMap = {
Protocol.http: 'http',
Protocol.bt: 'bt',
Protocol.ed2k: 'ed2k',
Protocol.hls: 'hls',
Protocol.ftp: 'ftp',
};
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
used: (json['used'] as num).toInt(),
+2 -1
View File
@@ -52,7 +52,8 @@ sealed class TaskStats {
final runtime = _map(json['runtime']);
if (snapshot.isEmpty && runtime.isEmpty) return null;
return switch (protocol) {
Protocol.http => HttpTaskStats.fromJson(snapshot),
// FTP stats have the same shape as HTTP stats: one entry per connection.
Protocol.http || Protocol.ftp => HttpTaskStats.fromJson(snapshot),
Protocol.bt => BtTaskStats.fromJson(snapshot: snapshot, runtime: runtime),
Protocol.ed2k => Ed2kTaskStats.fromJson(snapshot: snapshot, runtime: runtime),
Protocol.hls || null => null,
@@ -67,6 +67,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
final _maxRunningController = TextEditingController();
final _httpUserAgentController = TextEditingController();
final _httpConnectionsController = TextEditingController();
final _ftpConnectionsController = TextEditingController();
final _hlsConnectionsController = TextEditingController();
final _hlsMaxRetriesController = TextEditingController();
final _btListenPortController = TextEditingController();
@@ -101,6 +102,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_maxRunningController,
_httpUserAgentController,
_httpConnectionsController,
_ftpConnectionsController,
_hlsConnectionsController,
_hlsMaxRetriesController,
_btListenPortController,
@@ -583,6 +585,23 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
],
),
),
_SettingsBlock(
key: const ValueKey('settings-ftp-block'),
title: 'FTP',
child: _SettingsGroup(
children: [
SettingsItem(
title: context.l10n.connections,
child: _NumberSettingControl(
fieldKey: const ValueKey('ftp-connections-input'),
controller: _ftpConnectionsController,
min: 1,
max: _ftpMaxConnections,
),
),
],
),
),
_SettingsBlock(
title: 'BitTorrent',
child: _SettingsGroup(
@@ -1010,6 +1029,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_maxRunningController.text = _config!.maxRunning.clamp(1, 256).toString();
_httpUserAgentController.text = _config!.protocolConfig.http.userAgent;
_httpConnectionsController.text = _config!.protocolConfig.http.connections.clamp(1, 256).toString();
_ftpConnectionsController.text = _config!.protocolConfig.ftp.connections.clamp(1, _ftpMaxConnections).toString();
_hlsConnectionsController.text = _config!.protocolConfig.hls.segmentConnections.clamp(1, 256).toString();
_hlsMaxRetriesController.text = _config!.protocolConfig.hls.maxRetries.clamp(0, 30).toString();
_btListenPortController.text = _config!.protocolConfig.bt.listenPort.clamp(0, 65535).toString();
@@ -1113,6 +1133,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
min: 1,
max: 256,
);
config.protocolConfig.ftp.connections = _boundedInt(
_ftpConnectionsController,
fallback: config.protocolConfig.ftp.connections,
min: 1,
max: _ftpMaxConnections,
);
config.protocolConfig.hls.segmentConnections = _boundedInt(
_hlsConnectionsController,
fallback: config.protocolConfig.hls.segmentConnections,
@@ -2245,3 +2271,7 @@ class _ErrorState extends StatelessWidget {
);
}
}
/// The highest number of parallel FTP logins offered, the same limit as on the create task page. FTP servers
/// usually allow only a few logins per user.
const _ftpMaxConnections = 32;
@@ -304,7 +304,7 @@ TaskAssetType _assetType(String name, {required bool isFolder, required api_task
return switch (protocol) {
api_task.Protocol.bt => TaskAssetType.torrent,
api_task.Protocol.ed2k => TaskAssetType.ed2k,
api_task.Protocol.hls || api_task.Protocol.http || null => TaskAssetType.file,
api_task.Protocol.hls || api_task.Protocol.http || api_task.Protocol.ftp || null => TaskAssetType.file,
};
}
@@ -82,6 +82,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
bool? _deleteTorrentAfterDownload;
bool? _autoExtract;
bool _deleteAfterExtract = false;
String _ftpTls = _ftpTlsNone;
bool _allLinksFtp = false;
bool _hasPlainFtpLink = false;
List<DownloadCategory> _downloadCategories = const [];
bool _showAdvanced = false;
@@ -220,6 +223,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
}
_recognizeMagnetUri(urlText.trim());
_refreshFtpLinks();
}
/// Shows the FTP options (the 32-connection limit and the TLS select) only
/// while the input contains FTP links.
void _refreshFtpLinks() {
final links = Util.textToLines(_urlController.text).map((line) => line.trim()).where((line) => line.isNotEmpty);
final allFtp = links.isNotEmpty && links.every((link) => _parseProtocol(link) == _TaskProtocol.ftp);
final hasPlainFtp = links.any(_isPlainFtpUrl);
if (allFtp == _allLinksFtp && hasPlainFtp == _hasPlainFtpLink) return;
if (!mounted) {
_allLinksFtp = allFtp;
_hasPlainFtpLink = hasPlainFtp;
return;
}
setState(() {
_allLinksFtp = allFtp;
_hasPlainFtpLink = hasPlainFtp;
});
}
void _applyInitialTask(CreateTask? task) {
@@ -237,6 +259,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_httpBody = '';
_replaceHttpHeaders(const {'User-Agent': '', 'Cookie': '', 'Referer': ''});
_trackersController.clear();
_ftpTls = _ftpTlsNone;
_protocolTab = 0;
_urlController.text = req!.url;
_initialRawUrl = req.rawUrl;
@@ -261,6 +284,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_trackersController.text = extra.trackers.join('\n');
}
break;
case _TaskProtocol.ftp:
if (req.extra is Map) {
final tls = ReqExtraFtp.fromJson(Map<String, dynamic>.from(req.extra! as Map)).tls;
if (_ftpTlsModes.contains(tls)) {
_ftpTls = tls;
}
}
break;
case _TaskProtocol.ed2k:
case null:
break;
@@ -437,10 +468,22 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
fieldKey: const ValueKey('create-task-connections-input'),
controller: _connectionsController,
min: 1,
max: 256,
max: _allLinksFtp ? _ftpMaxConnections : 256,
hintText: context.l10n.enterCount,
),
),
if (_hasPlainFtpLink) ...[
const SizedBox(height: 16),
AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal,
label: context.l10n.ftpTls,
child: _FtpTlsSelect(
key: const ValueKey('create-task-ftp-tls'),
value: _ftpTls,
onChanged: (value) => setState(() => _ftpTls = value),
),
),
],
const SizedBox(height: 16),
AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal,
@@ -729,7 +772,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
),
),
],
if (_inputUrls().length <= 1) ...[
// Checksum verification is HTTP only.
if (_inputUrls().length <= 1 && !_allLinksFtp) ...[
const SizedBox(height: 14),
AppFormPair(
direction: stacked ? Axis.vertical : Axis.horizontal,
@@ -964,7 +1008,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
.createTask(
CreateTask(
req: _buildRequest(url, protocolUrl: _protocolUrlFor(url)),
opts: _buildOptions(name: urls.length > 1 ? '' : _renameController.text.trim()),
opts: _buildOptions(
url: _protocolUrlFor(url),
name: urls.length > 1 ? '' : _renameController.text.trim(),
),
),
);
}),
@@ -1024,6 +1071,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
extra = ReqExtraBt(trackers: trackers).toJson();
}
break;
case _TaskProtocol.ftp:
// ftps:// and ftpes:// fix the TLS mode; only a plain ftp:// link carries one.
if (_isPlainFtpUrl(protocolUrl ?? url) && _ftpTls != _ftpTlsNone) {
extra = ReqExtraFtp(tls: _ftpTls).toJson();
}
break;
case _TaskProtocol.ed2k:
case null:
break;
@@ -1039,7 +1092,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
bool _validateAdvancedOptions() {
if (_protocolTab == 0 && _inputUrls().length <= 1) {
if (_protocolTab == 0 && _inputUrls().length <= 1 && !_allLinksFtp) {
final checksumExpected = _checksumHashController.text.trim();
if (checksumExpected.isNotEmpty) {
final expectedLen = switch (_checksumAlgorithm.toLowerCase()) {
@@ -1099,6 +1152,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
if (uppercaseUrl.startsWith('HTTP:') || uppercaseUrl.startsWith('HTTPS:')) {
return _TaskProtocol.http;
}
// Checked before BitTorrent, so ftp://host/file.torrent downloads the file over FTP.
if (uppercaseUrl.startsWith('FTP:') || uppercaseUrl.startsWith('FTPS:') || uppercaseUrl.startsWith('FTPES:')) {
return _TaskProtocol.ftp;
}
if (uppercaseUrl.startsWith('MAGNET:') || uppercaseUrl.endsWith('.TORRENT')) {
return _TaskProtocol.bt;
}
@@ -1108,6 +1165,9 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
return null;
}
/// Whether [url] is a plain `ftp://` link, the only FTP scheme whose TLS mode is chosen by the user.
bool _isPlainFtpUrl(String url) => url.trim().toUpperCase().startsWith('FTP:');
String _normalizeTaskUrl(String url) {
if (_isBtHash(url)) {
return 'magnet:?xt=urn:btih:$url';
@@ -1130,7 +1190,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
}
/// Builds the task options for [url]. FTP links get the FTP options, with at
/// most [_ftpMaxConnections] connections; the checksum is HTTP only.
api_options.Options _buildOptions({
required String url,
String? name,
List<int> selectFiles = const [],
bool omitChecksum = false,
@@ -1138,18 +1201,22 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}) {
final urls = _inputUrls();
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
final checksumExpected = _checksumHashController.text.trim();
final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
? api_options.ChecksumOption(algorithm: _checksumAlgorithm, expected: checksumExpected)
: null;
return api_options.Options(
size: urls.length == 1 && _parseProtocol(_protocolUrlFor(urls.first)) == _TaskProtocol.http ? _initialSize : 0,
name: name ?? _renameController.text.trim(),
path: _directoryController.text.trim(),
asDefaultPath: _asDefaultPath,
skipAutoCategorize: skipAutoCategorize ?? _directoryManuallySet,
selectFiles: selectFiles,
extra: api_options.OptsExtraHttp(
final Map<String, dynamic> extra;
if (_parseProtocol(url) == _TaskProtocol.ftp) {
extra = api_options.OptsExtraFtp(
connections: connections.clamp(0, _ftpMaxConnections),
autoTorrent: _autoTorrent,
deleteTorrentAfterDownload: _deleteTorrentAfterDownload,
autoExtract: _autoExtract,
archivePassword: _archivePasswordController.text,
deleteAfterExtract: _deleteAfterExtract,
).toJson();
} else {
final checksumExpected = _checksumHashController.text.trim();
final checksum = (!omitChecksum && urls.length <= 1 && checksumExpected.isNotEmpty)
? api_options.ChecksumOption(algorithm: _checksumAlgorithm, expected: checksumExpected)
: null;
extra = api_options.OptsExtraHttp(
connections: connections,
autoTorrent: _autoTorrent,
deleteTorrentAfterDownload: _deleteTorrentAfterDownload,
@@ -1157,7 +1224,16 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
archivePassword: _archivePasswordController.text,
deleteAfterExtract: _deleteAfterExtract,
checksum: checksum,
).toJson(),
).toJson();
}
return api_options.Options(
size: urls.length == 1 && _parseProtocol(_protocolUrlFor(urls.first)) == _TaskProtocol.http ? _initialSize : 0,
name: name ?? _renameController.text.trim(),
path: _directoryController.text.trim(),
asDefaultPath: _asDefaultPath,
skipAutoCategorize: skipAutoCategorize ?? _directoryManuallySet,
selectFiles: selectFiles,
extra: extra,
);
}
@@ -1232,7 +1308,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
.createTask(
CreateTask(
rid: result.id,
opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile),
opts: _buildOptions(url: request.url, selectFiles: selected, omitChecksum: isMultiFile),
),
);
return;
@@ -1254,7 +1330,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
name: file.name,
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
skipAutoCategorize: keepStructure,
extra: _buildOptions(omitChecksum: isMultiFile).extra,
extra: _buildOptions(url: fileRequest.url, omitChecksum: isMultiFile).extra,
),
);
}).toList();
@@ -1263,13 +1339,20 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
.createTaskBatch(
CreateTaskBatch(
reqs: reqs,
opts: _buildOptions(omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
opts: _buildOptions(url: request.url, omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
),
);
return;
}
await ref.read(gopeedServiceProvider).createTask(CreateTask(req: request, opts: _buildOptions()));
await ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
req: request,
opts: _buildOptions(url: request.url),
),
);
}
Future<bool> _showResolveDialog(Request request, ResolveResult result) async {
@@ -1595,7 +1678,48 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
}
enum _TaskProtocol { http, bt, ed2k }
enum _TaskProtocol { http, bt, ed2k, ftp }
/// Chooses how a plain `ftp://` link is encrypted. The values are the
/// `tls` option of an FTP task.
class _FtpTlsSelect extends StatelessWidget {
const _FtpTlsSelect({super.key, required this.value, required this.onChanged});
final String value;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final labels = {
_ftpTlsNone: context.l10n.ftpTlsNone,
_ftpTlsExplicit: context.l10n.ftpTlsExplicit,
_ftpTlsImplicit: context.l10n.ftpTlsImplicit,
};
return LayoutBuilder(
builder: (context, constraints) => Select<String>(
value: value,
constraints: BoxConstraints.tightFor(width: constraints.maxWidth),
itemBuilder: (context, selected) =>
Text(labels[selected] ?? selected, maxLines: 1, overflow: TextOverflow.ellipsis),
popup: (context) => SelectPopup<String>(
items: SelectItemList(
children: [
for (final entry in labels.entries)
SelectItemButton<String>(
key: ValueKey('create-task-ftp-tls-${entry.key}'),
value: entry.key,
child: Text(entry.value),
),
],
),
),
onChanged: (selected) {
if (selected != null) onChanged(selected);
},
),
);
}
}
class _DirectDownloadToggle extends StatelessWidget {
const _DirectDownloadToggle({required this.value, required this.onChanged});
@@ -2007,3 +2131,11 @@ const _advancedScrollDuration = Duration(milliseconds: 260);
const _advancedScrollExtentRetryDelay = Duration(milliseconds: 40);
const _advancedScrollExtentChecks = 4;
const _advancedScrollDistance = 112.0;
/// The highest number of parallel logins offered for FTP links. FTP servers
/// usually allow only a few logins per user.
const _ftpMaxConnections = 32;
const _ftpTlsNone = 'none';
const _ftpTlsExplicit = 'explicit';
const _ftpTlsImplicit = 'implicit';
const _ftpTlsModes = {_ftpTlsNone, _ftpTlsExplicit, _ftpTlsImplicit};
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "حفظ كمجلد التنزيل الافتراضي",
"downloadDirValid": "الرجاء تحديد مجلد التنزيل",
"connections": "الاتصالات",
"ftpTls": "تشفير FTP",
"ftpTlsNone": "بدون",
"ftpTlsExplicit": "TLS صريح (FTPES)",
"ftpTlsImplicit": "TLS ضمني (FTPS)",
"maxRetries": "الحد الأقصى لإعادة المحاولات",
"useServerCtime": "استخدام وقت الخادم لإنشاء الملف",
"maxRunning": "الحد الأقصى للمهام المتزامنة",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Standart endirmə qovluğu kimi yadda saxla",
"downloadDirValid": "Zəhmət olmasa endirmə qovluğunu seçin",
"connections": "Əlaqələr",
"ftpTls": "FTP şifrələməsi",
"ftpTlsNone": "Yoxdur",
"ftpTlsExplicit": "Açıq TLS (FTPES)",
"ftpTlsImplicit": "Gizli TLS (FTPS)",
"maxRetries": "Maksimum cəhd sayı",
"useServerCtime": "Fayl yaratma tarixi üçün server vaxtından istifadə et",
"maxRunning": "Paralel Endirmələr",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Desa com a carpeta de baixades predeterminada",
"downloadDirValid": "Seleccioneu la carpeta de baixades",
"connections": "Connexions",
"ftpTls": "Xifratge FTP",
"ftpTlsNone": "Cap",
"ftpTlsExplicit": "TLS explícit (FTPES)",
"ftpTlsImplicit": "TLS implícit (FTPS)",
"maxRetries": "Reintents màxims",
"useServerCtime": "Utilitza l'hora del servidor per a la creació de fitxers",
"maxRunning": "Màxim de tasques en execució",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Als Standard-Downloadordner speichern",
"downloadDirValid": "Bitte wählen Sie das Download-Verzeichnis",
"connections": "Anschlüsse",
"ftpTls": "FTP-Verschlüsselung",
"ftpTlsNone": "Keine",
"ftpTlsExplicit": "Explizites TLS (FTPES)",
"ftpTlsImplicit": "Implizites TLS (FTPS)",
"maxRetries": "Maximale Wiederholungsversuche",
"useServerCtime": "Serverzeit für die Dateierstellung verwenden",
"maxRunning": "Maximale Anzahl ausgeführter Aufgaben",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Save as default download directory",
"downloadDirValid": "Please select the download directory",
"connections": "Connections",
"ftpTls": "FTP encryption",
"ftpTlsNone": "None",
"ftpTlsExplicit": "Explicit TLS (FTPES)",
"ftpTlsImplicit": "Implicit TLS (FTPS)",
"maxRetries": "Max retries",
"useServerCtime": "Use server time for file creation",
"maxRunning": "Concurrent Downloads",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Guardar como carpeta de descargas predeterminada",
"downloadDirValid": "Por favor, selecciona el directorio de descarga",
"connections": "Conexiones",
"ftpTls": "Cifrado FTP",
"ftpTlsNone": "Ninguno",
"ftpTlsExplicit": "TLS explícito (FTPES)",
"ftpTlsImplicit": "TLS implícito (FTPS)",
"maxRetries": "Reintentos máximos",
"useServerCtime": "Usar tiempo del servidor para la creación de archivos",
"maxRunning": "Máximo de Tareas en Ejecución",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "ذخیره به‌عنوان پوشه پیش‌فرض دانلود",
"downloadDirValid": "لطفا دایرکتوری دانلود را انتخاب کنید",
"connections": "اتصالات",
"ftpTls": "رمزگذاری FTP",
"ftpTlsNone": "هیچ",
"ftpTlsExplicit": "TLS صریح (FTPES)",
"ftpTlsImplicit": "TLS ضمنی (FTPS)",
"maxRetries": "حداکثر تلاش مجدد",
"useServerCtime": "استفاده از زمان سرور برای ایجاد فایل",
"maxRunning": "حداکثر وظایف در حال اجرا",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Enregistrer comme dossier de téléchargement par défaut",
"downloadDirValid": "Veuillez sélectionner le répertoire de téléchargement",
"connections": "Connexions",
"ftpTls": "Chiffrement FTP",
"ftpTlsNone": "Aucun",
"ftpTlsExplicit": "TLS explicite (FTPES)",
"ftpTlsImplicit": "TLS implicite (FTPS)",
"maxRetries": "Nombre maximal de tentatives",
"useServerCtime": "Utiliser l'heure du serveur pour la création de fichier",
"maxRunning": "Tâches en cours maximum",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Mentés alapértelmezett letöltési mappaként",
"downloadDirValid": "Kérjük, válassza ki a letöltési könyvtárat",
"connections": "Kapcsolatok",
"ftpTls": "FTP-titkosítás",
"ftpTlsNone": "Nincs",
"ftpTlsExplicit": "Explicit TLS (FTPES)",
"ftpTlsImplicit": "Implicit TLS (FTPS)",
"maxRetries": "Maximális újrapróbálkozások",
"useServerCtime": "Használja a szerveridőt a fájl létrehozásához",
"maxRunning": "Maximális futó feladatok",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Simpan sebagai folder unduhan default",
"downloadDirValid": "Silahkan pilih direktori unduhan",
"connections": "Koneksi",
"ftpTls": "Enkripsi FTP",
"ftpTlsNone": "Tidak ada",
"ftpTlsExplicit": "TLS eksplisit (FTPES)",
"ftpTlsImplicit": "TLS implisit (FTPS)",
"maxRetries": "Percobaan ulang maks.",
"useServerCtime": "Gunakan waktu server untuk pembuatan file",
"maxRunning": "Tugas Maksimum yang Berjalan",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Salva come cartella di download predefinita",
"downloadDirValid": "Seleziona la directory di scaricamento",
"connections": "Connessioni",
"ftpTls": "Crittografia FTP",
"ftpTlsNone": "Nessuna",
"ftpTlsExplicit": "TLS esplicito (FTPES)",
"ftpTlsImplicit": "TLS implicito (FTPS)",
"maxRetries": "Tentativi massimi",
"useServerCtime": "Utilizza l'ora del server per la creazione dei file",
"maxRunning": "Numero massimo di attività in esecuzione",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "デフォルトのダウンロード先として保存",
"downloadDirValid": "ダウンロードディレクトリを選択してください",
"connections": "接続",
"ftpTls": "FTP 暗号化",
"ftpTlsNone": "なし",
"ftpTlsExplicit": "明示的 TLS (FTPES)",
"ftpTlsImplicit": "暗黙的 TLS (FTPS)",
"maxRetries": "最大リトライ回数",
"useServerCtime": "ファイル作成時にサーバー時間を使用する",
"maxRunning": "最大実行タスク",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "기본 다운로드 폴더로 저장",
"downloadDirValid": "다운로드 폴더를 선택해 주세요",
"connections": "연결 수",
"ftpTls": "FTP 암호화",
"ftpTlsNone": "없음",
"ftpTlsExplicit": "명시적 TLS (FTPES)",
"ftpTlsImplicit": "암시적 TLS (FTPS)",
"maxRetries": "최대 재시도 횟수",
"useServerCtime": "파일 생성 시간에 서버 시간 사용",
"maxRunning": "최대 동시 작업 수",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Zapisz jako domyślny folder pobierania",
"downloadDirValid": "Wskaż folder pobierania",
"connections": "Połączenia",
"ftpTls": "Szyfrowanie FTP",
"ftpTlsNone": "Brak",
"ftpTlsExplicit": "Jawne TLS (FTPES)",
"ftpTlsImplicit": "Niejawne TLS (FTPS)",
"maxRetries": "Maks. liczba ponownych prób",
"useServerCtime": "Użyj czasu serwera do utworzenia pliku",
"maxRunning": "Maksymalna liczba zadań",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Salvar como pasta de download padrão",
"downloadDirValid": "Por favor, selecione a pasta",
"connections": "Conexões",
"ftpTls": "Criptografia FTP",
"ftpTlsNone": "Nenhuma",
"ftpTlsExplicit": "TLS explícito (FTPES)",
"ftpTlsImplicit": "TLS implícito (FTPS)",
"maxRetries": "Tentativas máximas",
"useServerCtime": "Usar o tempo de servidor para a criação de ficheiros",
"maxRunning": "Máximo de Tarefas Simultâneas",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Сохранить как папку загрузок по умолчанию",
"downloadDirValid": "Пожалуйста, выберите папку для загрузки",
"connections": "Соединений",
"ftpTls": "Шифрование FTP",
"ftpTlsNone": "Нет",
"ftpTlsExplicit": "Явный TLS (FTPES)",
"ftpTlsImplicit": "Неявный TLS (FTPS)",
"maxRetries": "Макс. число повторов",
"useServerCtime": "Использовать время сервера для создания файла",
"maxRunning": "Максимальное количество активных задач",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Uložiť ako predvolený priečinok na stiahnutie",
"downloadDirValid": "Vyberte priečinok na stiahnutie",
"connections": "Pripojenia",
"ftpTls": "Šifrovanie FTP",
"ftpTlsNone": "Žiadne",
"ftpTlsExplicit": "Explicitné TLS (FTPES)",
"ftpTlsImplicit": "Implicitné TLS (FTPS)",
"maxRetries": "Maximálny počet pokusov",
"useServerCtime": "Použiť čas servera na vytvorenie súboru",
"maxRunning": "Súbežné sťahovania",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "இயல்புநிலை பதிவிறக்க கோப்புறையாக சேமி",
"downloadDirValid": "பதிவிரக்குமிடத்தை தேர்வு செயுங்கள்",
"connections": "தொடர்புகள்",
"ftpTls": "FTP குறியாக்கம்",
"ftpTlsNone": "இல்லை",
"ftpTlsExplicit": "வெளிப்படையான TLS (FTPES)",
"ftpTlsImplicit": "மறைமுக TLS (FTPS)",
"maxRetries": "அதிகபட்ச மறுமுயற்சிகள்",
"useServerCtime": "கோப்பு உருவாக்க சேவையக நேரத்தைப் பயன்படுத்தவும்",
"maxRunning": "அதிகபட்ச இயங்கும் பணிகள்",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Varsayılan indirme klasörü olarak kaydet",
"downloadDirValid": "Lütfen indirme dizinini seçin",
"connections": "Bağlantılar",
"ftpTls": "FTP şifrelemesi",
"ftpTlsNone": "Yok",
"ftpTlsExplicit": "Açık TLS (FTPES)",
"ftpTlsImplicit": "Örtük TLS (FTPS)",
"maxRetries": "Maksimum yeniden deneme",
"useServerCtime": "Dosya oluşturma tarihi için sunucu zamanını kullan",
"maxRunning": "Maksimum Çalışan İşlem",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Зберегти як типову папку завантажень",
"downloadDirValid": "Будь ласка, виберіть каталог завантажень",
"connections": "З’єднання",
"ftpTls": "Шифрування FTP",
"ftpTlsNone": "Немає",
"ftpTlsExplicit": "Явний TLS (FTPES)",
"ftpTlsImplicit": "Неявний TLS (FTPS)",
"maxRetries": "Макс. кількість повторів",
"useServerCtime": "Використовувати час сервера для створення файлу",
"maxRunning": "Макс. завдань у черзі",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "Lưu làm thư mục tải xuống mặc định",
"downloadDirValid": "Vui lòng chọn thư mục tải về",
"connections": "Kết nối",
"ftpTls": "Mã hóa FTP",
"ftpTlsNone": "Không",
"ftpTlsExplicit": "TLS tường minh (FTPES)",
"ftpTlsImplicit": "TLS ngầm định (FTPS)",
"maxRetries": "Số lần thử lại tối đa",
"useServerCtime": "Sử dụng thời gian máy chủ cho việc tạo tệp",
"maxRunning": "Số nhiệm vụ tối đa",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "保存为默认下载目录",
"downloadDirValid": "请选择下载目录",
"connections": "连接数",
"ftpTls": "FTP 加密",
"ftpTlsNone": "无",
"ftpTlsExplicit": "显式 TLS (FTPES)",
"ftpTlsImplicit": "隐式 TLS (FTPS)",
"maxRetries": "最大重试次数",
"useServerCtime": "创建文件使用服务器时间",
"maxRunning": "最大下载数",
+4
View File
@@ -38,6 +38,10 @@
"rememberLastDownloadDir": "儲存為預設下載目錄",
"downloadDirValid": "請選擇下載目錄",
"connections": "連接數",
"ftpTls": "FTP 加密",
"ftpTlsNone": "無",
"ftpTlsExplicit": "明確 TLS (FTPES)",
"ftpTlsImplicit": "隱含 TLS (FTPS)",
"maxRetries": "最大重試次數",
"useServerCtime": "使用伺服器時間作為檔案建立時間",
"maxRunning": "最大執行任務數",
@@ -67,6 +67,25 @@ void main() {
expect(decoded.disablePex, isTrue);
});
test('FTP settings round-trip with the Go json keys', () {
final config = DownloaderConfig();
config.protocolConfig.ftp.connections = 6;
final json = config.toJson();
final ftp = (json['protocolConfig'] as Map<String, dynamic>)['ftp'] as Map<String, dynamic>;
expect(ftp, {'connections': 6});
expect(DownloaderConfig.fromJson(json).protocolConfig.ftp.connections, 6);
});
test('a stored config without FTP settings still parses', () {
final json = DownloaderConfig().toJson();
(json['protocolConfig'] as Map<String, dynamic>).remove('ftp');
final decoded = DownloaderConfig.fromJson(json);
expect(decoded.protocolConfig.ftp.connections, 0);
expect(decoded.protocolConfig.http.connections, DownloaderConfig().protocolConfig.http.connections);
});
test('BitTorrent peer discovery stays on when a stored config lacks the keys', () {
final bt = BtConfig.fromJson({'listenPort': 6881, 'seedRatio': 1.0});
expect(bt.disableDht, isFalse);
+16
View File
@@ -23,6 +23,22 @@ void main() {
expect(stats.connections.last.completed, isTrue);
});
test('FTP stats use the HTTP connection layout', () {
final stats =
TaskStats.fromJson(Protocol.ftp, {
'snapshot': {
'connections': [
{'downloaded': 512, 'total': 1024, 'completed': false, 'failed': false, 'retryTimes': 1},
],
},
'runtime': null,
})
as HttpTaskStats;
expect(stats.connections.single.downloaded, 512);
expect(stats.connections.single.retryTimes, 1);
});
test('BT stats decode the ordered completion bitset and peer transport', () {
final stats =
TaskStats.fromJson(Protocol.bt, {
@@ -4,10 +4,10 @@ import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
void main() {
api.Task buildTask(List<int> selection, int size, {String? doneAt}) => api.Task.fromJson({
api.Task buildTask(List<int> selection, int size, {String? doneAt, String protocol = 'bt'}) => api.Task.fromJson({
'id': 'bt-selection',
'name': 'bundle',
'protocol': 'bt',
'protocol': protocol,
'status': 'running',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
@@ -38,6 +38,13 @@ void main() {
expect(record.progress, 0.25);
});
test('tasks with the ftp protocol decode', () {
final task = buildTask([], 60, protocol: 'ftp');
expect(task.protocol, api.Protocol.ftp);
final record = TaskRecord.fromApi(task);
expect(record.protocol, api.Protocol.ftp);
});
test('empty selection preserves default all files', () {
final record = TaskRecord.fromApi(buildTask([], 60));
expect(record.files.length, 3);
@@ -15,6 +15,7 @@ import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/api/model/resolve_task.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
@@ -95,10 +96,7 @@ void main() {
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(submitted?.reqs?.map((item) => item.opts?.size), [3, 1048576]);
expect(submitted?.reqs?.map((item) => item.req?.url), [
'https://example.com/video',
'https://example.com/audio',
]);
expect(submitted?.reqs?.map((item) => item.req?.url), ['https://example.com/video', 'https://example.com/audio']);
expect(tester.takeException(), isNull);
});
@@ -1603,6 +1601,171 @@ void main() {
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
group('FTP links', _ftpTests);
}
Future<void> _pumpCreateRoute(WidgetTester tester, CapabilityRegistry registry, {CreateTask? initialTask}) async {
tester.view.physicalSize = const Size(1024, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(
path: '/create',
builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask),
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()),
routerConfig: router,
),
),
);
await tester.pumpAndSettle();
}
void _ftpTests() {
const urlInput = ValueKey('create-task-url-input');
const connectionsInput = ValueKey('create-task-connections-input');
const tlsSelect = ValueKey('create-task-ftp-tls');
testWidgets('ftp links offer the TLS mode and carry FTP options through resolve', (tester) async {
ResolveTask? resolved;
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.resolve, (task) {
resolved = task;
return ResolveResult(
id: 'ftp-resolved',
res: Resource(
name: 'big.iso',
files: [FileInfo(name: 'big.iso', size: 1024)],
),
);
})
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'ftp-task';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
expect(find.byKey(tlsSelect), findsNothing);
// ftps:// and ftpes:// fix the TLS mode, so only ftp:// offers the select.
for (final url in ['ftps://files.example/pub/big.iso', 'ftpes://files.example/pub/big.iso']) {
await tester.enterText(find.byKey(urlInput), url);
await tester.pump();
expect(find.byKey(tlsSelect), findsNothing, reason: url);
}
await tester.enterText(find.byKey(urlInput), 'FTP://files.example/pub/big.iso');
await tester.pump();
expect(find.byKey(tlsSelect), findsOneWidget);
expect(find.descendant(of: find.byKey(tlsSelect), matching: find.text('None')), findsOneWidget);
await tester.tap(find.byKey(tlsSelect));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('create-task-ftp-tls-implicit')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('create-task-ftp-tls-explicit')));
await tester.pumpAndSettle();
expect(find.descendant(of: find.byKey(tlsSelect), matching: find.text('Explicit TLS (FTPES)')), findsOneWidget);
await tester.enterText(find.byKey(connectionsInput), '64');
await tester.pump();
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
// The confirm button spins while the resolve dialog is open, so the frames never settle.
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
// The TLS mode travels on the request, so the resolve already uses it.
expect(resolved?.req?.url, 'FTP://files.example/pub/big.iso');
expect(resolved?.req?.extra, {'tls': 'explicit'});
expect(submitted?.rid, 'ftp-resolved');
final createOptions = submitted?.opts?.extra! as Map<String, dynamic>;
expect(createOptions['connections'], 32);
expect(createOptions.containsKey('tls'), isFalse);
});
testWidgets('an ftp .torrent link restores its TLS mode and is not treated as BitTorrent', (tester) async {
CreateTask? submitted;
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'ftp-task';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(
tester,
registry,
initialTask: CreateTask(
req: Request(
url: 'ftp://files.example/pub/linux.torrent',
extra: ReqExtraFtp(tls: 'implicit').toJson(),
),
opts: Options(extra: OptsExtraFtp(connections: 6).toJson()),
),
);
expect(_fieldText(tester, 'create-task-connections-input'), '6');
expect(find.descendant(of: find.byKey(tlsSelect), matching: find.text('Implicit TLS (FTPS)')), findsOneWidget);
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
// The HTTP tab holds the certificate and post-download switches FTP uses; trackers stay hidden.
expect(find.byKey(const ValueKey('create-task-skip-verify-cert')), findsOneWidget);
expect(find.text('Trackers'), findsNothing);
// Checksum verification is HTTP only, so an FTP link does not offer it.
expect(find.byKey(const ValueKey('create-task-checksum-expected')), findsNothing);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted?.req?.url, 'ftp://files.example/pub/linux.torrent');
expect(submitted?.req?.extra, {'tls': 'implicit'});
final options = OptsExtraFtp.fromJson(submitted?.opts?.extra! as Map<String, dynamic>);
expect(options.connections, 6);
});
testWidgets('mixed links cap only the FTP task at 32 connections', (tester) async {
final submitted = <CreateTask>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
submitted.add(task);
return 'task-${submitted.length}';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
await tester.enterText(find.byKey(urlInput), 'https://example.com/a.zip\nftpes://files.example/b.iso');
await tester.pump();
expect(find.byKey(tlsSelect), findsNothing);
await tester.enterText(find.byKey(connectionsInput), '64');
await tester.pump();
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
final byUrl = {for (final task in submitted) task.req!.url: task.opts!.extra! as Map<String, dynamic>};
expect(byUrl.keys, unorderedEquals(['https://example.com/a.zip', 'ftpes://files.example/b.iso']));
expect(byUrl['https://example.com/a.zip']!['connections'], 64);
expect(byUrl['https://example.com/a.zip']!.containsKey('tls'), isFalse);
expect(byUrl['ftpes://files.example/b.iso']!['connections'], 32);
expect(byUrl['ftpes://files.example/b.iso']!.containsKey('tls'), isFalse);
expect(submitted.map((task) => task.req!.extra), everyElement(isNull));
});
}
/// Unmounts the tree and lets the shadcn toast close timers fire, so a test
+39 -3
View File
@@ -1631,6 +1631,43 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('settings FTP connections save into the FTP protocol config', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
final settingsController = FakeSettingsController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(() => settingsController),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final ftpBlock = find.byKey(const ValueKey('settings-ftp-block'));
expect(ftpBlock, findsOneWidget);
expect(find.descendant(of: ftpBlock, matching: find.text('FTP')), findsOneWidget);
final connections = find.byKey(const ValueKey('ftp-connections-input'));
await tester.ensureVisible(connections);
await tester.pumpAndSettle();
await tester.enterText(connections, '6');
await tester.pump(const Duration(milliseconds: 700));
expect(settingsController.state.requireValue.config.protocolConfig.ftp.connections, 6);
expect(settingsController.state.requireValue.config.protocolConfig.http.connections, isNot(6));
// FTP servers usually allow only a few logins, so the field stops at 32.
await tester.enterText(connections, '64');
await tester.pump(const Duration(milliseconds: 700));
expect(tester.widget<shad.TextField>(connections).controller!.text, '32');
expect(settingsController.state.requireValue.config.protocolConfig.ftp.connections, 32);
expect(tester.takeException(), isNull);
});
testWidgets('download directory keeps long text clear of its aligned picker action', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
await tester.pumpWidget(
@@ -2589,9 +2626,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('dat is already used by 资料'), findsOneWidget);
AppPrimaryButton saveButton() => tester.widget<AppPrimaryButton>(
find.ancestor(of: find.text('Save'), matching: find.byType(AppPrimaryButton)),
);
AppPrimaryButton saveButton() =>
tester.widget<AppPrimaryButton>(find.ancestor(of: find.text('Save'), matching: find.byType(AppPrimaryButton)));
expect(saveButton().onPressed, isNull);
await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'mkv');