refactor: remove proxy API (#1596)

This commit is contained in:
Levi authored and GitHub committed 2026-10-21 22:48:17 +08:00
1 parent 47cafb7575
commit 4214765d39
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
+13 -5
View File
@@ -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),
);
+30 -45
View File
@@ -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});
}
}
+43 -26
View File
@@ -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)),
],
),
),
@@ -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);
}
}
}
@@ -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)}';
}
}
@@ -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),
),
),
),
),
],
),
);
}
}
+10 -1
View File
@@ -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": "الإجراءات"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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": "عملیات"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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": "操作"
}
+10 -1
View File
@@ -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": "작업"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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": "Действия"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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": "செயல்கள்"
}
+10 -1
View File
@@ -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"
}
+10 -1
View File
@@ -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": "Дії"
}
+10 -1
View File
@@ -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"
}
+11 -2
View File
@@ -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": "操作"
}
+11 -2
View File
@@ -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": "操作"
}
-50
View File
@@ -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;
}
+34 -141
View File
@@ -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';
}
+105 -40
View File
@@ -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