mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 12:23:43 +00:00
refactor: remove proxy API (#1596)
This commit is contained in:
98 files changed
+5958
-1225
No files matched your search
@@ -68,12 +68,11 @@ The following endpoints are web HTTP infrastructure and are not part of the FFI/
|
||||
|
||||
- `/fs/tasks/*`
|
||||
- `/fs/extensions/*`
|
||||
- `/api/web/proxy`
|
||||
- Web static file hosting at `/`
|
||||
|
||||
`/fs/tasks` returns files from the Go host machine to a browser over HTTP. Native clients can access local file paths directly and do not need an equivalent API.
|
||||
|
||||
`/api/web/proxy` is infrastructure used by the web transport to work around browser CORS restrictions. It is not part of the published core `/api/v1/**` API. Native clients can request the target URL directly with Dio and do not need the Go proxy.
|
||||
All unmatched `/api` and `/api/**` requests use the common 404 handler before the web file handler.
|
||||
|
||||
## 4. Backend Maintenance Model
|
||||
|
||||
@@ -182,7 +181,7 @@ The REST adapter is limited to transport responsibilities:
|
||||
- Convert `http.Request` into a Dispatcher request.
|
||||
- Write a Dispatcher response back to HTTP.
|
||||
- Handle API tokens, web login, CORS, compression, and panic recovery.
|
||||
- Register web-only file, proxy, and static resource routes.
|
||||
- Register web-only file and static resource routes.
|
||||
|
||||
The REST adapter no longer calls the global `rest.Downloader` directly.
|
||||
|
||||
@@ -198,7 +197,6 @@ Core API routes
|
||||
|
||||
Web-only routes
|
||||
├── /api/web/login
|
||||
├── /api/web/proxy
|
||||
├── /fs/tasks/*
|
||||
├── /fs/extensions/*
|
||||
└── /* static Web UI
|
||||
@@ -416,12 +414,7 @@ The following patterns are prohibited:
|
||||
|
||||
### 7.4 Non-business HTTP capabilities
|
||||
|
||||
`proxyRequest` is not a Gopeed Core API and belongs in a separate `ExternalHttpClient`:
|
||||
|
||||
```text
|
||||
Native → request the target directly with Dio
|
||||
Web → /api/web/proxy
|
||||
```
|
||||
Tracker subscriptions run in Go. Application update checks and extension store requests run directly in the client. Go supplies mirror candidate URLs through `/api/v1/mirrors/{provider}/candidates`; clients probe and download update packages locally with Dio to retain real download progress. There is no arbitrary external HTTP forwarding API.
|
||||
|
||||
Task file access does not belong in `GopeedClient`:
|
||||
|
||||
@@ -594,7 +587,7 @@ Flutter converts all of them into `GopeedException`. Components do not handle Di
|
||||
|
||||
- Windows, macOS, Linux: startup, InvokeAsync, events, shutdown, and repeated startup.
|
||||
- Android, iOS: gomobile InvokeAsync callbacks, reverse MethodChannel events, and background/foreground transitions.
|
||||
- Web: REST, login, proxy, and task-file downloads.
|
||||
- Web: REST, login, and task-file downloads.
|
||||
- Native: enabling and disabling REST does not interrupt download tasks.
|
||||
|
||||
## 12. Implementation Phases
|
||||
|
||||
@@ -208,11 +208,6 @@ Future<void> login(LoginReq loginReq) {
|
||||
return _parse<void>(() => _request('api/web/login', method: 'POST', data: loginReq.toJson()), null);
|
||||
}
|
||||
|
||||
Future<Response<String>> proxyRequest<T>(String uri, {dynamic data, Options? options}) async {
|
||||
initDefault();
|
||||
return _transport!.proxyRequest(uri, data: data, options: options);
|
||||
}
|
||||
|
||||
String join(String path) {
|
||||
initDefault();
|
||||
return _transport!.join(path);
|
||||
@@ -226,3 +221,16 @@ Future<dynamic> forward(
|
||||
}) async {
|
||||
return _request(path, method: method, data: data, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
Future<void> triggerProtocolJob(String protocol, String name) => _parse<void>(
|
||||
() => _request(
|
||||
'api/v1/protocols/${Uri.encodeComponent(protocol)}/jobs/${Uri.encodeComponent(name)}/trigger',
|
||||
method: 'POST',
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
Future<List<String>> getMirrorCandidates(String provider, String url) => _parse(
|
||||
() => _request('api/v1/mirrors/${Uri.encodeComponent(provider)}/candidates', method: 'POST', data: {'url': url}),
|
||||
(json) => List<String>.from(json['urls'] as List),
|
||||
);
|
||||
@@ -1,27 +1,18 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api.dart';
|
||||
import 'model/store_extension.dart';
|
||||
|
||||
class GopeedSiteApi {
|
||||
GopeedSiteApi._();
|
||||
GopeedSiteApi({Dio? client})
|
||||
: _client =
|
||||
client ??
|
||||
Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10)));
|
||||
|
||||
static final instance = GopeedSiteApi._();
|
||||
|
||||
static const _host = 'gopeed.com';
|
||||
static final instance = GopeedSiteApi();
|
||||
final Dio _client;
|
||||
final _storePages = <String, ({StoreExtensionPage page, DateTime expires})>{};
|
||||
final _storeRequests = <String, Future<StoreExtensionPage>>{};
|
||||
|
||||
Future<List<dynamic>> getReleases({int perPage = 10}) async {
|
||||
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
||||
if (json is! List<dynamic>) {
|
||||
throw const FormatException('Invalid releases response');
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
Future<StoreExtensionPage> getExtensions({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
@@ -31,14 +22,14 @@ class GopeedSiteApi {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final params = {
|
||||
'view': 'summary',
|
||||
'page': page.toString(),
|
||||
'limit': limit.clamp(1, 100).toString(),
|
||||
'sort': sort.name,
|
||||
'order': order.name,
|
||||
'view': 'summary',
|
||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||
};
|
||||
final key = Uri.https(_host, '/api/extensions', params).toString();
|
||||
final key = Uri(queryParameters: params).toString();
|
||||
final now = DateTime.now();
|
||||
_storePages.removeWhere((_, entry) => !now.isBefore(entry.expires));
|
||||
final cached = _storePages[key];
|
||||
@@ -58,40 +49,34 @@ class GopeedSiteApi {
|
||||
}
|
||||
|
||||
Future<StoreExtensionPage> _fetchStorePage(Map<String, String> params) async {
|
||||
final json = await _getJson('/api/extensions', queryParameters: params);
|
||||
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||
final response = await _client.get<Map<String, dynamic>>(
|
||||
'https://gopeed.com/api/extensions',
|
||||
queryParameters: params,
|
||||
);
|
||||
return StoreExtensionPage.fromJson(response.data!);
|
||||
}
|
||||
|
||||
Future<StoreExtension> getExtension(String id, {String? version}) async {
|
||||
final json = await _getJsonUri(
|
||||
Uri(
|
||||
scheme: 'https',
|
||||
host: _host,
|
||||
pathSegments: ['api', 'extensions', id],
|
||||
queryParameters: {if (version != null && version.isNotEmpty) 'version': version},
|
||||
),
|
||||
final uri = Uri(
|
||||
scheme: 'https',
|
||||
host: 'gopeed.com',
|
||||
pathSegments: ['api', 'extensions', id],
|
||||
queryParameters: {'version': ?version},
|
||||
);
|
||||
return StoreExtension.fromJson(json as Map<String, dynamic>);
|
||||
final response = await _client.get<Map<String, dynamic>>(uri.toString());
|
||||
return StoreExtension.fromJson(response.data!);
|
||||
}
|
||||
|
||||
Future<dynamic> getReleases() async {
|
||||
final response = await _client.get<dynamic>(
|
||||
'https://gopeed.com/api/releases',
|
||||
queryParameters: {'per_page': 10},
|
||||
options: Options(followRedirects: false),
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Future<void> reportExtensionInstall(String id) async {
|
||||
final uri = Uri.https(_host, '/api/extensions/install');
|
||||
await proxyRequest(
|
||||
uri.toString(),
|
||||
data: {'id': id},
|
||||
options: Options(method: 'POST', contentType: Headers.jsonContentType),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
|
||||
return _getJsonUri(Uri.https(_host, path, queryParameters));
|
||||
}
|
||||
|
||||
Future<dynamic> _getJsonUri(Uri uri) async {
|
||||
final Response<String> response = await proxyRequest(uri.toString());
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw Exception('Empty response from $uri');
|
||||
}
|
||||
return jsonDecode(response.data!);
|
||||
await _client.post<void>('https://gopeed.com/api/extensions/install', data: {'id': id});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ class DownloaderConfig {
|
||||
int maxRunning;
|
||||
ProtocolConfig protocolConfig = ProtocolConfig();
|
||||
ExtraConfig extra = ExtraConfig();
|
||||
GithubMirrorConfig githubMirror = GithubMirrorConfig();
|
||||
ProxyConfig proxy = ProxyConfig();
|
||||
WebhookConfig webhook = WebhookConfig();
|
||||
ScriptConfig script = ScriptConfig();
|
||||
@@ -98,10 +99,43 @@ class HttpConfig {
|
||||
Map<String, dynamic> toJson() => _$HttpConfigToJson(this);
|
||||
}
|
||||
|
||||
DateTime? _trackerUpdateTimeFromJson(String? value) =>
|
||||
value == null || value.startsWith('0001-') ? null : DateTime.parse(value);
|
||||
|
||||
@JsonSerializable()
|
||||
class TrackerSubscriptionStatus {
|
||||
final bool success;
|
||||
final int trackerCount;
|
||||
final DateTime lastFetchTime;
|
||||
|
||||
TrackerSubscriptionStatus({required this.success, required this.trackerCount, required this.lastFetchTime});
|
||||
|
||||
factory TrackerSubscriptionStatus.fromJson(Map<String, dynamic> json) => _$TrackerSubscriptionStatusFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TrackerSubscriptionStatusToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class BtState {
|
||||
Map<String, List<String>> trackerSources;
|
||||
Map<String, TrackerSubscriptionStatus> trackerSubscriptionStatuses;
|
||||
@JsonKey(fromJson: _trackerUpdateTimeFromJson)
|
||||
DateTime? lastTrackerUpdateTime;
|
||||
|
||||
BtState({this.trackerSources = const {}, this.trackerSubscriptionStatuses = const {}, this.lastTrackerUpdateTime});
|
||||
|
||||
factory BtState.fromJson(Map<String, dynamic>? json) => json == null ? BtState() : _$BtStateFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$BtStateToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class BtConfig {
|
||||
BtState state = BtState();
|
||||
List<String> trackerSubscribeUrls;
|
||||
bool autoUpdateTrackers;
|
||||
List<String> customTrackers;
|
||||
int listenPort;
|
||||
List<String> trackers;
|
||||
bool seedKeep;
|
||||
double seedRatio;
|
||||
int seedTime;
|
||||
@@ -109,8 +143,10 @@ class BtConfig {
|
||||
bool disablePex;
|
||||
|
||||
BtConfig({
|
||||
this.trackerSubscribeUrls = const [],
|
||||
this.autoUpdateTrackers = true,
|
||||
this.customTrackers = const [],
|
||||
this.listenPort = 0,
|
||||
this.trackers = const [],
|
||||
this.seedKeep = false,
|
||||
this.seedRatio = 0,
|
||||
this.seedTime = 0,
|
||||
@@ -158,9 +194,6 @@ class ExtraConfig {
|
||||
String analyticsClientId;
|
||||
List<DownloadCategory> downloadCategories;
|
||||
|
||||
ExtraConfigBt bt = ExtraConfigBt();
|
||||
ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();
|
||||
|
||||
ExtraConfig({
|
||||
this.themeMode = '',
|
||||
this.themeColor = 'green',
|
||||
@@ -271,22 +304,6 @@ class ProxyConfig {
|
||||
Map<String, dynamic> toJson() => _$ProxyConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ExtraConfigBt {
|
||||
List<String> trackerSubscribeUrls = [];
|
||||
List<String> subscribeTrackers = [];
|
||||
bool autoUpdateTrackers = true;
|
||||
DateTime? lastTrackerUpdateTime;
|
||||
|
||||
List<String> customTrackers = [];
|
||||
|
||||
ExtraConfigBt();
|
||||
|
||||
factory ExtraConfigBt.fromJson(Map<String, dynamic> json) => _$ExtraConfigBtFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
|
||||
}
|
||||
|
||||
enum GithubMirrorType { jsdelivr, ghProxy }
|
||||
|
||||
@JsonSerializable()
|
||||
@@ -304,16 +321,16 @@ class GithubMirror {
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class ExtraConfigGithubMirror {
|
||||
class GithubMirrorConfig {
|
||||
bool enabled;
|
||||
List<GithubMirror> mirrors;
|
||||
|
||||
ExtraConfigGithubMirror({this.enabled = true, this.mirrors = const []});
|
||||
GithubMirrorConfig({this.enabled = true, this.mirrors = const []});
|
||||
|
||||
factory ExtraConfigGithubMirror.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? ExtraConfigGithubMirror() : _$ExtraConfigGithubMirrorFromJson(json);
|
||||
factory GithubMirrorConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? GithubMirrorConfig() : _$GithubMirrorConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ExtraConfigGithubMirrorToJson(this);
|
||||
Map<String, dynamic> toJson() => _$GithubMirrorConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
|
||||
@@ -15,6 +15,7 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
||||
)
|
||||
..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map<String, dynamic>?)
|
||||
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
|
||||
..githubMirror = GithubMirrorConfig.fromJson(json['githubMirror'] as Map<String, dynamic>?)
|
||||
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
||||
..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
||||
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
|
||||
@@ -27,6 +28,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
|
||||
'maxRunning': instance.maxRunning,
|
||||
'protocolConfig': instance.protocolConfig.toJson(),
|
||||
'extra': instance.extra.toJson(),
|
||||
'githubMirror': instance.githubMirror.toJson(),
|
||||
'proxy': instance.proxy.toJson(),
|
||||
'webhook': instance.webhook.toJson(),
|
||||
'script': instance.script.toJson(),
|
||||
@@ -92,19 +94,56 @@ Map<String, dynamic> _$HttpConfigToJson(HttpConfig instance) => <String, dynamic
|
||||
'useServerCtime': instance.useServerCtime,
|
||||
};
|
||||
|
||||
TrackerSubscriptionStatus _$TrackerSubscriptionStatusFromJson(Map<String, dynamic> json) => TrackerSubscriptionStatus(
|
||||
success: json['success'] as bool,
|
||||
trackerCount: (json['trackerCount'] as num).toInt(),
|
||||
lastFetchTime: DateTime.parse(json['lastFetchTime'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackerSubscriptionStatusToJson(TrackerSubscriptionStatus instance) => <String, dynamic>{
|
||||
'success': instance.success,
|
||||
'trackerCount': instance.trackerCount,
|
||||
'lastFetchTime': instance.lastFetchTime.toIso8601String(),
|
||||
};
|
||||
|
||||
BtState _$BtStateFromJson(Map<String, dynamic> json) => BtState(
|
||||
trackerSources:
|
||||
(json['trackerSources'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, (e as List<dynamic>).map((e) => e as String).toList()),
|
||||
) ??
|
||||
const {},
|
||||
trackerSubscriptionStatuses:
|
||||
(json['trackerSubscriptionStatuses'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, TrackerSubscriptionStatus.fromJson(e as Map<String, dynamic>)),
|
||||
) ??
|
||||
const {},
|
||||
lastTrackerUpdateTime: _trackerUpdateTimeFromJson(json['lastTrackerUpdateTime'] as String?),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BtStateToJson(BtState instance) => <String, dynamic>{
|
||||
'trackerSources': instance.trackerSources,
|
||||
'trackerSubscriptionStatuses': instance.trackerSubscriptionStatuses.map((k, e) => MapEntry(k, e.toJson())),
|
||||
'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(),
|
||||
};
|
||||
|
||||
BtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig(
|
||||
trackerSubscribeUrls: (json['trackerSubscribeUrls'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
autoUpdateTrackers: json['autoUpdateTrackers'] as bool? ?? true,
|
||||
customTrackers: (json['customTrackers'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,
|
||||
trackers: (json['trackers'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
seedKeep: json['seedKeep'] as bool? ?? false,
|
||||
seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0,
|
||||
seedTime: (json['seedTime'] as num?)?.toInt() ?? 0,
|
||||
disableDht: json['disableDht'] as bool? ?? false,
|
||||
disablePex: json['disablePex'] as bool? ?? false,
|
||||
);
|
||||
)..state = BtState.fromJson(json['state'] as Map<String, dynamic>?);
|
||||
|
||||
Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
|
||||
'state': instance.state.toJson(),
|
||||
'trackerSubscribeUrls': instance.trackerSubscribeUrls,
|
||||
'autoUpdateTrackers': instance.autoUpdateTrackers,
|
||||
'customTrackers': instance.customTrackers,
|
||||
'listenPort': instance.listenPort,
|
||||
'trackers': instance.trackers,
|
||||
'seedKeep': instance.seedKeep,
|
||||
'seedRatio': instance.seedRatio,
|
||||
'seedTime': instance.seedTime,
|
||||
@@ -128,37 +167,31 @@ Map<String, dynamic> _$Ed2kConfigToJson(Ed2kConfig instance) => <String, dynamic
|
||||
'nodesDat': instance.nodesDat,
|
||||
};
|
||||
|
||||
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) =>
|
||||
ExtraConfig(
|
||||
themeMode: json['themeMode'] as String? ?? '',
|
||||
themeColor: json['themeColor'] as String? ?? 'green',
|
||||
locale: json['locale'] as String? ?? '',
|
||||
lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,
|
||||
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
|
||||
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
||||
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
||||
desktopNotification: json['desktopNotification'] as bool? ?? true,
|
||||
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false,
|
||||
backgroundContinuedProcessing:
|
||||
json['backgroundContinuedProcessing']
|
||||
as bool? ??
|
||||
false,
|
||||
windowState: json['windowState'] == null
|
||||
? null
|
||||
: WindowStateConfig.fromJson(json['windowState'] as Map<String, dynamic>?),
|
||||
bookmarks: (json['bookmarks'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)) ?? const {},
|
||||
createHistory: (json['createHistory'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
|
||||
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
|
||||
analyticsClientId: json['analyticsClientId'] as String? ?? '',
|
||||
downloadCategories:
|
||||
(json['downloadCategories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
)
|
||||
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)
|
||||
..githubMirror = ExtraConfigGithubMirror.fromJson(json['githubMirror'] as Map<String, dynamic>?);
|
||||
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
||||
themeMode: json['themeMode'] as String? ?? '',
|
||||
themeColor: json['themeColor'] as String? ?? 'green',
|
||||
locale: json['locale'] as String? ?? '',
|
||||
lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,
|
||||
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
|
||||
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
||||
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
||||
desktopNotification: json['desktopNotification'] as bool? ?? true,
|
||||
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false,
|
||||
backgroundContinuedProcessing: json['backgroundContinuedProcessing'] as bool? ?? false,
|
||||
windowState: json['windowState'] == null
|
||||
? null
|
||||
: WindowStateConfig.fromJson(json['windowState'] as Map<String, dynamic>?),
|
||||
bookmarks: (json['bookmarks'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)) ?? const {},
|
||||
createHistory: (json['createHistory'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
|
||||
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
|
||||
analyticsClientId: json['analyticsClientId'] as String? ?? '',
|
||||
downloadCategories:
|
||||
(json['downloadCategories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynamic>{
|
||||
'themeMode': instance.themeMode,
|
||||
@@ -170,8 +203,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
|
||||
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
|
||||
'desktopNotification': instance.desktopNotification,
|
||||
'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive,
|
||||
'backgroundContinuedProcessing':
|
||||
instance.backgroundContinuedProcessing,
|
||||
'backgroundContinuedProcessing': instance.backgroundContinuedProcessing,
|
||||
'windowState': instance.windowState.toJson(),
|
||||
'bookmarks': instance.bookmarks,
|
||||
'createHistory': instance.createHistory,
|
||||
@@ -179,8 +211,6 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
|
||||
'analyticsEnabled': instance.analyticsEnabled,
|
||||
'analyticsClientId': instance.analyticsClientId,
|
||||
'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
|
||||
'bt': instance.bt.toJson(),
|
||||
'githubMirror': instance.githubMirror.toJson(),
|
||||
};
|
||||
|
||||
WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
|
||||
@@ -249,23 +279,6 @@ Map<String, dynamic> _$ProxyConfigToJson(ProxyConfig instance) => <String, dynam
|
||||
'pwd': instance.pwd,
|
||||
};
|
||||
|
||||
ExtraConfigBt _$ExtraConfigBtFromJson(Map<String, dynamic> json) => ExtraConfigBt()
|
||||
..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List<dynamic>).map((e) => e as String).toList()
|
||||
..subscribeTrackers = (json['subscribeTrackers'] as List<dynamic>).map((e) => e as String).toList()
|
||||
..autoUpdateTrackers = json['autoUpdateTrackers'] as bool
|
||||
..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null
|
||||
? null
|
||||
: DateTime.parse(json['lastTrackerUpdateTime'] as String)
|
||||
..customTrackers = (json['customTrackers'] as List<dynamic>).map((e) => e as String).toList();
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) => <String, dynamic>{
|
||||
'trackerSubscribeUrls': instance.trackerSubscribeUrls,
|
||||
'subscribeTrackers': instance.subscribeTrackers,
|
||||
'autoUpdateTrackers': instance.autoUpdateTrackers,
|
||||
'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(),
|
||||
'customTrackers': instance.customTrackers,
|
||||
};
|
||||
|
||||
GithubMirror _$GithubMirrorFromJson(Map<String, dynamic> json) => GithubMirror(
|
||||
type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']),
|
||||
url: json['url'] as String,
|
||||
@@ -282,14 +295,14 @@ Map<String, dynamic> _$GithubMirrorToJson(GithubMirror instance) => <String, dyn
|
||||
|
||||
const _$GithubMirrorTypeEnumMap = {GithubMirrorType.jsdelivr: 'jsdelivr', GithubMirrorType.ghProxy: 'ghProxy'};
|
||||
|
||||
ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(Map<String, dynamic> json) => ExtraConfigGithubMirror(
|
||||
GithubMirrorConfig _$GithubMirrorConfigFromJson(Map<String, dynamic> json) => GithubMirrorConfig(
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
mirrors:
|
||||
(json['mirrors'] as List<dynamic>?)?.map((e) => GithubMirror.fromJson(e as Map<String, dynamic>)).toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigGithubMirrorToJson(ExtraConfigGithubMirror instance) => <String, dynamic>{
|
||||
Map<String, dynamic> _$GithubMirrorConfigToJson(GithubMirrorConfig instance) => <String, dynamic>{
|
||||
'enabled': instance.enabled,
|
||||
'mirrors': instance.mirrors.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
@@ -317,12 +317,7 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
|
||||
}
|
||||
|
||||
Future<void> startUpdate(VersionInfo versionInfo, UpdateProgressCallback onProgress) async {
|
||||
final runtime = ref.read(appRuntimeControllerProvider).value;
|
||||
await updateApp(
|
||||
versionInfo,
|
||||
githubMirror: runtime?.downloaderConfig.extra.githubMirror ?? ExtraConfigGithubMirror(enabled: false),
|
||||
onProgress: onProgress,
|
||||
);
|
||||
await updateApp(versionInfo, service: ref.read(gopeedServiceProvider), onProgress: onProgress);
|
||||
}
|
||||
|
||||
AppPlatformState _snapshot() => AppPlatformState(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
@@ -106,14 +105,9 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
),
|
||||
);
|
||||
unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive));
|
||||
unawaited(
|
||||
ContinuedProcessing.setEnabled(
|
||||
config.extra.backgroundContinuedProcessing,
|
||||
),
|
||||
);
|
||||
unawaited(ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing));
|
||||
}
|
||||
|
||||
|
||||
Future<AppRuntimeState> _init() async {
|
||||
await AppInitializer.ensureStorageInitialized();
|
||||
try {
|
||||
@@ -175,7 +169,6 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
..address = config.api.address
|
||||
..apiToken = config.api.token;
|
||||
}
|
||||
unawaited(_initTrackerUpdate(config));
|
||||
unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale)));
|
||||
await _initAnalytics(config);
|
||||
|
||||
@@ -190,9 +183,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
// Make sure the native Continued Processing manager has the
|
||||
// persisted setting before app initialization completes. This
|
||||
// avoids the first download racing ahead of setEnabled(true).
|
||||
await ContinuedProcessing.setEnabled(
|
||||
config.extra.backgroundContinuedProcessing,
|
||||
);
|
||||
await ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing);
|
||||
|
||||
LocationKeepAliveCoordinator.instance.start(
|
||||
() =>
|
||||
@@ -271,9 +262,6 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
if (extra.themeColor.isEmpty) {
|
||||
extra.themeColor = 'green';
|
||||
}
|
||||
if (extra.bt.trackerSubscribeUrls.isEmpty) {
|
||||
extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
|
||||
}
|
||||
if (config.proxy.scheme.isEmpty) {
|
||||
config.proxy.scheme = 'http';
|
||||
}
|
||||
@@ -308,26 +296,6 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
),
|
||||
];
|
||||
}
|
||||
if (extra.githubMirror.mirrors.isEmpty) {
|
||||
extra.githubMirror.mirrors = [
|
||||
GithubMirror(type: GithubMirrorType.jsdelivr, url: 'https://fastly.jsdelivr.net/gh', isBuiltIn: true),
|
||||
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://fastgit.cc', isBuiltIn: true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initTrackerUpdate(DownloaderConfig config) async {
|
||||
final btExtra = config.extra.bt;
|
||||
final lastUpdateTime = btExtra.lastTrackerUpdateTime;
|
||||
if (!btExtra.autoUpdateTrackers ||
|
||||
(lastUpdateTime != null && !lastUpdateTime.isBefore(DateTime.now().subtract(const Duration(days: 1))))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateTrackers(config);
|
||||
} catch (error, stackTrace) {
|
||||
logger.w('tracker update failed', error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initAnalytics(DownloaderConfig config) async {
|
||||
@@ -344,32 +312,12 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateTrackers(DownloaderConfig config) async {
|
||||
final btExtra = config.extra.bt;
|
||||
final trackers = <String>[];
|
||||
for (final url in btExtra.trackerSubscribeUrls) {
|
||||
final resp = await api.proxyRequest(url);
|
||||
final data = resp.data;
|
||||
if (resp.statusCode == 200 && data != null && data.isNotEmpty) {
|
||||
trackers.addAll(const LineSplitter().convert(data).where((line) => line.trim().isNotEmpty));
|
||||
}
|
||||
}
|
||||
btExtra.subscribeTrackers = trackers;
|
||||
btExtra.lastTrackerUpdateTime = DateTime.now();
|
||||
config.protocolConfig.bt.trackers = {...btExtra.subscribeTrackers, ...btExtra.customTrackers}.toList();
|
||||
await ref.read(gopeedServiceProvider).putConfig(config);
|
||||
|
||||
final current = state.value;
|
||||
if (current != null) {
|
||||
state = AsyncValue.data(
|
||||
AppRuntimeState(
|
||||
startConfig: current.startConfig,
|
||||
apiServerState: current.apiServerState,
|
||||
downloaderConfig: config,
|
||||
localBackendStarted: current.localBackendStarted,
|
||||
startupError: current.startupError,
|
||||
),
|
||||
);
|
||||
Future<void> updateTrackers() async {
|
||||
final service = ref.read(gopeedServiceProvider);
|
||||
try {
|
||||
await service.triggerProtocolJob('bt', 'update-trackers');
|
||||
} finally {
|
||||
replaceDownloaderConfig(await service.getConfig());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,17 +457,3 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
|
||||
const allTrackerSubscribeUrls = [
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt',
|
||||
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt',
|
||||
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt',
|
||||
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt',
|
||||
];
|
||||
@@ -82,6 +82,14 @@ RpcCodecRegistry createAppCapabilityCodecs() {
|
||||
|
||||
void _bindGopeed(CapabilityRegistry registry) {
|
||||
registry
|
||||
..bind(GopeedMethods.triggerProtocolJob, (p) async {
|
||||
await api.triggerProtocolJob(p['protocol'] as String, p['name'] as String);
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(
|
||||
GopeedMethods.getMirrorCandidates,
|
||||
(p) => api.getMirrorCandidates(p['provider'] as String, p['url'] as String),
|
||||
)
|
||||
..bind(GopeedMethods.resolve, api.resolve)
|
||||
..bind(GopeedMethods.createTask, api.createTask)
|
||||
..bind(GopeedMethods.createTaskBatch, api.createTaskBatch)
|
||||
|
||||
@@ -13,6 +13,8 @@ import '../../api/model/update_extension_settings.dart';
|
||||
import 'capability_rpc.dart';
|
||||
|
||||
abstract final class GopeedMethods {
|
||||
static const triggerProtocolJob = RpcMethod<Map<String, dynamic>, RpcUnit>('gopeed.protocol.job.trigger');
|
||||
static const getMirrorCandidates = RpcMethod<Map<String, dynamic>, List<String>>('gopeed.mirrors.candidates');
|
||||
static const resolve = RpcMethod<ResolveTask, ResolveResult>('gopeed.resolve');
|
||||
static const createTask = RpcMethod<CreateTask, String>('gopeed.task.create');
|
||||
static const createTaskBatch = RpcMethod<CreateTaskBatch, List<String>>('gopeed.task.createBatch');
|
||||
@@ -43,6 +45,10 @@ class GopeedService {
|
||||
|
||||
final CapabilityInvoker _invoker;
|
||||
|
||||
Future<void> triggerProtocolJob(String protocol, String name) async =>
|
||||
_invoker.invoke(GopeedMethods.triggerProtocolJob, {'protocol': protocol, 'name': name});
|
||||
Future<List<String>> getMirrorCandidates(String provider, String url) =>
|
||||
_invoker.invoke(GopeedMethods.getMirrorCandidates, {'provider': provider, 'url': url});
|
||||
Future<ResolveResult> resolve(ResolveTask request) => _invoker.invoke(GopeedMethods.resolve, request);
|
||||
|
||||
Future<String> createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:pub_semver/pub_semver.dart';
|
||||
|
||||
import '../../api/gopeed_site_api.dart';
|
||||
|
||||
class VersionInfo {
|
||||
const VersionInfo({
|
||||
required this.version,
|
||||
required this.changeLog,
|
||||
required this.releaseUrl,
|
||||
this.assetSha256 = const {},
|
||||
});
|
||||
|
||||
final String version;
|
||||
final String changeLog;
|
||||
final String releaseUrl;
|
||||
// Populated only from official release metadata, never from a download mirror.
|
||||
final Map<String, String> assetSha256;
|
||||
}
|
||||
|
||||
/// Query the official GitHub API directly, then the independent website API
|
||||
/// only if the GitHub request or its response validation fails.
|
||||
class AppUpdateClient {
|
||||
AppUpdateClient({Dio? client, GopeedSiteApi? site})
|
||||
: _client =
|
||||
client ??
|
||||
Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30))),
|
||||
_site = site ?? GopeedSiteApi.instance;
|
||||
|
||||
static const releasesUrl = 'https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10';
|
||||
final Dio _client;
|
||||
final GopeedSiteApi _site;
|
||||
|
||||
Future<List<_Release>> _fetchGithubReleases() async {
|
||||
final cancel = CancelToken();
|
||||
try {
|
||||
final response = await _client
|
||||
.get<dynamic>(releasesUrl, cancelToken: cancel, options: Options(followRedirects: false))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
return _decodeReleases(response.data);
|
||||
} finally {
|
||||
cancel.cancel('GitHub release request completed');
|
||||
}
|
||||
}
|
||||
|
||||
Future<VersionInfo?> check(String currentVersion) async {
|
||||
final current = _parseVersion(currentVersion);
|
||||
if (current == null) throw FormatException('Invalid current application version', currentVersion);
|
||||
List<_Release> releases;
|
||||
try {
|
||||
releases = await _fetchGithubReleases();
|
||||
} catch (_) {
|
||||
releases = _decodeReleases(await _site.getReleases());
|
||||
}
|
||||
_Release? selected;
|
||||
for (final release in releases) {
|
||||
final version = release.version;
|
||||
if (release.draft || version.compareTo(current) <= 0) continue;
|
||||
final preview = release.prerelease || version.isPreRelease;
|
||||
if (!current.isPreRelease && preview) continue;
|
||||
if (current.isPreRelease &&
|
||||
preview &&
|
||||
(version.major != current.major || version.minor != current.minor || version.patch != current.patch)) {
|
||||
continue;
|
||||
}
|
||||
if (selected == null || version.compareTo(selected.version) > 0) selected = release;
|
||||
}
|
||||
if (selected == null) return null;
|
||||
return VersionInfo(
|
||||
version: selected.tag.replaceFirst(RegExp(r'^v'), ''),
|
||||
changeLog: selected.body,
|
||||
assetSha256: Map.unmodifiable(selected.assetSha256),
|
||||
releaseUrl: selected.url.isEmpty
|
||||
? 'https://github.com/GopeedLab/gopeed/releases/tag/${selected.tag}'
|
||||
: selected.url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build metadata does not affect SemVer precedence.
|
||||
Version? _parseVersion(String raw) {
|
||||
try {
|
||||
final version = Version.parse(raw.replaceFirst(RegExp(r'^v'), ''));
|
||||
return Version(version.major, version.minor, version.patch, pre: version.preRelease.join('.'));
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<_Release> _decodeReleases(dynamic data) {
|
||||
if (data is! List) throw const FormatException('Invalid release list');
|
||||
final releases = <_Release>[];
|
||||
for (final entry in data) {
|
||||
if (entry is! Map || entry['tag_name'] is! String) continue;
|
||||
final tag = entry['tag_name'] as String;
|
||||
final version = _parseVersion(tag);
|
||||
if (version == null) continue;
|
||||
releases.add(
|
||||
_Release(
|
||||
tag: tag,
|
||||
version: version,
|
||||
draft: entry['draft'] == true,
|
||||
prerelease: entry['prerelease'] == true,
|
||||
body: entry['body'] as String? ?? '',
|
||||
url: entry['html_url'] as String? ?? '',
|
||||
assetSha256: _decodeAssetSha256(entry['assets']),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.isNotEmpty && releases.isEmpty) throw const FormatException('Response contains no valid releases');
|
||||
return releases;
|
||||
}
|
||||
|
||||
Map<String, String> _decodeAssetSha256(dynamic assets) {
|
||||
if (assets == null) return {};
|
||||
if (assets is! List) throw const FormatException('Invalid release assets');
|
||||
final result = <String, String>{};
|
||||
for (final asset in assets) {
|
||||
if (asset is! Map || asset['name'] is! String || asset['digest'] is! String) continue;
|
||||
final digest = asset['digest'] as String;
|
||||
final match = RegExp(r'^sha256:([a-fA-F0-9]{64})$').firstMatch(digest);
|
||||
if (match == null || match.end != digest.length) continue;
|
||||
final name = asset['name'] as String;
|
||||
if (result.containsKey(name)) throw const FormatException('Duplicate release asset');
|
||||
result[name] = match.group(1)!.toLowerCase();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class _Release {
|
||||
const _Release({
|
||||
required this.tag,
|
||||
required this.version,
|
||||
required this.draft,
|
||||
required this.prerelease,
|
||||
required this.body,
|
||||
required this.url,
|
||||
required this.assetSha256,
|
||||
});
|
||||
final String tag;
|
||||
final Version version;
|
||||
final bool draft;
|
||||
final bool prerelease;
|
||||
final String body;
|
||||
final String url;
|
||||
final Map<String, String> assetSha256;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class DownloadChecksumException implements Exception {
|
||||
const DownloadChecksumException();
|
||||
@override
|
||||
String toString() => 'Update package checksum verification failed';
|
||||
}
|
||||
|
||||
/// Probes backend-provided URLs concurrently, then downloads the fastest usable
|
||||
/// candidate. Other candidates remain available if HEAD is unsupported or the
|
||||
/// actual download fails. This layer has no GitHub URL or mirror-config rules.
|
||||
Future<void> downloadFromCandidates(
|
||||
List<String> candidates,
|
||||
String savePath, {
|
||||
required ProgressCallback onReceiveProgress,
|
||||
required String expectedSha256,
|
||||
Dio? client,
|
||||
Duration probeTimeout = const Duration(seconds: 3),
|
||||
}) async {
|
||||
// Reject missing or malformed trusted metadata before any network request.
|
||||
if (expectedSha256.length != 64 || !RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(expectedSha256)) {
|
||||
throw const DownloadChecksumException();
|
||||
}
|
||||
final urls = candidates.toSet().toList(growable: false);
|
||||
if (urls.isEmpty) throw ArgumentError.value(candidates, 'candidates', 'No download candidates');
|
||||
final dio = client ?? Dio(BaseOptions(connectTimeout: const Duration(seconds: 10)));
|
||||
try {
|
||||
final probes = await Future.wait(
|
||||
urls.map((url) async {
|
||||
final watch = Stopwatch()..start();
|
||||
final cancel = CancelToken();
|
||||
try {
|
||||
final response = await dio
|
||||
.head<void>(
|
||||
url,
|
||||
cancelToken: cancel,
|
||||
options: Options(receiveTimeout: probeTimeout, validateStatus: (status) => status == 200),
|
||||
)
|
||||
.timeout(probeTimeout);
|
||||
if (_isPackageResponse(response.headers)) return (url: url, latency: watch.elapsed);
|
||||
} catch (_) {
|
||||
// HEAD failure does not prove that a normal GET will fail.
|
||||
} finally {
|
||||
cancel.cancel();
|
||||
}
|
||||
return (url: url, latency: null);
|
||||
}),
|
||||
);
|
||||
final available = probes.where((probe) => probe.latency != null).toList()
|
||||
..sort((a, b) => a.latency!.compareTo(b.latency!));
|
||||
final ordered = [
|
||||
...available.map((probe) => probe.url),
|
||||
...probes.where((p) => p.latency == null).map((p) => p.url),
|
||||
];
|
||||
Object? lastError;
|
||||
StackTrace? lastStack;
|
||||
for (final url in ordered) {
|
||||
try {
|
||||
onReceiveProgress(0, 0);
|
||||
final response = await dio.download(
|
||||
url,
|
||||
savePath,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
deleteOnError: true,
|
||||
options: Options(receiveTimeout: const Duration(seconds: 30), validateStatus: (status) => status == 200),
|
||||
);
|
||||
if (!_isPackageResponse(response.headers) || await File(savePath).length() == 0) {
|
||||
throw StateError('Invalid download response from $url');
|
||||
}
|
||||
final actual = await sha256.bind(File(savePath).openRead()).first;
|
||||
if (actual.toString() != expectedSha256.toLowerCase()) throw const DownloadChecksumException();
|
||||
return;
|
||||
} catch (error, stack) {
|
||||
lastError = error;
|
||||
lastStack = stack;
|
||||
final file = File(savePath);
|
||||
if (await file.exists()) await file.delete();
|
||||
}
|
||||
}
|
||||
Error.throwWithStackTrace(lastError!, lastStack!);
|
||||
} finally {
|
||||
if (client == null) dio.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isPackageResponse(Headers headers) {
|
||||
final contentType = headers.value(Headers.contentTypeHeader)?.split(';').first.trim().toLowerCase();
|
||||
return contentType != 'text/html' && contentType != 'application/xhtml+xml' && contentType != 'application/json';
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../util/util.dart';
|
||||
import '../../../ffi/libgopeed_worker.dart';
|
||||
import '../../../libgopeed_boot.dart';
|
||||
@@ -10,17 +8,6 @@ import '../gopeed_transport.dart';
|
||||
GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport();
|
||||
|
||||
class NativeGopeedTransport implements GopeedTransport {
|
||||
NativeGopeedTransport()
|
||||
: _externalDio = Dio(
|
||||
BaseOptions(
|
||||
contentType: Headers.jsonContentType,
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
),
|
||||
);
|
||||
|
||||
final Dio _externalDio;
|
||||
final LibgopeedWorker? _ffiWorker = Util.isDesktop() ? LibgopeedWorker() : null;
|
||||
|
||||
@override
|
||||
@@ -45,15 +32,6 @@ class NativeGopeedTransport implements GopeedTransport {
|
||||
return jsonDecode(payload);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||
return _externalDio.request<String>(
|
||||
uri,
|
||||
data: data,
|
||||
options: (options ?? Options()).copyWith(responseType: ResponseType.plain),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ class RestGopeedTransport implements GopeedTransport {
|
||||
RestGopeedTransport(GopeedTransportConfig config)
|
||||
: _dio = Dio(
|
||||
BaseOptions(
|
||||
// Keep release requests relative so deployments under a reverse
|
||||
// proxy path continue to work, matching the legacy Web client.
|
||||
// Keep requests relative for deployments under a reverse proxy path.
|
||||
baseUrl: webTransportBaseUrl(config.address, Uri.base),
|
||||
contentType: Headers.jsonContentType,
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
@@ -53,18 +52,6 @@ class RestGopeedTransport implements GopeedTransport {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||
options ??= Options();
|
||||
options.headers ??= {};
|
||||
options.headers!['X-Target-Uri'] = uri;
|
||||
return _dio.request<String>(
|
||||
'/api/web/proxy?t=${DateTime.now().millisecondsSinceEpoch}',
|
||||
data: data,
|
||||
options: options.copyWith(responseType: ResponseType.plain),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) {
|
||||
final baseUrl = _dio.options.baseUrl;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'entry/gopeed_transport_stub.dart'
|
||||
@@ -60,8 +59,6 @@ String normalizeWebApiBaseUrl(String address, Uri pageUri) {
|
||||
abstract interface class GopeedTransport {
|
||||
Future<dynamic> request(String path, {String method = 'GET', dynamic data, Map<String, dynamic>? queryParameters});
|
||||
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options});
|
||||
|
||||
String join(String path);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../capabilities/gopeed_capability.dart';
|
||||
|
||||
/// Mirror requests use backend-generated candidates, without forwarding the
|
||||
/// Downloader's authentication headers to resource providers or their mirrors.
|
||||
class MirrorClient {
|
||||
MirrorClient({
|
||||
required GopeedService service,
|
||||
required this.provider,
|
||||
Dio? client,
|
||||
this.timeout = const Duration(seconds: 30),
|
||||
}) : _service = service,
|
||||
_client = client ?? Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: timeout));
|
||||
|
||||
final String provider;
|
||||
final GopeedService _service;
|
||||
final Dio _client;
|
||||
final Duration timeout;
|
||||
|
||||
/// The first complete response accepted by [decode] wins. HTTP 200 alone is
|
||||
/// insufficient: a mirror's HTML error page must not hide a usable response.
|
||||
Future<T> getJson<T>(String url, {required T Function(dynamic) decode}) async {
|
||||
final urls = (await _service.getMirrorCandidates(provider, url)).toSet();
|
||||
if (urls.isEmpty) throw StateError('No mirror candidates');
|
||||
final result = Completer<T>();
|
||||
final tokens = <CancelToken>[];
|
||||
var pending = urls.length;
|
||||
Future<void> attempt(String candidate, CancelToken token) async {
|
||||
try {
|
||||
final response = await _client
|
||||
.get<dynamic>(candidate, cancelToken: token)
|
||||
.timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
token.cancel('Mirror candidate timed out');
|
||||
throw TimeoutException('Mirror candidate timed out', timeout);
|
||||
},
|
||||
);
|
||||
final value = decode(response.data);
|
||||
if (!result.isCompleted) result.complete(value);
|
||||
} catch (error, stack) {
|
||||
if (--pending == 0 && !result.isCompleted) result.completeError(error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
for (final candidate in urls) {
|
||||
final token = CancelToken();
|
||||
tokens.add(token);
|
||||
unawaited(attempt(candidate, token));
|
||||
}
|
||||
try {
|
||||
return await result.future;
|
||||
} finally {
|
||||
for (final token in tokens) {
|
||||
token.cancel('Mirror candidate race completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,29 +20,55 @@ class SettingsState {
|
||||
}
|
||||
|
||||
class SettingsController extends AsyncNotifier<SettingsState> {
|
||||
Future<void> _saveTail = Future<void>.value();
|
||||
int _saveRevision = 0;
|
||||
|
||||
@override
|
||||
Future<SettingsState> build() async {
|
||||
await ref.read(appRuntimeControllerProvider.future);
|
||||
return SettingsState(config: await ref.read(gopeedServiceProvider).getConfig());
|
||||
final config = await ref.read(gopeedServiceProvider).getConfig();
|
||||
return SettingsState(config: config);
|
||||
}
|
||||
|
||||
Future<void> reload({bool showLoading = true}) async {
|
||||
if (showLoading) state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(SettingsState(config: await ref.read(gopeedServiceProvider).getConfig()));
|
||||
final config = await ref.read(gopeedServiceProvider).getConfig();
|
||||
state = AsyncValue.data(SettingsState(config: config));
|
||||
}
|
||||
|
||||
Future<void> save(DownloaderConfig config) async {
|
||||
Future<void> save(DownloaderConfig config) {
|
||||
final snapshot = DownloaderConfig.fromJson(config.toJson());
|
||||
final revision = ++_saveRevision;
|
||||
state = AsyncValue.data(SettingsState(config: config, saving: true));
|
||||
final latest = await ref.read(gopeedServiceProvider).getConfig();
|
||||
config.extra
|
||||
..windowState = latest.extra.windowState
|
||||
..bookmarks = latest.extra.bookmarks
|
||||
..createHistory = latest.extra.createHistory
|
||||
..runAsMenubarApp = latest.extra.runAsMenubarApp
|
||||
..analyticsClientId = latest.extra.analyticsClientId;
|
||||
await ref.read(gopeedServiceProvider).putConfig(config);
|
||||
ref.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(config);
|
||||
state = AsyncValue.data(SettingsState(config: config));
|
||||
final keepAlive = ref.keepAlive();
|
||||
final result = _saveTail.then((_) => _save(snapshot, revision)).whenComplete(keepAlive.close);
|
||||
// A failed save must not prevent subsequent edits or retries from running.
|
||||
_saveTail = result.then((_) {}, onError: (Object _, StackTrace _) {});
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _save(DownloaderConfig config, int revision) async {
|
||||
try {
|
||||
final service = ref.read(gopeedServiceProvider);
|
||||
final latest = await service.getConfig();
|
||||
config.extra
|
||||
..windowState = latest.extra.windowState
|
||||
..bookmarks = latest.extra.bookmarks
|
||||
..createHistory = latest.extra.createHistory
|
||||
..runAsMenubarApp = latest.extra.runAsMenubarApp
|
||||
..analyticsClientId = latest.extra.analyticsClientId;
|
||||
// The backend preserves protocol-owned results and validates the whole PUT.
|
||||
await service.putConfig(config);
|
||||
final saved = await service.getConfig();
|
||||
if (ref.mounted) {
|
||||
ref.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(saved);
|
||||
if (revision == _saveRevision) state = AsyncValue.data(SettingsState(config: saved));
|
||||
}
|
||||
} finally {
|
||||
if (ref.mounted && revision == _saveRevision && state.value?.saving == true) {
|
||||
state = AsyncValue.data(state.requireValue.copyWith(saving: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testWebhook(String url) async {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@ import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_loading_button.dart';
|
||||
import '../../../../util/package_info.dart';
|
||||
import '../../../../util/updater.dart';
|
||||
import '../../../../core/network/candidate_download.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import 'app_update_notes_viewport.dart';
|
||||
@@ -57,7 +58,9 @@ Future<void> showAppUpdateDialog(
|
||||
if (!dialogContext.mounted) return;
|
||||
setDialogState(() {
|
||||
updating = false;
|
||||
errorMessage = dialogContext.l10n.updateFailed;
|
||||
errorMessage = error is DownloadChecksumException
|
||||
? dialogContext.l10n.updateVerificationFailed
|
||||
: dialogContext.l10n.updateFailed;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -137,7 +140,13 @@ Future<void> showAppUpdateDialog(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 15, color: palette.error),
|
||||
const SizedBox(width: 6),
|
||||
Text(errorMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: palette.error, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -12,18 +12,20 @@ class SettingsItem extends StatelessWidget {
|
||||
this.subtitle,
|
||||
this.subtitleAction,
|
||||
this.titleTrailing,
|
||||
this.stacked = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? subtitleAction;
|
||||
final Widget? titleTrailing;
|
||||
final bool stacked;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
|
||||
if (desktop) {
|
||||
if (desktop && !stacked) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const leadingSpace = 2.0;
|
||||
|
||||
@@ -105,7 +105,7 @@ class SettingsListEditor extends StatelessWidget {
|
||||
children: [
|
||||
const Icon(Icons.add, size: 17),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
Text(addLabel ?? context.l10n.add),
|
||||
Flexible(child: Text(addLabel ?? context.l10n.add)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../../../api/model/downloader_config.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_button.dart';
|
||||
import 'settings_list_editor.dart';
|
||||
import 'tracker_subscriptions_schedule.dart';
|
||||
import 'tracker_subscriptions_table.dart';
|
||||
|
||||
class TrackerSubscriptionsControl extends StatefulWidget {
|
||||
const TrackerSubscriptionsControl({
|
||||
super.key,
|
||||
required this.urls,
|
||||
required this.statuses,
|
||||
required this.autoUpdate,
|
||||
required this.lastUpdated,
|
||||
required this.onChanged,
|
||||
required this.onAutoUpdateChanged,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
final List<String> urls;
|
||||
final Map<String, TrackerSubscriptionStatus> statuses;
|
||||
final bool autoUpdate;
|
||||
final DateTime? lastUpdated;
|
||||
final ValueChanged<List<String>> onChanged;
|
||||
final ValueChanged<bool> onAutoUpdateChanged;
|
||||
final Future<void> Function() onUpdate;
|
||||
|
||||
@override
|
||||
State<TrackerSubscriptionsControl> createState() => _TrackerSubscriptionsControlState();
|
||||
}
|
||||
|
||||
class _TrackerSubscriptionsControlState extends State<TrackerSubscriptionsControl> {
|
||||
bool _updating = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TrackerSubscriptionsSchedule(
|
||||
lastUpdated: widget.lastUpdated,
|
||||
autoUpdate: widget.autoUpdate,
|
||||
onAutoUpdateChanged: widget.onAutoUpdateChanged,
|
||||
),
|
||||
const SizedBox(height: AppDesignTokens.space12),
|
||||
TrackerSubscriptionsTable(urls: widget.urls, statuses: widget.statuses, onDelete: _delete),
|
||||
const SizedBox(height: AppDesignTokens.space12),
|
||||
Wrap(
|
||||
key: const ValueKey('tracker-subscriptions-actions'),
|
||||
alignment: WrapAlignment.end,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: AppDesignTokens.space8,
|
||||
runSpacing: AppDesignTokens.space8,
|
||||
children: [
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('tracker-update-button'),
|
||||
onPressed: _update,
|
||||
loading: _updating,
|
||||
icon: const Icon(Icons.refresh, size: 17),
|
||||
child: Text(context.l10n.newVersionUpdate),
|
||||
),
|
||||
SecondaryButton(
|
||||
key: const ValueKey('add-tracker-subscription'),
|
||||
onPressed: _add,
|
||||
leading: const Icon(Icons.add, size: 17),
|
||||
child: Text(context.l10n.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _add() async {
|
||||
final value = await showTextSettingDialog(
|
||||
context,
|
||||
title: context.l10n.addTrackerSubscription,
|
||||
fieldLabel: context.l10n.trackerSubscriptionUrl,
|
||||
requireHttpUrl: true,
|
||||
);
|
||||
if (!mounted || value == null) return;
|
||||
final next = [...widget.urls, value];
|
||||
widget.onChanged(next.toSet().toList());
|
||||
}
|
||||
|
||||
Future<void> _delete(int index) async {
|
||||
final url = widget.urls[index];
|
||||
if (!await showDeleteSettingsEntryDialog(context, url) || !mounted) return;
|
||||
widget.onChanged(List<String>.from(widget.urls)..remove(url));
|
||||
}
|
||||
|
||||
Future<void> _update() async {
|
||||
setState(() => _updating = true);
|
||||
try {
|
||||
await widget.onUpdate();
|
||||
} finally {
|
||||
if (mounted) setState(() => _updating = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
|
||||
class TrackerSubscriptionsSchedule extends StatelessWidget {
|
||||
const TrackerSubscriptionsSchedule({
|
||||
super.key,
|
||||
required this.lastUpdated,
|
||||
required this.autoUpdate,
|
||||
required this.onAutoUpdateChanged,
|
||||
});
|
||||
|
||||
final DateTime? lastUpdated;
|
||||
final bool autoUpdate;
|
||||
final ValueChanged<bool> onAutoUpdateChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return Column(
|
||||
key: const ValueKey('tracker-subscriptions-schedule'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
key: const ValueKey('tracker-auto-update-control'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(context.l10n.updateEveryDay, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||
),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
Switch(value: autoUpdate, onChanged: onAutoUpdateChanged),
|
||||
],
|
||||
),
|
||||
if (lastUpdated case final time?) ...[
|
||||
const SizedBox(height: AppDesignTokens.space4),
|
||||
Text(
|
||||
key: const ValueKey('tracker-last-updated'),
|
||||
context.l10n.lastUpdated(_formatDateTime(time)),
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(color: palette.textMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime time) {
|
||||
final value = time.toLocal();
|
||||
String two(int number) => number.toString().padLeft(2, '0');
|
||||
return '${value.year}-${two(value.month)}-${two(value.day)} ${two(value.hour)}:${two(value.minute)}:${two(value.second)}';
|
||||
}
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../../../api/model/downloader_config.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
|
||||
class TrackerSubscriptionsTable extends StatefulWidget {
|
||||
const TrackerSubscriptionsTable({super.key, required this.urls, required this.statuses, required this.onDelete});
|
||||
|
||||
final List<String> urls;
|
||||
final Map<String, TrackerSubscriptionStatus> statuses;
|
||||
final ValueChanged<int> onDelete;
|
||||
|
||||
@override
|
||||
State<TrackerSubscriptionsTable> createState() => _TrackerSubscriptionsTableState();
|
||||
}
|
||||
|
||||
class _TrackerSubscriptionsTableState extends State<TrackerSubscriptionsTable> {
|
||||
static const _headerHeight = 40.0;
|
||||
static const _rowHeight = 48.0;
|
||||
static const _statusWidth = 110.0;
|
||||
static const _countWidth = 120.0;
|
||||
static const _actionsWidth = 80.0;
|
||||
final _horizontalController = ScrollController();
|
||||
final _verticalController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_horizontalController.dispose();
|
||||
_verticalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final height = _headerHeight + _rowHeight * widget.urls.length.clamp(1, 5) + 12;
|
||||
return Container(
|
||||
key: const ValueKey('tracker-subscriptions-table'),
|
||||
height: height,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: palette.border),
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentWidth = math.max(640.0, constraints.maxWidth);
|
||||
final hiddenWidth = contentWidth - constraints.maxWidth;
|
||||
return Scrollbar(
|
||||
key: const ValueKey('tracker-subscriptions-horizontal-scrollbar'),
|
||||
controller: _horizontalController,
|
||||
interactive: true,
|
||||
thickness: 7,
|
||||
scrollbarOrientation: ScrollbarOrientation.bottom,
|
||||
notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SingleChildScrollView(
|
||||
controller: _horizontalController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: SizedBox(
|
||||
width: contentWidth,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: _headerHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: palette.sideBg,
|
||||
border: Border(bottom: BorderSide(color: palette.border)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _header(context.l10n.trackerSubscriptionUrl)),
|
||||
SizedBox(
|
||||
width: _statusWidth,
|
||||
child: _header(context.l10n.trackerSubscriptionUpdateStatus),
|
||||
),
|
||||
SizedBox(
|
||||
width: _countWidth,
|
||||
child: _header(context.l10n.trackerSubscriptionTrackerCount),
|
||||
),
|
||||
const SizedBox(width: _actionsWidth),
|
||||
],
|
||||
),
|
||||
),
|
||||
_pinnedActions(
|
||||
hiddenWidth,
|
||||
color: palette.sideBg,
|
||||
child: SizedBox(
|
||||
key: const ValueKey('tracker-subscriptions-actions-header'),
|
||||
child: _header(context.l10n.actions),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: widget.urls.isEmpty
|
||||
? Center(
|
||||
child: Text('—', style: TextStyle(color: palette.textMuted)),
|
||||
)
|
||||
: Scrollbar(
|
||||
controller: _verticalController,
|
||||
thumbVisibility: true,
|
||||
notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical,
|
||||
child: ListView.builder(
|
||||
key: const ValueKey('tracker-subscriptions-rows'),
|
||||
controller: _verticalController,
|
||||
padding: EdgeInsets.zero,
|
||||
itemExtent: _rowHeight,
|
||||
itemCount: widget.urls.length,
|
||||
itemBuilder: (context, index) => _row(index, hiddenWidth),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: AppPalette.of(context).textSecondary, fontSize: 11, fontWeight: FontWeight.w700),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Keep actions inside the same vertical list while compensating for the
|
||||
// horizontal viewport offset, so each button stays aligned with its source.
|
||||
Widget _pinnedActions(double hiddenWidth, {required Color color, required Widget child}) {
|
||||
final palette = AppPalette.of(context);
|
||||
return AnimatedBuilder(
|
||||
animation: _horizontalController,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
border: Border(bottom: BorderSide(color: palette.border)),
|
||||
),
|
||||
child: Center(child: child),
|
||||
),
|
||||
builder: (context, child) => PositionedDirectional(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
end:
|
||||
hiddenWidth - (_horizontalController.hasClients ? _horizontalController.offset.clamp(0.0, hiddenWidth) : 0),
|
||||
width: _actionsWidth,
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(int index, double hiddenWidth) {
|
||||
final palette = AppPalette.of(context);
|
||||
final url = widget.urls[index];
|
||||
final status = widget.statuses[url];
|
||||
final result = status == null
|
||||
? context.l10n.trackerSubscriptionNotUpdated
|
||||
: status.success
|
||||
? context.l10n.trackerSubscriptionFetchSuccess
|
||||
: context.l10n.trackerSubscriptionFetchFailed;
|
||||
return DecoratedBox(
|
||||
key: ValueKey('tracker-subscription-$url'),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: palette.border)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: AppTooltip(
|
||||
message: url,
|
||||
child: Text(
|
||||
url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _statusWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
result,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: status == null
|
||||
? palette.textMuted
|
||||
: status.success
|
||||
? palette.success
|
||||
: palette.error,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _countWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
status?.trackerCount.toString() ?? '—',
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _actionsWidth),
|
||||
],
|
||||
),
|
||||
),
|
||||
_pinnedActions(
|
||||
hiddenWidth,
|
||||
color: palette.cardBg,
|
||||
child: AppTooltip(
|
||||
message: context.l10n.delete,
|
||||
child: SizedBox.square(
|
||||
dimension: 44,
|
||||
child: GhostButton(
|
||||
key: ValueKey('delete-settings-list-$url'),
|
||||
density: ButtonDensity.icon,
|
||||
onPressed: () => widget.onDelete(index),
|
||||
child: const Icon(Icons.delete_outline, size: 17),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "الفئة",
|
||||
"deleteCategoryTitle": "تحذف الفئة؟",
|
||||
"updateFailed": "لقد فشل التحديث، حاول مرة أخرى لاحقاً",
|
||||
"updateVerificationFailed": "فشل التحقق من التحديث. تم إيقاف التثبيت.",
|
||||
"newVersionAvailable": "ويمكن الحصول على نسخة جديدة",
|
||||
"releaseNotes": "ما الجديد",
|
||||
"noReleaseNotes": "لا توجد مذكرات إطلاق",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "التبديل إلى إعدادات المصادقة",
|
||||
"mcpRequiresTcp": "تتوفر إرشادات ربط الوكيل عندما يكون بروتوكول API هو TCP.",
|
||||
"emptyTaskCreateHint": "اضغط على {action} لبدء التنزيل",
|
||||
"emptyStateRetryHint": "اضغط على {action} لإعادة المحاولة"
|
||||
"emptyStateRetryHint": "اضغط على {action} لإعادة المحاولة",
|
||||
"addTrackerSubscription": "إضافة اشتراك متتبعات",
|
||||
"trackerSubscriptionUrl": "رابط الاشتراك",
|
||||
"trackerSubscriptionFetchSuccess": "نجح",
|
||||
"trackerSubscriptionFetchFailed": "فشل",
|
||||
"trackerSubscriptionUpdateStatus": "حالة التحديث",
|
||||
"trackerSubscriptionTrackerCount": "عدد المتتبعات",
|
||||
"trackerSubscriptionNotUpdated": "لم يُحدّث بعد",
|
||||
"actions": "الإجراءات"
|
||||
}
|
||||
@@ -452,6 +452,7 @@
|
||||
"editCategory": "Kateqoriyanı Redaktə Et",
|
||||
"deleteCategoryTitle": "Kateqoriya silinsin?",
|
||||
"updateFailed": "Yeniləmə uğursuz oldu. Zəhmət olmasa sonra yenidən cəhd edin",
|
||||
"updateVerificationFailed": "Yeniləmənin yoxlanması uğursuz oldu. Quraşdırma dayandırıldı.",
|
||||
"newVersionAvailable": "Yeni versiya mövcuddur",
|
||||
"releaseNotes": "Yeniliklər",
|
||||
"noReleaseNotes": "Buraxılış qeydləri mövcud deyil",
|
||||
@@ -640,5 +641,13 @@
|
||||
"webApiConfigReadOnlyHelp": "Veb versiyası API parametrlərinin onlayn dəyişdirilməsini dəstəkləmir. Server başlanğıc seçimlərini və ya konfiqurasiya faylını yeniləyin, sonra dəyişikliklərin qüvvəyə minməsi üçün xidməti yenidən başladın.",
|
||||
"mcpRequiresTcp": "Agent əlaqə təlimatları API protokolu TCP olduqda mövcuddur.",
|
||||
"emptyTaskCreateHint": "Endirməyə başlamaq üçün {action} düyməsinə klikləyin",
|
||||
"emptyStateRetryHint": "Yenidən cəhd etmək üçün {action} düyməsinə klikləyin"
|
||||
"emptyStateRetryHint": "Yenidən cəhd etmək üçün {action} düyməsinə klikləyin",
|
||||
"addTrackerSubscription": "Tracker abunəliyi əlavə et",
|
||||
"trackerSubscriptionUrl": "Abunəlik URL-i",
|
||||
"trackerSubscriptionFetchSuccess": "Uğurlu",
|
||||
"trackerSubscriptionFetchFailed": "Uğursuz",
|
||||
"trackerSubscriptionUpdateStatus": "Yeniləmə statusu",
|
||||
"trackerSubscriptionTrackerCount": "Tracker sayı",
|
||||
"trackerSubscriptionNotUpdated": "Hələ yenilənməyib",
|
||||
"actions": "Əməliyyatlar"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Edita categoria",
|
||||
"deleteCategoryTitle": "Esborro la categoria?",
|
||||
"updateFailed": "Ha fallat l' actualització. Torneu- ho a provar més tard",
|
||||
"updateVerificationFailed": "La verificació de l’actualització ha fallat. S’ha aturat la instal·lació.",
|
||||
"newVersionAvailable": "Hi ha disponible una nova versió",
|
||||
"releaseNotes": "Quèts nous",
|
||||
"noReleaseNotes": "No hi ha notes disponibles",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Canvia a la configuració autenticada",
|
||||
"mcpRequiresTcp": "Les instruccions de connexió d’agents estan disponibles quan el protocol de l’API és TCP.",
|
||||
"emptyTaskCreateHint": "Fes clic a {action} per començar a baixar",
|
||||
"emptyStateRetryHint": "Fes clic a {action} per tornar-ho a provar"
|
||||
"emptyStateRetryHint": "Fes clic a {action} per tornar-ho a provar",
|
||||
"addTrackerSubscription": "Afegeix una subscripció de trackers",
|
||||
"trackerSubscriptionUrl": "URL de subscripció",
|
||||
"trackerSubscriptionFetchSuccess": "Correcte",
|
||||
"trackerSubscriptionFetchFailed": "Fallida",
|
||||
"trackerSubscriptionUpdateStatus": "Estat de l’actualització",
|
||||
"trackerSubscriptionTrackerCount": "Nombre de trackers",
|
||||
"trackerSubscriptionNotUpdated": "Sense actualitzar",
|
||||
"actions": "Accions"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Kategorie bearbeiten",
|
||||
"deleteCategoryTitle": "Kategorie löschen?",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen. Bitte versuchen Sie es später noch einmal",
|
||||
"updateVerificationFailed": "Die Überprüfung des Updates ist fehlgeschlagen. Die Installation wurde gestoppt.",
|
||||
"newVersionAvailable": "Eine neue Version ist verfügbar",
|
||||
"releaseNotes": "Was ist neu?",
|
||||
"noReleaseNotes": "Keine Versionshinweise verfügbar",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Zur authentifizierten Konfiguration wechseln",
|
||||
"mcpRequiresTcp": "Anweisungen zur Agent-Verbindung sind verfügbar, wenn das API-Protokoll TCP ist.",
|
||||
"emptyTaskCreateHint": "Klicke auf {action}, um den Download zu starten",
|
||||
"emptyStateRetryHint": "Klicke auf {action}, um es erneut zu versuchen"
|
||||
"emptyStateRetryHint": "Klicke auf {action}, um es erneut zu versuchen",
|
||||
"addTrackerSubscription": "Tracker-Abonnement hinzufügen",
|
||||
"trackerSubscriptionUrl": "Abonnement-URL",
|
||||
"trackerSubscriptionFetchSuccess": "Erfolgreich",
|
||||
"trackerSubscriptionFetchFailed": "Fehlgeschlagen",
|
||||
"trackerSubscriptionUpdateStatus": "Aktualisierungsstatus",
|
||||
"trackerSubscriptionTrackerCount": "Tracker-Anzahl",
|
||||
"trackerSubscriptionNotUpdated": "Noch nicht aktualisiert",
|
||||
"actions": "Aktionen"
|
||||
}
|
||||
@@ -462,6 +462,7 @@
|
||||
"editCategory": "Edit Category",
|
||||
"deleteCategoryTitle": "Delete category?",
|
||||
"updateFailed": "Update failed. Please try again later",
|
||||
"updateVerificationFailed": "Update verification failed. Installation stopped.",
|
||||
"newVersionAvailable": "A new version is available",
|
||||
"releaseNotes": "What’s new",
|
||||
"noReleaseNotes": "No release notes available",
|
||||
@@ -666,5 +667,13 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"addTrackerSubscription": "Add tracker subscription",
|
||||
"trackerSubscriptionUrl": "Subscription URL",
|
||||
"trackerSubscriptionFetchSuccess": "Succeeded",
|
||||
"trackerSubscriptionFetchFailed": "Failed",
|
||||
"trackerSubscriptionUpdateStatus": "Update status",
|
||||
"trackerSubscriptionTrackerCount": "Tracker count",
|
||||
"trackerSubscriptionNotUpdated": "Not updated",
|
||||
"actions": "Actions"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Editar la categoría",
|
||||
"deleteCategoryTitle": "¿Supir la categoría?",
|
||||
"updateFailed": "Actualizar falló. Por favor, intente de nuevo más tarde",
|
||||
"updateVerificationFailed": "Falló la verificación de la actualización. Se detuvo la instalación.",
|
||||
"newVersionAvailable": "Una nueva versión está disponible",
|
||||
"releaseNotes": "Qué es nuevo",
|
||||
"noReleaseNotes": "No hay notas de liberación disponibles",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Cambiar a la configuración autenticada",
|
||||
"mcpRequiresTcp": "Las instrucciones de conexión de agentes están disponibles cuando el protocolo de la API es TCP.",
|
||||
"emptyTaskCreateHint": "Haz clic en {action} para empezar a descargar",
|
||||
"emptyStateRetryHint": "Haz clic en {action} para reintentar"
|
||||
"emptyStateRetryHint": "Haz clic en {action} para reintentar",
|
||||
"addTrackerSubscription": "Añadir suscripción de trackers",
|
||||
"trackerSubscriptionUrl": "URL de suscripción",
|
||||
"trackerSubscriptionFetchSuccess": "Correcto",
|
||||
"trackerSubscriptionFetchFailed": "Fallido",
|
||||
"trackerSubscriptionUpdateStatus": "Estado de actualización",
|
||||
"trackerSubscriptionTrackerCount": "Número de trackers",
|
||||
"trackerSubscriptionNotUpdated": "Sin actualizar",
|
||||
"actions": "Acciones"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "ویرایش دستهبندی",
|
||||
"deleteCategoryTitle": "دسته بندی حذفی؟",
|
||||
"updateFailed": "به روز رسانی شکست خورد لطفا دوباره امتحان کنید",
|
||||
"updateVerificationFailed": "تأیید بهروزرسانی ناموفق بود. نصب متوقف شد.",
|
||||
"newVersionAvailable": "نسخه جدید در دسترس است",
|
||||
"releaseNotes": "چه چیز جدیدی است",
|
||||
"noReleaseNotes": "هیچ یادداشت آزادی در دسترس نیست",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "تغییر به پیکربندی احراز هویت",
|
||||
"mcpRequiresTcp": "راهنمای اتصال عامل زمانی در دسترس است که پروتکل API روی TCP باشد.",
|
||||
"emptyTaskCreateHint": "برای شروع دانلود روی {action} کلیک کنید",
|
||||
"emptyStateRetryHint": "برای تلاش مجدد روی {action} کلیک کنید"
|
||||
"emptyStateRetryHint": "برای تلاش مجدد روی {action} کلیک کنید",
|
||||
"addTrackerSubscription": "افزودن اشتراک ردیاب",
|
||||
"trackerSubscriptionUrl": "نشانی اشتراک",
|
||||
"trackerSubscriptionFetchSuccess": "موفق",
|
||||
"trackerSubscriptionFetchFailed": "ناموفق",
|
||||
"trackerSubscriptionUpdateStatus": "وضعیت بهروزرسانی",
|
||||
"trackerSubscriptionTrackerCount": "تعداد ردیابها",
|
||||
"trackerSubscriptionNotUpdated": "هنوز بهروزرسانی نشده",
|
||||
"actions": "عملیات"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Modifier la catégorie",
|
||||
"deleteCategoryTitle": "Supprimer la catégorie?",
|
||||
"updateFailed": "La mise à jour a échoué. Veuillez réessayer plus tard",
|
||||
"updateVerificationFailed": "La vérification de la mise à jour a échoué. L’installation a été arrêtée.",
|
||||
"newVersionAvailable": "Une nouvelle version est disponible",
|
||||
"releaseNotes": "Quoi de neuf",
|
||||
"noReleaseNotes": "Aucune note de sortie disponible",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Passer à la configuration authentifiée",
|
||||
"mcpRequiresTcp": "Les instructions de connexion des agents sont disponibles lorsque le protocole de l’API est TCP.",
|
||||
"emptyTaskCreateHint": "Cliquez sur {action} pour commencer le téléchargement",
|
||||
"emptyStateRetryHint": "Cliquez sur {action} pour réessayer"
|
||||
"emptyStateRetryHint": "Cliquez sur {action} pour réessayer",
|
||||
"addTrackerSubscription": "Ajouter un abonnement de trackers",
|
||||
"trackerSubscriptionUrl": "URL d’abonnement",
|
||||
"trackerSubscriptionFetchSuccess": "Réussite",
|
||||
"trackerSubscriptionFetchFailed": "Échec",
|
||||
"trackerSubscriptionUpdateStatus": "État de mise à jour",
|
||||
"trackerSubscriptionTrackerCount": "Nombre de trackers",
|
||||
"trackerSubscriptionNotUpdated": "Pas encore mis à jour",
|
||||
"actions": "Actions"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Kategória szerkesztése",
|
||||
"deleteCategoryTitle": "Kategória törlése?",
|
||||
"updateFailed": "A frissítés nem sikerült. Kérjük, próbálja újra később",
|
||||
"updateVerificationFailed": "A frissítés ellenőrzése sikertelen. A telepítés leállt.",
|
||||
"newVersionAvailable": "Új verzió érhető el",
|
||||
"releaseNotes": "Újdonságok",
|
||||
"noReleaseNotes": "Nem állnak rendelkezésre kiadási megjegyzések",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Váltás hitelesített konfigurációra",
|
||||
"mcpRequiresTcp": "Az ügynökcsatlakoztatási útmutató akkor érhető el, ha az API protokollja TCP.",
|
||||
"emptyTaskCreateHint": "Kattints a {action} gombra a letöltés indításához",
|
||||
"emptyStateRetryHint": "Kattints a {action} gombra az újrapróbálkozáshoz"
|
||||
"emptyStateRetryHint": "Kattints a {action} gombra az újrapróbálkozáshoz",
|
||||
"addTrackerSubscription": "Tracker-feliratkozás hozzáadása",
|
||||
"trackerSubscriptionUrl": "Feliratkozási URL",
|
||||
"trackerSubscriptionFetchSuccess": "Sikeres",
|
||||
"trackerSubscriptionFetchFailed": "Sikertelen",
|
||||
"trackerSubscriptionUpdateStatus": "Frissítés állapota",
|
||||
"trackerSubscriptionTrackerCount": "Trackerek száma",
|
||||
"trackerSubscriptionNotUpdated": "Még nem frissült",
|
||||
"actions": "Műveletek"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Sunting Kategori",
|
||||
"deleteCategoryTitle": "Hapus kategori?",
|
||||
"updateFailed": "Pemutakhiran gagal. Silakan coba lagi nanti",
|
||||
"updateVerificationFailed": "Verifikasi pembaruan gagal. Instalasi dihentikan.",
|
||||
"newVersionAvailable": "Versi baru tersedia",
|
||||
"releaseNotes": "Apa yang baru",
|
||||
"noReleaseNotes": "Tidak ada catatan rilis yang tersedia",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Beralih ke konfigurasi terautentikasi",
|
||||
"mcpRequiresTcp": "Petunjuk koneksi agen tersedia saat protokol API menggunakan TCP.",
|
||||
"emptyTaskCreateHint": "Klik {action} untuk mulai mengunduh",
|
||||
"emptyStateRetryHint": "Klik {action} untuk mencoba lagi"
|
||||
"emptyStateRetryHint": "Klik {action} untuk mencoba lagi",
|
||||
"addTrackerSubscription": "Tambah langganan tracker",
|
||||
"trackerSubscriptionUrl": "URL langganan",
|
||||
"trackerSubscriptionFetchSuccess": "Berhasil",
|
||||
"trackerSubscriptionFetchFailed": "Gagal",
|
||||
"trackerSubscriptionUpdateStatus": "Status pembaruan",
|
||||
"trackerSubscriptionTrackerCount": "Jumlah tracker",
|
||||
"trackerSubscriptionNotUpdated": "Belum diperbarui",
|
||||
"actions": "Tindakan"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Modifica della categoria",
|
||||
"deleteCategoryTitle": "Elimina categoria?",
|
||||
"updateFailed": "Aggiornamento fallito. Si prega di riprovare più tardi",
|
||||
"updateVerificationFailed": "Verifica dell’aggiornamento non riuscita. Installazione interrotta.",
|
||||
"newVersionAvailable": "Una nuova versione è disponibile",
|
||||
"releaseNotes": "Cosa c’è di nuovo",
|
||||
"noReleaseNotes": "Nessun note di rilascio disponibili",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Passa alla configurazione autenticata",
|
||||
"mcpRequiresTcp": "Le istruzioni per connettere gli agenti sono disponibili quando il protocollo API è TCP.",
|
||||
"emptyTaskCreateHint": "Fai clic su {action} per iniziare il download",
|
||||
"emptyStateRetryHint": "Fai clic su {action} per riprovare"
|
||||
"emptyStateRetryHint": "Fai clic su {action} per riprovare",
|
||||
"addTrackerSubscription": "Aggiungi abbonamento tracker",
|
||||
"trackerSubscriptionUrl": "URL abbonamento",
|
||||
"trackerSubscriptionFetchSuccess": "Riuscito",
|
||||
"trackerSubscriptionFetchFailed": "Non riuscito",
|
||||
"trackerSubscriptionUpdateStatus": "Stato aggiornamento",
|
||||
"trackerSubscriptionTrackerCount": "Numero di tracker",
|
||||
"trackerSubscriptionNotUpdated": "Non ancora aggiornato",
|
||||
"actions": "Azioni"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "編集カテゴリ",
|
||||
"deleteCategoryTitle": "カテゴリを削除?",
|
||||
"updateFailed": "更新が失敗しました。 後でもう一度お試しください",
|
||||
"updateVerificationFailed": "更新パッケージの検証に失敗しました。インストールを中止しました。",
|
||||
"newVersionAvailable": "新規バージョンが利用可能",
|
||||
"releaseNotes": "新着情報",
|
||||
"noReleaseNotes": "リリースノートは使用できません",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "認証付き設定に切り替える",
|
||||
"mcpRequiresTcp": "API プロトコルが TCP の場合にエージェント接続手順を利用できます。",
|
||||
"emptyTaskCreateHint": "{action}をクリックしてダウンロードを開始",
|
||||
"emptyStateRetryHint": "{action}をクリックして再試行"
|
||||
"emptyStateRetryHint": "{action}をクリックして再試行",
|
||||
"addTrackerSubscription": "Tracker 購読を追加",
|
||||
"trackerSubscriptionUrl": "購読 URL",
|
||||
"trackerSubscriptionFetchSuccess": "成功",
|
||||
"trackerSubscriptionFetchFailed": "失敗",
|
||||
"trackerSubscriptionUpdateStatus": "更新状況",
|
||||
"trackerSubscriptionTrackerCount": "Tracker 数",
|
||||
"trackerSubscriptionNotUpdated": "未更新",
|
||||
"actions": "操作"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "관련 기사",
|
||||
"deleteCategoryTitle": "범주 삭제?",
|
||||
"updateFailed": "업데이트 실패. 나중에 다시 시도하십시오",
|
||||
"updateVerificationFailed": "업데이트 검증에 실패했습니다. 설치를 중단했습니다.",
|
||||
"newVersionAvailable": "새 버전은 사용할 수 있습니다",
|
||||
"releaseNotes": "새로운 소식",
|
||||
"noReleaseNotes": "출시 노트",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "인증 구성으로 전환",
|
||||
"mcpRequiresTcp": "API 프로토콜이 TCP일 때 에이전트 연결 안내를 사용할 수 있습니다.",
|
||||
"emptyTaskCreateHint": "{action}을 눌러 다운로드 시작",
|
||||
"emptyStateRetryHint": "{action}을 눌러 다시 시도"
|
||||
"emptyStateRetryHint": "{action}을 눌러 다시 시도",
|
||||
"addTrackerSubscription": "트래커 구독 추가",
|
||||
"trackerSubscriptionUrl": "구독 URL",
|
||||
"trackerSubscriptionFetchSuccess": "성공",
|
||||
"trackerSubscriptionFetchFailed": "실패",
|
||||
"trackerSubscriptionUpdateStatus": "업데이트 상태",
|
||||
"trackerSubscriptionTrackerCount": "트래커 수",
|
||||
"trackerSubscriptionNotUpdated": "아직 업데이트되지 않음",
|
||||
"actions": "작업"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Edytuj kategorię",
|
||||
"deleteCategoryTitle": "Skasować kategorię?",
|
||||
"updateFailed": "Uaktualnienie nie powiodło się. Proszę spróbować ponownie później",
|
||||
"updateVerificationFailed": "Weryfikacja aktualizacji nie powiodła się. Instalacja została zatrzymana.",
|
||||
"newVersionAvailable": "Nowa wersja jest dostępna",
|
||||
"releaseNotes": "Co nowego?",
|
||||
"noReleaseNotes": "Brak informacji o wydaniu",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Przełącz na konfigurację z uwierzytelnianiem",
|
||||
"mcpRequiresTcp": "Instrukcje łączenia agentów są dostępne, gdy protokół API to TCP.",
|
||||
"emptyTaskCreateHint": "Kliknij {action}, aby rozpocząć pobieranie",
|
||||
"emptyStateRetryHint": "Kliknij {action}, aby spróbować ponownie"
|
||||
"emptyStateRetryHint": "Kliknij {action}, aby spróbować ponownie",
|
||||
"addTrackerSubscription": "Dodaj subskrypcję trackerów",
|
||||
"trackerSubscriptionUrl": "URL subskrypcji",
|
||||
"trackerSubscriptionFetchSuccess": "Sukces",
|
||||
"trackerSubscriptionFetchFailed": "Niepowodzenie",
|
||||
"trackerSubscriptionUpdateStatus": "Stan aktualizacji",
|
||||
"trackerSubscriptionTrackerCount": "Liczba trackerów",
|
||||
"trackerSubscriptionNotUpdated": "Jeszcze nie zaktualizowano",
|
||||
"actions": "Działania"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Editar a Categoria",
|
||||
"deleteCategoryTitle": "Apagar a categoria?",
|
||||
"updateFailed": "A actualização falhou. Por favor tente novamente mais tarde",
|
||||
"updateVerificationFailed": "A verificação da atualização falhou. A instalação foi interrompida.",
|
||||
"newVersionAvailable": "Uma nova versão está disponível",
|
||||
"releaseNotes": "O que há de novo",
|
||||
"noReleaseNotes": "Nenhuma nota de lançamento disponível",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Mudar para a configuração autenticada",
|
||||
"mcpRequiresTcp": "As instruções de conexão de agentes estão disponíveis quando o protocolo da API é TCP.",
|
||||
"emptyTaskCreateHint": "Clique em {action} para começar a baixar",
|
||||
"emptyStateRetryHint": "Clique em {action} para tentar novamente"
|
||||
"emptyStateRetryHint": "Clique em {action} para tentar novamente",
|
||||
"addTrackerSubscription": "Adicionar subscrição de trackers",
|
||||
"trackerSubscriptionUrl": "URL da subscrição",
|
||||
"trackerSubscriptionFetchSuccess": "Sucesso",
|
||||
"trackerSubscriptionFetchFailed": "Falha",
|
||||
"trackerSubscriptionUpdateStatus": "Estado da atualização",
|
||||
"trackerSubscriptionTrackerCount": "Número de trackers",
|
||||
"trackerSubscriptionNotUpdated": "Ainda não atualizado",
|
||||
"actions": "Ações"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Категория редактирования",
|
||||
"deleteCategoryTitle": "Удалить категорию?",
|
||||
"updateFailed": "Обновление не удалось. Пожалуйста, попробуйте еще раз позже",
|
||||
"updateVerificationFailed": "Проверка обновления не удалась. Установка остановлена.",
|
||||
"newVersionAvailable": "Доступна новая версия",
|
||||
"releaseNotes": "Что нового",
|
||||
"noReleaseNotes": "Нет доступных записей о выпуске",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Переключиться на конфигурацию с аутентификацией",
|
||||
"mcpRequiresTcp": "Инструкции по подключению агентов доступны, когда протокол API — TCP.",
|
||||
"emptyTaskCreateHint": "Нажмите {action}, чтобы начать загрузку",
|
||||
"emptyStateRetryHint": "Нажмите {action}, чтобы повторить попытку"
|
||||
"emptyStateRetryHint": "Нажмите {action}, чтобы повторить попытку",
|
||||
"addTrackerSubscription": "Добавить подписку на трекеры",
|
||||
"trackerSubscriptionUrl": "URL подписки",
|
||||
"trackerSubscriptionFetchSuccess": "Успешно",
|
||||
"trackerSubscriptionFetchFailed": "Ошибка",
|
||||
"trackerSubscriptionUpdateStatus": "Статус обновления",
|
||||
"trackerSubscriptionTrackerCount": "Число трекеров",
|
||||
"trackerSubscriptionNotUpdated": "Ещё не обновлено",
|
||||
"actions": "Действия"
|
||||
}
|
||||
@@ -461,6 +461,7 @@
|
||||
"editCategory": "Upraviť kategóriu",
|
||||
"deleteCategoryTitle": "Odstrániť kategóriu?",
|
||||
"updateFailed": "Aktualizácia zlyhala. Skúste to znova neskôr",
|
||||
"updateVerificationFailed": "Overenie aktualizácie zlyhalo. Inštalácia bola zastavená.",
|
||||
"newVersionAvailable": "Je dostupná nová verzia",
|
||||
"releaseNotes": "Čo je nové",
|
||||
"noReleaseNotes": "Nie sú dostupné žiadne poznámky k vydaniu",
|
||||
@@ -657,5 +658,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"emptyStateRetryHint": "Kliknutím na {action} skúsite znova"
|
||||
"emptyStateRetryHint": "Kliknutím na {action} skúsite znova",
|
||||
"addTrackerSubscription": "Pridať odber trackerov",
|
||||
"trackerSubscriptionUrl": "URL odberu",
|
||||
"trackerSubscriptionFetchSuccess": "Úspešné",
|
||||
"trackerSubscriptionFetchFailed": "Neúspešné",
|
||||
"trackerSubscriptionUpdateStatus": "Stav aktualizácie",
|
||||
"trackerSubscriptionTrackerCount": "Počet trackerov",
|
||||
"trackerSubscriptionNotUpdated": "Zatiaľ neaktualizované",
|
||||
"actions": "Akcie"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "வகையைத் திருத்துக",
|
||||
"deleteCategoryTitle": "வகையை நீக்கவா?",
|
||||
"updateFailed": "புதுப்பித்தல் தோல்வியுற்றது. பின்னர் மீண்டும் முயற்சிக்கவும்",
|
||||
"updateVerificationFailed": "புதுப்பிப்பைச் சரிபார்க்க முடியவில்லை. நிறுவல் நிறுத்தப்பட்டது.",
|
||||
"newVersionAvailable": "ஒரு புதிய பதிப்பு கிடைக்கிறது",
|
||||
"releaseNotes": "புதியது என்ன:",
|
||||
"noReleaseNotes": "வெளியீட்டு குறிப்புகள் எதுவும் கிடைக்கவில்லை",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "அங்கீகார உள்ளமைவுக்கு மாறு",
|
||||
"mcpRequiresTcp": "API நெறிமுறை TCP ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்.",
|
||||
"emptyTaskCreateHint": "பதிவிறக்கத்தைத் தொடங்க {action} ஐ அழுத்தவும்",
|
||||
"emptyStateRetryHint": "மீண்டும் முயற்சிக்க {action} ஐ அழுத்தவும்"
|
||||
"emptyStateRetryHint": "மீண்டும் முயற்சிக்க {action} ஐ அழுத்தவும்",
|
||||
"addTrackerSubscription": "டிராக்கர் சந்தாவைச் சேர்",
|
||||
"trackerSubscriptionUrl": "சந்தா URL",
|
||||
"trackerSubscriptionFetchSuccess": "வெற்றி",
|
||||
"trackerSubscriptionFetchFailed": "தோல்வி",
|
||||
"trackerSubscriptionUpdateStatus": "புதுப்பிப்பு நிலை",
|
||||
"trackerSubscriptionTrackerCount": "டிராக்கர் எண்ணிக்கை",
|
||||
"trackerSubscriptionNotUpdated": "இன்னும் புதுப்பிக்கப்படவில்லை",
|
||||
"actions": "செயல்கள்"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Edit Kategori",
|
||||
"deleteCategoryTitle": "Delete kategorisi?",
|
||||
"updateFailed": "Güncelleme başarısız oldu. Lütfen daha sonra tekrar deneyin",
|
||||
"updateVerificationFailed": "Güncelleme doğrulanamadı. Kurulum durduruldu.",
|
||||
"newVersionAvailable": "Yeni bir sürüm mevcut",
|
||||
"releaseNotes": "Yeni Nedir",
|
||||
"noReleaseNotes": "Mevcut sürüm notları mevcut değildir",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Kimlik doğrulamalı yapılandırmaya geç",
|
||||
"mcpRequiresTcp": "Ajan bağlantı yönergeleri API protokolü TCP olduğunda kullanılabilir.",
|
||||
"emptyTaskCreateHint": "İndirmeye başlamak için {action} düğmesine tıklayın",
|
||||
"emptyStateRetryHint": "Yeniden denemek için {action} düğmesine tıklayın"
|
||||
"emptyStateRetryHint": "Yeniden denemek için {action} düğmesine tıklayın",
|
||||
"addTrackerSubscription": "Tracker aboneliği ekle",
|
||||
"trackerSubscriptionUrl": "Abonelik URL’si",
|
||||
"trackerSubscriptionFetchSuccess": "Başarılı",
|
||||
"trackerSubscriptionFetchFailed": "Başarısız",
|
||||
"trackerSubscriptionUpdateStatus": "Güncelleme durumu",
|
||||
"trackerSubscriptionTrackerCount": "Tracker sayısı",
|
||||
"trackerSubscriptionNotUpdated": "Henüz güncellenmedi",
|
||||
"actions": "İşlemler"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Редагувати Категорія",
|
||||
"deleteCategoryTitle": "Категорія Видалити?",
|
||||
"updateFailed": "Поверніть ще раз",
|
||||
"updateVerificationFailed": "Не вдалося перевірити оновлення. Встановлення зупинено.",
|
||||
"newVersionAvailable": "Нова версія доступна",
|
||||
"releaseNotes": "Що нового",
|
||||
"noReleaseNotes": "Немає вихідних нот",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Перейти до конфігурації з автентифікацією",
|
||||
"mcpRequiresTcp": "Інструкції з підключення агентів доступні, коли протокол API — TCP.",
|
||||
"emptyTaskCreateHint": "Натисніть {action}, щоб почати завантаження",
|
||||
"emptyStateRetryHint": "Натисніть {action}, щоб спробувати ще раз"
|
||||
"emptyStateRetryHint": "Натисніть {action}, щоб спробувати ще раз",
|
||||
"addTrackerSubscription": "Додати підписку на трекери",
|
||||
"trackerSubscriptionUrl": "URL підписки",
|
||||
"trackerSubscriptionFetchSuccess": "Успішно",
|
||||
"trackerSubscriptionFetchFailed": "Помилка",
|
||||
"trackerSubscriptionUpdateStatus": "Стан оновлення",
|
||||
"trackerSubscriptionTrackerCount": "Кількість трекерів",
|
||||
"trackerSubscriptionNotUpdated": "Ще не оновлено",
|
||||
"actions": "Дії"
|
||||
}
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "Sửa phân loại",
|
||||
"deleteCategoryTitle": "Xoá phân loại",
|
||||
"updateFailed": "Lỗi cập nhật. Xin hãy thử lại sau",
|
||||
"updateVerificationFailed": "Xác minh bản cập nhật thất bại. Đã dừng cài đặt.",
|
||||
"newVersionAvailable": "Name",
|
||||
"releaseNotes": "Có gì mới không?",
|
||||
"noReleaseNotes": "Không có ghi chú giải thoát",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "Chuyển sang cấu hình có xác thực",
|
||||
"mcpRequiresTcp": "Hướng dẫn kết nối tác nhân khả dụng khi giao thức API là TCP.",
|
||||
"emptyTaskCreateHint": "Nhấn {action} để bắt đầu tải xuống",
|
||||
"emptyStateRetryHint": "Nhấn {action} để thử lại"
|
||||
"emptyStateRetryHint": "Nhấn {action} để thử lại",
|
||||
"addTrackerSubscription": "Thêm đăng ký tracker",
|
||||
"trackerSubscriptionUrl": "URL đăng ký",
|
||||
"trackerSubscriptionFetchSuccess": "Thành công",
|
||||
"trackerSubscriptionFetchFailed": "Thất bại",
|
||||
"trackerSubscriptionUpdateStatus": "Trạng thái cập nhật",
|
||||
"trackerSubscriptionTrackerCount": "Số tracker",
|
||||
"trackerSubscriptionNotUpdated": "Chưa cập nhật",
|
||||
"actions": "Thao tác"
|
||||
}
|
||||
@@ -426,7 +426,7 @@
|
||||
"logDirectoryUnavailable": "日志目录尚未初始化",
|
||||
"unableOpenExternalLink": "无法打开外部链接",
|
||||
"neverUpdated": "尚未更新",
|
||||
"lastUpdated": "上次更新:{time}",
|
||||
"lastUpdated": "上次更新时间:{time}",
|
||||
"unableLoadSettings": "无法加载设置",
|
||||
"fieldRequired": "{field}不能为空",
|
||||
"addGithubMirror": "添加 GitHub 镜像",
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "编辑分类",
|
||||
"deleteCategoryTitle": "删除分类?",
|
||||
"updateFailed": "更新失败,请稍后重试",
|
||||
"updateVerificationFailed": "更新包校验失败,已停止安装",
|
||||
"newVersionAvailable": "发现新版本",
|
||||
"releaseNotes": "更新内容",
|
||||
"noReleaseNotes": "暂无更新说明",
|
||||
@@ -456,5 +457,13 @@
|
||||
"webApiConfigReadOnlyHelp": "Web 版本不支持在线修改 API 配置。请修改服务端启动参数或配置文件,并重启服务后使配置生效。",
|
||||
"mcpRequiresTcp": "API 通讯协议为 TCP 时才能使用 Agent 接入指引。",
|
||||
"emptyTaskCreateHint": "点击{action}开始下载",
|
||||
"emptyStateRetryHint": "点击{action}重新加载"
|
||||
"emptyStateRetryHint": "点击{action}重新加载",
|
||||
"addTrackerSubscription": "添加 Tracker 订阅",
|
||||
"trackerSubscriptionUrl": "订阅地址",
|
||||
"trackerSubscriptionFetchSuccess": "成功",
|
||||
"trackerSubscriptionFetchFailed": "失败",
|
||||
"trackerSubscriptionUpdateStatus": "更新状态",
|
||||
"trackerSubscriptionTrackerCount": "Tracker 数量",
|
||||
"trackerSubscriptionNotUpdated": "未更新",
|
||||
"actions": "操作"
|
||||
}
|
||||
@@ -426,7 +426,7 @@
|
||||
"logDirectoryUnavailable": "日誌目錄尚未初始化",
|
||||
"unableOpenExternalLink": "無法開啟外部連結",
|
||||
"neverUpdated": "尚未更新",
|
||||
"lastUpdated": "上次更新:{time}",
|
||||
"lastUpdated": "上次更新時間:{time}",
|
||||
"unableLoadSettings": "無法載入設定",
|
||||
"fieldRequired": "{field}不能為空",
|
||||
"addGithubMirror": "新增 GitHub 映象",
|
||||
@@ -438,6 +438,7 @@
|
||||
"editCategory": "編輯分類",
|
||||
"deleteCategoryTitle": "刪除分類?",
|
||||
"updateFailed": "更新失敗,請稍後重試",
|
||||
"updateVerificationFailed": "更新套件驗證失敗,已停止安裝",
|
||||
"newVersionAvailable": "發現新版本",
|
||||
"releaseNotes": "更新內容",
|
||||
"noReleaseNotes": "暫無更新說明",
|
||||
@@ -456,5 +457,13 @@
|
||||
"useMcpApiTokenTemplate": "切換為認證設定",
|
||||
"mcpRequiresTcp": "API 通訊協定為 TCP 時才能使用 Agent 接入指引。",
|
||||
"emptyTaskCreateHint": "點擊{action}開始下載",
|
||||
"emptyStateRetryHint": "點擊{action}重新載入"
|
||||
"emptyStateRetryHint": "點擊{action}重新載入",
|
||||
"addTrackerSubscription": "新增 Tracker 訂閱",
|
||||
"trackerSubscriptionUrl": "訂閱網址",
|
||||
"trackerSubscriptionFetchSuccess": "成功",
|
||||
"trackerSubscriptionFetchFailed": "失敗",
|
||||
"trackerSubscriptionUpdateStatus": "更新狀態",
|
||||
"trackerSubscriptionTrackerCount": "Tracker 數量",
|
||||
"trackerSubscriptionNotUpdated": "未更新",
|
||||
"actions": "操作"
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../api/model/downloader_config.dart';
|
||||
|
||||
enum MirrorType { githubSource, githubRelease }
|
||||
|
||||
Future<String> githubAutoMirror(String rawUrl, MirrorType type, {required ExtraConfigGithubMirror config}) async {
|
||||
final mirrorUrls = githubMirrorUrls(rawUrl, type, config: config);
|
||||
if (mirrorUrls.isEmpty) return rawUrl;
|
||||
|
||||
final results = await Future.wait(
|
||||
mirrorUrls.map((url) async {
|
||||
final client = Dio(
|
||||
BaseOptions(connectTimeout: const Duration(seconds: 3), sendTimeout: const Duration(seconds: 3)),
|
||||
);
|
||||
final startedAt = DateTime.now();
|
||||
try {
|
||||
final response = await client.head<void>(url);
|
||||
if (response.statusCode == 200) return (url, DateTime.now().difference(startedAt));
|
||||
} catch (_) {
|
||||
// A failed mirror probe must never prevent the original download.
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
return (url, null);
|
||||
}),
|
||||
);
|
||||
|
||||
final available = results.where((result) => result.$2 != null).toList()
|
||||
..sort((left, right) => left.$2!.compareTo(right.$2!));
|
||||
return available.isEmpty ? rawUrl : available.first.$1;
|
||||
}
|
||||
|
||||
List<String> githubMirrorUrls(String rawUrl, MirrorType type, {required ExtraConfigGithubMirror config}) {
|
||||
if (!config.enabled) return const [];
|
||||
final urls = <String>[];
|
||||
for (final mirror in config.mirrors.where((item) => !item.isDeleted)) {
|
||||
switch (mirror.type) {
|
||||
case GithubMirrorType.jsdelivr:
|
||||
if (type != MirrorType.githubSource) continue;
|
||||
final match = RegExp(r'.*raw\.githubusercontent\.com(/[^/]+)(/[^/]+)/(?:master|main)(/.*)').firstMatch(rawUrl);
|
||||
if (match != null) {
|
||||
urls.add('${mirror.url}${match.group(1)}${match.group(2)}${match.group(3)}');
|
||||
}
|
||||
case GithubMirrorType.ghProxy:
|
||||
urls.add('${mirror.url}/$rawUrl');
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:install_plugin/install_plugin.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pub_semver/pub_semver.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../api/api.dart' as api;
|
||||
import '../api/gopeed_site_api.dart';
|
||||
import '../api/model/downloader_config.dart';
|
||||
import '../core/capabilities/gopeed_capability.dart';
|
||||
import '../core/network/candidate_download.dart';
|
||||
import '../core/network/app_update_client.dart';
|
||||
import 'arch/arch.dart';
|
||||
import 'github_mirror.dart';
|
||||
import 'log_util.dart';
|
||||
import 'package_info.dart';
|
||||
import 'util.dart';
|
||||
|
||||
export '../core/network/app_update_client.dart' show VersionInfo;
|
||||
|
||||
enum UpdateChannel {
|
||||
windowsInstaller,
|
||||
windowsPortable,
|
||||
@@ -46,17 +44,6 @@ UpdateChannel? get updateChannel {
|
||||
|
||||
String get _updaterBinaryName => 'updater${Util.isWindows() ? '.exe' : ''}';
|
||||
|
||||
const _releasePageSize = 10;
|
||||
const _githubReleasesUrl = 'https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=$_releasePageSize';
|
||||
|
||||
class VersionInfo {
|
||||
const VersionInfo({required this.version, required this.changeLog, required this.releaseUrl});
|
||||
|
||||
final String version;
|
||||
final String changeLog;
|
||||
final String releaseUrl;
|
||||
}
|
||||
|
||||
/// Installs the small native updater bundled with desktop releases.
|
||||
///
|
||||
/// Development builds do not necessarily contain this asset. Callers should
|
||||
@@ -71,91 +58,27 @@ Future<void> installUpdater() async {
|
||||
);
|
||||
}
|
||||
|
||||
Future<VersionInfo?> checkUpdate() async {
|
||||
List<dynamic> releases;
|
||||
try {
|
||||
final releaseDataStr = (await api.proxyRequest(_githubReleasesUrl)).data;
|
||||
if (releaseDataStr == null || releaseDataStr.isEmpty) {
|
||||
throw const FormatException('Empty GitHub releases response');
|
||||
}
|
||||
final releaseData = jsonDecode(releaseDataStr);
|
||||
if (releaseData is! List<dynamic>) {
|
||||
throw const FormatException('Invalid GitHub releases response');
|
||||
}
|
||||
releases = releaseData;
|
||||
} catch (_) {
|
||||
releases = await GopeedSiteApi.instance.getReleases(perPage: _releasePageSize);
|
||||
}
|
||||
return selectUpdateRelease(releases, appVersion);
|
||||
Future<VersionInfo?> checkUpdate() => AppUpdateClient().check(appVersion);
|
||||
|
||||
/// Package selection belongs to the client, including when the Downloader is remote.
|
||||
String appUpdateAssetName(String version, UpdateChannel? channel, String architecture) {
|
||||
final arch = {'ia32': '386', 'x64': 'amd64'}[architecture] ?? architecture;
|
||||
final prefix = 'Gopeed-v$version';
|
||||
return switch (channel) {
|
||||
UpdateChannel.windowsInstaller => '$prefix-windows-$arch.zip',
|
||||
UpdateChannel.windowsPortable => '$prefix-windows-$arch-portable.zip',
|
||||
UpdateChannel.macosDmg => '$prefix-macos-$arch.dmg',
|
||||
UpdateChannel.linuxDeb => '$prefix-linux-$arch.deb',
|
||||
UpdateChannel.androidApk => switch (architecture) {
|
||||
'arm' => '$prefix-android-armeabi-v7a.apk',
|
||||
'arm64' => '$prefix-android-arm64-v8a.apk',
|
||||
'x64' => '$prefix-android-x86_64.apk',
|
||||
_ => '$prefix-android.apk',
|
||||
},
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
bool isNewerVersion(String latest, String current) {
|
||||
try {
|
||||
return _parseVersion(latest).compareTo(_parseVersion(current)) > 0;
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
VersionInfo? selectUpdateRelease(List<dynamic> releases, String currentVersionText) {
|
||||
late final Version currentVersion;
|
||||
try {
|
||||
currentVersion = _parseVersion(currentVersionText);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? selectedRelease;
|
||||
Version? selectedVersion;
|
||||
|
||||
for (final item in releases) {
|
||||
if (item is! Map) continue;
|
||||
final release = Map<String, dynamic>.from(item);
|
||||
if (release['draft'] == true) continue;
|
||||
|
||||
final tagName = release['tag_name'];
|
||||
if (tagName is! String || tagName.isEmpty) continue;
|
||||
|
||||
late final Version candidateVersion;
|
||||
try {
|
||||
candidateVersion = _parseVersion(tagName);
|
||||
} on FormatException {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidateVersion.compareTo(currentVersion) <= 0) continue;
|
||||
|
||||
final candidateIsPrerelease = release['prerelease'] == true || candidateVersion.isPreRelease;
|
||||
if (!currentVersion.isPreRelease) {
|
||||
if (candidateIsPrerelease) continue;
|
||||
} else if (candidateIsPrerelease && !_hasSameReleaseCore(candidateVersion, currentVersion)) {
|
||||
// Preview builds graduate through their own beta/rc line before returning
|
||||
// to stable; they do not jump into the next version's preview line.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selectedVersion == null || candidateVersion.compareTo(selectedVersion) > 0) {
|
||||
selectedRelease = release;
|
||||
selectedVersion = candidateVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedRelease == null || selectedVersion == null) return null;
|
||||
final tagName = selectedRelease['tag_name'] as String;
|
||||
return VersionInfo(
|
||||
version: _versionText(tagName),
|
||||
changeLog: (selectedRelease['body'] ?? '').toString(),
|
||||
releaseUrl: (selectedRelease['html_url'] ?? 'https://github.com/GopeedLab/gopeed/releases/tag/$tagName').toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Version _parseVersion(String version) => Version.parse(_versionText(version));
|
||||
|
||||
String _versionText(String version) => version.startsWith('v') ? version.substring(1) : version;
|
||||
|
||||
bool _hasSameReleaseCore(Version first, Version second) =>
|
||||
first.major == second.major && first.minor == second.minor && first.patch == second.patch;
|
||||
|
||||
/// Extracts the matching section from Gopeed's bilingual GitHub release notes.
|
||||
String localizedReleaseNotes(String fullChangeLog, String languageCode) {
|
||||
final isChinese = languageCode.toLowerCase().startsWith('zh');
|
||||
@@ -175,23 +98,23 @@ typedef UpdateProgressCallback = void Function(int received, int total);
|
||||
/// Store-distributed and unsupported channels safely open the release page.
|
||||
Future<void> updateApp(
|
||||
VersionInfo versionInfo, {
|
||||
required ExtraConfigGithubMirror githubMirror,
|
||||
required GopeedService service,
|
||||
required UpdateProgressCallback onProgress,
|
||||
}) async {
|
||||
final channel = updateChannel;
|
||||
final assetName = updateAssetName(versionInfo.version, channel: channel);
|
||||
final assetName = appUpdateAssetName(versionInfo.version, channel, getArch().name);
|
||||
var assetPath = '';
|
||||
|
||||
if (assetName.isNotEmpty) {
|
||||
final rawUrl = 'https://github.com/GopeedLab/gopeed/releases/download/v${versionInfo.version}/$assetName';
|
||||
final expectedSha256 = versionInfo.assetSha256[assetName];
|
||||
if (expectedSha256 == null) throw const DownloadChecksumException();
|
||||
onProgress(0, 0);
|
||||
assetPath = path.join((await getTemporaryDirectory()).path, assetName);
|
||||
final downloadUrl = await githubAutoMirror(rawUrl, MirrorType.githubRelease, config: githubMirror);
|
||||
final client = Dio();
|
||||
try {
|
||||
await client.download(downloadUrl, assetPath, onReceiveProgress: onProgress);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
final urls = await service.getMirrorCandidates(
|
||||
'github',
|
||||
'https://github.com/GopeedLab/gopeed/releases/download/v${versionInfo.version}/$assetName',
|
||||
);
|
||||
await downloadFromCandidates(urls, assetPath, expectedSha256: expectedSha256, onReceiveProgress: onProgress);
|
||||
}
|
||||
|
||||
switch (channel) {
|
||||
@@ -233,33 +156,3 @@ Future<void> _openRelease(String releaseUrl) async {
|
||||
final opened = await launchUrl(Uri.parse(releaseUrl), mode: LaunchMode.externalApplication);
|
||||
if (!opened) throw StateError('Unable to open the release page.');
|
||||
}
|
||||
|
||||
String updateAssetName(String version, {UpdateChannel? channel, Architecture? architecture}) {
|
||||
final targetChannel = channel ?? updateChannel;
|
||||
final arch = architecture ?? getArch();
|
||||
|
||||
String commonArchName() => switch (arch) {
|
||||
Architecture.ia32 => '386',
|
||||
Architecture.x64 => 'amd64',
|
||||
_ => arch.name,
|
||||
};
|
||||
|
||||
return switch (targetChannel) {
|
||||
UpdateChannel.windowsInstaller => 'Gopeed-v$version-windows-${commonArchName()}.zip',
|
||||
UpdateChannel.windowsPortable => 'Gopeed-v$version-windows-${commonArchName()}-portable.zip',
|
||||
UpdateChannel.macosDmg => 'Gopeed-v$version-macos-${commonArchName()}.dmg',
|
||||
UpdateChannel.linuxDeb => 'Gopeed-v$version-linux-${commonArchName()}.deb',
|
||||
UpdateChannel.androidApk => _androidAssetName(version, arch),
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
String _androidAssetName(String version, Architecture arch) {
|
||||
final archName = switch (arch) {
|
||||
Architecture.arm => 'armeabi-v7a',
|
||||
Architecture.arm64 => 'arm64-v8a',
|
||||
Architecture.x64 => 'x86_64',
|
||||
_ => null,
|
||||
};
|
||||
return 'Gopeed-v$version-android${archName == null ? '' : '-$archName'}.apk';
|
||||
}
|
||||
@@ -8,71 +8,136 @@ import 'package:gopeed/api/gopeed_site_api.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
void main() {
|
||||
test('summary requests are shared and cached; explicit refresh requests fresh data', () async {
|
||||
final pending = Completer<Response<String>>();
|
||||
final transport = _Transport()..onProxy = (_) => pending.future;
|
||||
late Dio client;
|
||||
late _SiteAdapter adapter;
|
||||
late GopeedSiteApi site;
|
||||
late _Transport transport;
|
||||
|
||||
setUp(() {
|
||||
client = Dio();
|
||||
adapter = _SiteAdapter();
|
||||
client.httpClientAdapter = adapter;
|
||||
site = GopeedSiteApi(client: client);
|
||||
transport = _Transport();
|
||||
api.setTransportForTesting(transport);
|
||||
final site = GopeedSiteApi.instance;
|
||||
});
|
||||
tearDown(() {
|
||||
client.close(force: true);
|
||||
expect(transport.calls, 0);
|
||||
for (final request in adapter.requests) {
|
||||
expect(request.uri.origin, 'https://gopeed.com');
|
||||
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
|
||||
}
|
||||
});
|
||||
|
||||
test('install counts are reported directly with JSON and failures remain observable', () async {
|
||||
await site.reportExtensionInstall('开发者@name');
|
||||
final request = adapter.requests.single;
|
||||
expect(request.uri.toString(), 'https://gopeed.com/api/extensions/install');
|
||||
expect(request.method, 'POST');
|
||||
expect(request.contentType, Headers.jsonContentType);
|
||||
expect(request.data, {'id': '开发者@name'});
|
||||
adapter.statusCode = 503;
|
||||
await expectLater(site.reportExtensionInstall('test'), throwsA(isA<DioException>()));
|
||||
});
|
||||
|
||||
test('summary requests go directly to the website and remain shared and cached', () async {
|
||||
final pending = Completer<Map<String, dynamic>>();
|
||||
final started = Completer<void>();
|
||||
adapter.onRequest = (_) {
|
||||
if (!started.isCompleted) started.complete();
|
||||
return pending.future;
|
||||
};
|
||||
final first = site.getExtensions(query: 'cache-test', forceRefresh: true);
|
||||
final second = site.getExtensions(query: 'cache-test');
|
||||
expect(transport.urls.length, 1);
|
||||
expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary');
|
||||
pending.complete(
|
||||
_response({
|
||||
'data': [
|
||||
{'id': 'test'},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await started.future;
|
||||
expect(adapter.requests.length, 1);
|
||||
final request = adapter.requests.single;
|
||||
expect(request.method, 'GET');
|
||||
expect(request.uri.path, '/api/extensions');
|
||||
expect(request.uri.queryParameters, {
|
||||
'view': 'summary',
|
||||
'page': '1',
|
||||
'limit': '20',
|
||||
'sort': 'stars',
|
||||
'order': 'desc',
|
||||
'q': 'cache-test',
|
||||
});
|
||||
pending.complete({
|
||||
'data': [
|
||||
{'id': 'test'},
|
||||
],
|
||||
});
|
||||
expect((await first).data.single.hasDetails, isFalse);
|
||||
await second;
|
||||
await site.getExtensions(query: 'cache-test');
|
||||
expect(transport.urls.length, 1);
|
||||
expect(adapter.requests.length, 1);
|
||||
await site.getExtensions(query: 'cache-test', forceRefresh: true);
|
||||
expect(transport.urls.length, 2);
|
||||
expect(adapter.requests.length, 2);
|
||||
});
|
||||
|
||||
test('failed lists are not cached', () async {
|
||||
final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline'));
|
||||
api.setTransportForTesting(transport);
|
||||
final site = GopeedSiteApi.instance;
|
||||
await expectLater(site.getExtensions(query: 'error-test'), throwsStateError);
|
||||
transport.onProxy = (_) async => _response({
|
||||
adapter.statusCode = 503;
|
||||
await expectLater(site.getExtensions(query: 'error-test'), throwsA(isA<DioException>()));
|
||||
adapter.statusCode = 200;
|
||||
adapter.onRequest = (_) async => {
|
||||
'data': [
|
||||
{'id': 'recovered'},
|
||||
],
|
||||
});
|
||||
};
|
||||
expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered');
|
||||
expect(transport.urls.length, 2);
|
||||
expect(adapter.requests.length, 2);
|
||||
});
|
||||
|
||||
test('detail IDs are encoded once and a null README is still loaded detail', () async {
|
||||
final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null});
|
||||
api.setTransportForTesting(transport);
|
||||
final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3');
|
||||
final uri = Uri.parse(transport.urls.single);
|
||||
test('detail IDs are encoded once and version remains a query parameter', () async {
|
||||
adapter.onRequest = (_) async => {'id': '开发者@name', 'readme': null};
|
||||
final result = await site.getExtension('开发者@name', version: '1.2.3');
|
||||
final uri = adapter.requests.single.uri;
|
||||
expect(uri.pathSegments, ['api', 'extensions', '开发者@name']);
|
||||
expect(uri.queryParameters['version'], '1.2.3');
|
||||
expect(result.hasDetails, isTrue);
|
||||
await site.getExtension('literal%2Fname');
|
||||
expect(adapter.requests.last.uri.pathSegments.last, 'literal%2Fname');
|
||||
});
|
||||
}
|
||||
|
||||
Response<String> _response(Map<String, dynamic> json) => Response(
|
||||
requestOptions: RequestOptions(path: '/test'),
|
||||
data: jsonEncode(json),
|
||||
statusCode: 200,
|
||||
);
|
||||
|
||||
class _Transport implements GopeedTransport {
|
||||
final urls = <String>[];
|
||||
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
|
||||
|
||||
int calls = 0;
|
||||
@override
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||
urls.add(uri);
|
||||
return onProxy(uri);
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
calls++;
|
||||
throw StateError('Store requests must not use the Downloader transport');
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
String join(String path) => path;
|
||||
}
|
||||
|
||||
class _SiteAdapter implements HttpClientAdapter {
|
||||
final requests = <RequestOptions>[];
|
||||
int statusCode = 200;
|
||||
Future<Map<String, dynamic>> Function(RequestOptions) onRequest = (_) async => {'data': []};
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<List<int>>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
requests.add(options);
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode(await onRequest(options)),
|
||||
statusCode,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
void main() {
|
||||
test('protocol jobs use the common protocol and job name API', () async {
|
||||
final transport = _Transport();
|
||||
api.setTransportForTesting(transport);
|
||||
await LocalAppCapabilities.instance.capabilities.gopeed.triggerProtocolJob('bt', 'update-trackers');
|
||||
expect(transport.path, 'api/v1/protocols/bt/jobs/update-trackers/trigger');
|
||||
expect(transport.method, 'POST');
|
||||
});
|
||||
|
||||
test('mirror settings and tracker settings serialize at their backend-owned locations', () {
|
||||
final config = DownloaderConfig();
|
||||
config.githubMirror = GithubMirrorConfig(enabled: false, mirrors: []);
|
||||
config.protocolConfig.bt.customTrackers = ['udp://example.com:80/announce'];
|
||||
config.protocolConfig.bt.state.trackerSubscriptionStatuses = {
|
||||
'https://example.com/list': TrackerSubscriptionStatus(
|
||||
success: true,
|
||||
trackerCount: 12,
|
||||
lastFetchTime: DateTime.utc(2026, 10, 19),
|
||||
),
|
||||
};
|
||||
final json = config.toJson();
|
||||
expect(json['githubMirror'], {'enabled': false, 'mirrors': []});
|
||||
expect(json['extra'], isNot(contains('githubMirror')));
|
||||
expect(json['extra'], isNot(contains('bt')));
|
||||
expect((json['protocolConfig'] as Map)['bt']['customTrackers'], ['udp://example.com:80/announce']);
|
||||
final restored = DownloaderConfig.fromJson(
|
||||
json,
|
||||
).protocolConfig.bt.state.trackerSubscriptionStatuses['https://example.com/list']!;
|
||||
expect(restored.success, isTrue);
|
||||
expect(restored.trackerCount, 12);
|
||||
expect(restored.lastFetchTime, DateTime.utc(2026, 10, 19));
|
||||
expect(BtState.fromJson({'lastTrackerUpdateTime': '0001-01-01T00:00:00Z'}).lastTrackerUpdateTime, isNull);
|
||||
});
|
||||
|
||||
test('protocol operations survive the shared capability codec', () {
|
||||
final codecs = createAppCapabilityCodecs();
|
||||
final payload = {'protocol': 'bt', 'name': 'update-trackers'};
|
||||
expect(codecs.decode<Map<String, dynamic>>(codecs.encode(payload)), payload);
|
||||
expect(codecs.decode<Map<String, dynamic>?>(null), isNull);
|
||||
expect(GopeedMethods.triggerProtocolJob.name, 'gopeed.protocol.job.trigger');
|
||||
expect(const RpcUnit(), isA<RpcUnit>());
|
||||
});
|
||||
|
||||
test('mirror candidates use provider dispatch and the shared capability codec', () async {
|
||||
final transport = _Transport();
|
||||
api.setTransportForTesting(transport);
|
||||
const url = 'https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip';
|
||||
final result = await LocalAppCapabilities.instance.capabilities.gopeed.getMirrorCandidates('github', url);
|
||||
expect(transport.path, 'api/v1/mirrors/github/candidates');
|
||||
expect(transport.method, 'POST');
|
||||
expect(transport.data, {'url': url});
|
||||
expect(result, ['https://example.com/package.dmg']);
|
||||
final codecs = createAppCapabilityCodecs();
|
||||
expect(codecs.decode<List<String>>(codecs.encode(result)), result);
|
||||
expect(GopeedMethods.getMirrorCandidates.name, 'gopeed.mirrors.candidates');
|
||||
final request = {'provider': 'another-resource', 'url': 'https://example.com/resource'};
|
||||
expect(codecs.decode<Map<String, dynamic>>(codecs.encode(request)), request);
|
||||
await LocalAppCapabilities.instance.capabilities.gopeed.getMirrorCandidates('another-resource', request['url']!);
|
||||
expect(transport.path, 'api/v1/mirrors/another-resource/candidates');
|
||||
expect(transport.data, {'url': request['url']});
|
||||
});
|
||||
}
|
||||
|
||||
class _Transport implements GopeedTransport {
|
||||
String? path;
|
||||
String? method;
|
||||
dynamic data;
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
this.path = path;
|
||||
this.method = method;
|
||||
this.data = data;
|
||||
return {
|
||||
'code': 0,
|
||||
'data': path.endsWith('/trigger')
|
||||
? null
|
||||
: {
|
||||
'urls': ['https://example.com/package.dmg'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
Loaded 50 of 98 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user