feat: move native communication to FFI (#1457)

This commit is contained in:
Levi authored and GitHub committed 2026-09-05 20:18:42 +08:00
1 parent 177c225f16
commit 39cd9034cd
86 files changed
+4558 -886

No files matched your search

+45 -105
View File
@@ -1,8 +1,6 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import '../core/network/gopeed/gopeed_transport.dart';
import 'model/create_task.dart';
import 'model/create_task_batch.dart';
@@ -27,77 +25,35 @@ class ApiTimeoutException implements Exception {
String toString() => message;
}
class _Client {
_Client._();
GopeedTransport? _transport;
static final _Client instance = _Client._();
late Dio dio;
bool initialized = false;
void init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) {
final isUnixSocket = network == 'unix';
final baseUrl = isUnixSocket
? 'http://127.0.0.1/'
: kIsWeb
? kDebugMode
? 'http://127.0.0.1:9999/'
: ''
: 'http://$address/';
dio = Dio(
BaseOptions(
baseUrl: baseUrl,
contentType: Headers.jsonContentType,
sendTimeout: const Duration(seconds: 5),
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 120),
),
);
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
handler.next(options);
},
onError: (error, handler) {
if (kIsWeb && error.response?.statusCode == 401) {
onUnauthorized?.call();
}
handler.next(error);
},
),
);
if (isUnixSocket) {
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) {
return Socket.startConnect(InternetAddress(address, type: InternetAddressType.unix), 0);
};
return client;
};
}
initialized = true;
}
void setTransportForTesting(GopeedTransport transport) {
_transport = transport;
}
void init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) {
_Client.instance.init(network, address, apiToken, onUnauthorized: onUnauthorized);
void init(String network, String address, String apiToken, {void Function()? onUnauthorized}) {
_transport = createGopeedTransport(
GopeedTransportConfig(network: network, address: address, apiToken: apiToken, onUnauthorized: onUnauthorized),
);
}
void initDefault({String address = '127.0.0.1:9999', String apiToken = ''}) {
if (!_Client.instance.initialized) {
if (_transport == null) {
init('tcp', address, apiToken);
}
}
Future<T> _parse<T>(Future<Response<dynamic>> Function() fetch, T Function(dynamic json)? fromJsonT) async {
Future<dynamic> _request(String path, {String method = 'GET', dynamic data, Map<String, dynamic>? queryParameters}) {
initDefault();
return _transport!.request(path, method: method, data: data, queryParameters: queryParameters);
}
Future<T> _parse<T>(Future<dynamic> Function() fetch, T Function(dynamic json)? fromJsonT) async {
initDefault();
try {
final resp = await fetch();
fromJsonT ??= (json) => null as T;
final result = Result<T>.fromJson(resp.data, fromJsonT);
final result = Result<T>.fromJson(resp as Map<String, dynamic>, fromJsonT);
if (result.code == 0) {
return result.data as T;
}
@@ -119,169 +75,153 @@ Future<ResolveResult> resolve(ResolveTask resolveTask) {
throw ArgumentError('resolve request is required');
}
return _parse<ResolveResult>(
() => _Client.instance.dio.post('api/v1/resolve', data: resolveTask),
() => _request('api/v1/resolve', method: 'POST', data: resolveTask),
(data) => ResolveResult.fromJson(data as Map<String, dynamic>),
);
}
Future<String> createTask(CreateTask createTask) {
return _parse<String>(() => _Client.instance.dio.post('api/v1/tasks', data: createTask), (data) => data as String);
return _parse<String>(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String);
}
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) {
return _parse<List<String>>(
() => _Client.instance.dio.post('api/v1/tasks/batch', data: createTaskBatch),
() => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch),
(data) => (data as List<dynamic>).map((e) => e as String).toList(),
);
}
Future<void> patchTask(String id, ResolveTask patchTask) {
return _parse<void>(() => _Client.instance.dio.patch('api/v1/tasks/$id', data: patchTask), null);
return _parse<void>(() => _request('api/v1/tasks/$id', method: 'PATCH', data: patchTask), null);
}
Future<List<Task>> getTasks(List<Status> statuses) {
final query = statuses.map((e) => 'status=${e.name}').join('&');
return _parse<List<Task>>(
() => _Client.instance.dio.get('/api/v1/tasks?$query'),
() => _request('/api/v1/tasks?$query'),
(data) => (data as List<dynamic>).map((e) => Task.fromJson(e as Map<String, dynamic>)).toList(),
);
}
Future<TaskRuntimeStatus> getTaskStatus(String id) {
return _parse<TaskRuntimeStatus>(
() => _Client.instance.dio.get('/api/v1/tasks/$id/status'),
() => _request('/api/v1/tasks/$id/status'),
(data) => TaskRuntimeStatus.fromJson(data as Map<String, dynamic>),
);
}
Future<Map<String, dynamic>> getTaskStats(String id) {
return _parse<Map<String, dynamic>>(
() => _Client.instance.dio.get('/api/v1/tasks/$id/stats'),
() => _request('/api/v1/tasks/$id/stats'),
(data) =>
data is Map ? {for (final entry in data.entries) entry.key.toString(): entry.value} : const <String, dynamic>{},
);
}
Future<void> pauseTask(String id) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/$id/pause'), null);
return _parse<void>(() => _request('api/v1/tasks/$id/pause', method: 'PUT'), null);
}
Future<void> continueTask(String id) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/$id/continue'), null);
return _parse<void>(() => _request('api/v1/tasks/$id/continue', method: 'PUT'), null);
}
Future<void> pauseAllTasks(List<String>? ids) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/pause', queryParameters: {'id': ids}), null);
return _parse<void>(() => _request('api/v1/tasks/pause', method: 'PUT', queryParameters: {'id': ids}), null);
}
Future<void> continueAllTasks(List<String>? ids) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/continue', queryParameters: {'id': ids}), null);
return _parse<void>(() => _request('api/v1/tasks/continue', method: 'PUT', queryParameters: {'id': ids}), null);
}
Future<void> deleteTask(String id, bool force) {
return _parse<void>(() => _Client.instance.dio.delete('api/v1/tasks/$id?force=$force'), null);
return _parse<void>(() => _request('api/v1/tasks/$id', method: 'DELETE', queryParameters: {'force': force}), null);
}
Future<void> deleteTasks(List<String>? ids, bool force) {
return _parse<void>(
() => _Client.instance.dio.delete('api/v1/tasks', queryParameters: {'id': ids, 'force': force}),
() => _request('api/v1/tasks', method: 'DELETE', queryParameters: {'id': ids, 'force': force}),
null,
);
}
Future<DownloaderConfig> getConfig() {
return _parse<DownloaderConfig>(
() => _Client.instance.dio.get('api/v1/config'),
() => _request('api/v1/config'),
(data) => DownloaderConfig.fromJson(data as Map<String, dynamic>),
);
}
Future<void> putConfig(DownloaderConfig config) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/config', data: config.toJson()), null);
return _parse<void>(() => _request('api/v1/config', method: 'PUT', data: config.toJson()), null);
}
Future<String> installExtension(InstallExtension installExtension) {
return _parse<String>(
() => _Client.instance.dio.post('api/v1/extensions', data: installExtension.toJson()),
() => _request('api/v1/extensions', method: 'POST', data: installExtension.toJson()),
(data) => data as String,
);
}
Future<List<Extension>> getExtensions() {
return _parse<List<Extension>>(
() => _Client.instance.dio.get('api/v1/extensions'),
() => _request('api/v1/extensions'),
(data) => (data as List<dynamic>).map((e) => Extension.fromJson(e as Map<String, dynamic>)).toList(),
);
}
Future<void> updateExtensionSettings(String identity, UpdateExtensionSettings updateExtensionSettings) {
return _parse<void>(
() => _Client.instance.dio.put('api/v1/extensions/$identity/settings', data: updateExtensionSettings.toJson()),
() => _request('api/v1/extensions/$identity/settings', method: 'PUT', data: updateExtensionSettings.toJson()),
null,
);
}
Future<void> switchExtension(String identity, SwitchExtension switchExtension) {
return _parse<void>(
() => _Client.instance.dio.put('api/v1/extensions/$identity/switch', data: switchExtension.toJson()),
() => _request('api/v1/extensions/$identity/switch', method: 'PUT', data: switchExtension.toJson()),
null,
);
}
Future<void> deleteExtension(String identity) {
return _parse<void>(() => _Client.instance.dio.delete('api/v1/extensions/$identity'), null);
return _parse<void>(() => _request('api/v1/extensions/$identity', method: 'DELETE'), null);
}
Future<UpdateCheckExtensionResp> upgradeCheckExtension(String identity) {
return _parse<UpdateCheckExtensionResp>(
() => _Client.instance.dio.get('api/v1/extensions/$identity/update'),
() => _request('api/v1/extensions/$identity/update'),
(data) => UpdateCheckExtensionResp.fromJson(data as Map<String, dynamic>),
);
}
Future<void> updateExtension(String identity) {
return _parse<void>(() => _Client.instance.dio.post('api/v1/extensions/$identity/update'), null);
return _parse<void>(() => _request('api/v1/extensions/$identity/update', method: 'POST'), null);
}
Future<void> testWebhook(String url) {
return _parse<void>(() => _Client.instance.dio.post('api/v1/webhook/test', data: {'url': url}), null);
return _parse<void>(() => _request('api/v1/webhook/test', method: 'POST', data: {'url': url}), null);
}
Future<void> login(LoginReq loginReq) {
return _parse<void>(() => _Client.instance.dio.post('api/web/login', data: loginReq.toJson()), null);
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();
options ??= Options();
options.headers ??= {};
options.headers!['X-Target-Uri'] = uri;
return _Client.instance.dio.request<String>(
'/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}',
data: data,
options: options,
);
return _transport!.proxyRequest(uri, data: data, options: options);
}
String join(String path) {
initDefault();
final baseUrl = _Client.instance.dio.options.baseUrl;
final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
return '$cleanBaseUrl/$cleanPath';
return _transport!.join(path);
}
Future<Response<dynamic>> forward(
Future<dynamic> forward(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
initDefault();
return _Client.instance.dio.request<dynamic>(
path,
data: data,
queryParameters: queryParameters,
options: Options(method: method),
);
return _request(path, method: method, data: data, queryParameters: queryParameters);
}
@@ -13,6 +13,7 @@ class DownloaderConfig {
ScriptConfig script = ScriptConfig();
AutoTorrentConfig autoTorrent = AutoTorrentConfig();
ArchiveConfig archive = ArchiveConfig();
ApiServerConfig api = ApiServerConfig();
bool autoStartTasks;
bool autoDeleteMissingFileTasks;
@@ -28,6 +29,21 @@ class DownloaderConfig {
Map<String, dynamic> toJson() => _$DownloaderConfigToJson(this);
}
@JsonSerializable()
class ApiServerConfig {
bool enable;
String network;
String address;
String token;
ApiServerConfig({this.enable = false, this.network = 'tcp', this.address = '127.0.0.1:9999', this.token = ''});
factory ApiServerConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? ApiServerConfig() : _$ApiServerConfigFromJson(json);
Map<String, dynamic> toJson() => _$ApiServerConfigToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ProtocolConfig {
HttpConfig http = HttpConfig();
@@ -102,6 +118,12 @@ class ExtraConfig {
bool notifyWhenNewVersion;
bool desktopNotification;
bool backgroundLocationKeepAlive;
WindowStateConfig windowState;
Map<String, String> bookmarks;
List<String> createHistory;
bool runAsMenubarApp;
bool analyticsEnabled;
String analyticsClientId;
List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt();
@@ -117,8 +139,14 @@ class ExtraConfig {
this.notifyWhenNewVersion = true,
this.desktopNotification = true,
this.backgroundLocationKeepAlive = false,
WindowStateConfig? windowState,
this.bookmarks = const {},
this.createHistory = const [],
this.runAsMenubarApp = false,
this.analyticsEnabled = true,
this.analyticsClientId = '',
this.downloadCategories = const [],
});
}) : windowState = windowState ?? WindowStateConfig();
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? ExtraConfig() : _$ExtraConfigFromJson(json);
@@ -126,6 +154,20 @@ class ExtraConfig {
Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
}
@JsonSerializable()
class WindowStateConfig {
bool isMaximized;
double? width;
double? height;
WindowStateConfig({this.isMaximized = false, this.width, this.height});
factory WindowStateConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? WindowStateConfig() : _$WindowStateConfigFromJson(json);
Map<String, dynamic> toJson() => _$WindowStateConfigToJson(this);
}
@JsonSerializable()
class DownloadCategory {
String name;
@@ -19,7 +19,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
..autoTorrent = AutoTorrentConfig.fromJson(json['autoTorrent'] as Map<String, dynamic>?)
..archive = ArchiveConfig.fromJson(json['archive'] as Map<String, dynamic>?);
..archive = ArchiveConfig.fromJson(json['archive'] as Map<String, dynamic>?)
..api = ApiServerConfig.fromJson(json['api'] as Map<String, dynamic>?);
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <String, dynamic>{
'downloadDir': instance.downloadDir,
@@ -31,10 +32,25 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
'script': instance.script.toJson(),
'autoTorrent': instance.autoTorrent.toJson(),
'archive': instance.archive.toJson(),
'api': instance.api.toJson(),
'autoStartTasks': instance.autoStartTasks,
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
};
ApiServerConfig _$ApiServerConfigFromJson(Map<String, dynamic> json) => ApiServerConfig(
enable: json['enable'] as bool? ?? false,
network: json['network'] as String? ?? 'tcp',
address: json['address'] as String? ?? '127.0.0.1:9999',
token: json['token'] as String? ?? '',
);
Map<String, dynamic> _$ApiServerConfigToJson(ApiServerConfig instance) => <String, dynamic>{
'enable': instance.enable,
'network': instance.network,
'address': instance.address,
'token': instance.token,
};
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolConfig()
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)
@@ -101,6 +117,14 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) =>
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
desktopNotification: json['desktopNotification'] as bool? ?? true,
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] 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>))
@@ -120,11 +144,29 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
'desktopNotification': instance.desktopNotification,
'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive,
'windowState': instance.windowState.toJson(),
'bookmarks': instance.bookmarks,
'createHistory': instance.createHistory,
'runAsMenubarApp': instance.runAsMenubarApp,
'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(
isMaximized: json['isMaximized'] as bool? ?? false,
width: (json['width'] as num?)?.toDouble(),
height: (json['height'] as num?)?.toDouble(),
);
Map<String, dynamic> _$WindowStateConfigToJson(WindowStateConfig instance) => <String, dynamic>{
'isMaximized': instance.isMaximized,
'width': ?instance.width,
'height': ?instance.height,
};
DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) => DownloadCategory(
name: json['name'] as String,
path: json['path'] as String,
@@ -7,8 +7,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../../api/model/task.dart';
import '../../core/capabilities/app_capabilities.dart';
import '../../core/common/task_event.dart';
import '../../core/libgopeed_boot.dart';
import '../../l10n/l10n.dart';
import '../../util/util.dart';
import 'app_runtime_controller.dart';
@@ -25,8 +25,7 @@ class AppNotificationState {
class AppNotificationController extends AsyncNotifier<AppNotificationState> {
final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin();
final Map<String, Status> _previousStatus = {};
Timer? _timer;
StreamSubscription<TaskEvent>? _taskEventSubscription;
var _notificationId = 0;
@override
@@ -36,9 +35,9 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
return const AppNotificationState();
}
await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale));
_startPolling();
_listenTaskEvents();
ref.onDispose(() {
_timer?.cancel();
unawaited(_taskEventSubscription?.cancel());
});
return const AppNotificationState(started: true);
}
@@ -77,30 +76,18 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
);
}
void _startPolling() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 2), (_) async {
try {
final runtime = ref.read(appRuntimeControllerProvider).value;
if (runtime?.downloaderConfig.extra.desktopNotification == false) {
return;
}
final tasks = await ref.read(gopeedServiceProvider).getTasks(Status.values);
final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? '');
for (final task in tasks) {
final previous = _previousStatus[task.id];
if (previous != null && previous != task.status) {
if (task.status == Status.done) {
await _showNotification(title: locale.notificationTaskDone, body: task.name);
} else if (task.status == Status.error) {
await _showNotification(title: locale.notificationTaskError, body: task.name);
}
}
_previousStatus[task.id] = task.status;
}
final currentIds = tasks.map((task) => task.id).toSet();
_previousStatus.removeWhere((id, _) => !currentIds.contains(id));
} catch (_) {}
void _listenTaskEvents() {
_taskEventSubscription?.cancel();
_taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async {
final runtime = ref.read(appRuntimeControllerProvider).value;
if (runtime?.downloaderConfig.extra.desktopNotification == false) return;
final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? '');
switch (event.type) {
case TaskEventType.done:
await _showNotification(title: locale.notificationTaskDone, body: event.name);
case TaskEventType.error:
await _showNotification(title: locale.notificationTaskError, body: event.name);
}
});
}
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart' show Size;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:launch_at_startup/launch_at_startup.dart';
@@ -15,8 +16,6 @@ import '../../core/libgopeed_boot.dart';
import '../../core/capabilities/app_capabilities.dart';
import '../../core/window/app_window_launcher.dart';
import '../../core/window/window_capability_transport.dart';
import '../../database/database.dart';
import '../../database/entity.dart';
import '../../features/tasks/application/pending_create_task.dart';
import '../../features/tasks/application/pending_update_task.dart';
import '../../l10n/l10n.dart';
@@ -64,9 +63,13 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
VersionInfo? _latestVersion;
String? _updateError;
Future<VersionInfo?>? _checkUpdateFuture;
final _windowsResizeSave = Util.debounce(() async {
late final void Function() _windowsResizeSave = Util.debounce(() async {
final size = await windowManager.getSize();
Database.instance.saveWindowState(WindowStateEntity(width: size.width, height: size.height));
await ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) {
extra.windowState
..width = size.width
..height = size.height;
});
}, 500);
@override
@@ -103,11 +106,13 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
Future<void> _start() async {
_started = true;
final extra = ref.read(appRuntimeControllerProvider).value!.downloaderConfig.extra;
await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry);
await _restoreWindowState(extra.windowState);
windowManager.addListener(this);
await windowManager.setPreventClose(true);
if (Util.isMacos()) {
_runAsMenubarApp = Database.instance.getRunAsMenubarApp();
_runAsMenubarApp = extra.runAsMenubarApp;
await windowManager.setSkipTaskbar(_runAsMenubarApp);
}
await _initTray();
@@ -245,7 +250,9 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
if (!Util.isMacos()) {
return;
}
Database.instance.saveRunAsMenubarApp(enabled);
await ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) {
extra.runAsMenubarApp = enabled;
});
_runAsMenubarApp = enabled;
await windowManager.setSkipTaskbar(enabled);
await Future<void>.delayed(const Duration(milliseconds: 100));
@@ -369,16 +376,36 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
@override
void onWindowMaximize() {
Database.instance.saveWindowState(WindowStateEntity(isMaximized: true));
unawaited(
ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) {
extra.windowState.isMaximized = true;
}),
);
}
@override
void onWindowUnmaximize() {
Database.instance.saveWindowState(WindowStateEntity(isMaximized: false));
unawaited(
ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) {
extra.windowState.isMaximized = false;
}),
);
}
@override
void onWindowResize() {
_windowsResizeSave();
}
Future<void> _restoreWindowState(WindowStateConfig windowState) async {
final width = windowState.width;
final height = windowState.height;
if (width != null && height != null && width > 0 && height > 0) {
await windowManager.setSize(Size(width, height));
await windowManager.center();
}
if (windowState.isMaximized) {
await windowManager.maximize();
}
}
}
@@ -8,14 +8,15 @@ import 'package:path_provider/path_provider.dart';
import '../../api/api.dart' as api;
import '../../api/model/downloader_config.dart';
import '../../core/capabilities/app_capabilities.dart';
import '../../core/common/api_server_state.dart';
import '../../core/common/start_config.dart';
import '../../core/common/task_event.dart';
import '../../core/entry/app_initializer.dart';
import '../../core/libgopeed_boot.dart';
import '../../database/database.dart';
import '../../database/entity.dart';
import '../../features/auth/application/web_auth_controller.dart';
import '../../l10n/l10n.dart';
import '../../util/log_util.dart';
import '../../util/analytics.dart';
import '../../util/package_info.dart';
import '../../util/util.dart';
import '../rpc/webview_rpc_service.dart';
@@ -31,28 +32,26 @@ final appRuntimeControllerProvider = AsyncNotifierProvider<AppRuntimeController,
class AppRuntimeState {
const AppRuntimeState({
required this.startConfig,
required this.runningPort,
required this.apiServerState,
required this.downloaderConfig,
this.localBackendStarted = false,
this.startupError,
});
final StartConfig startConfig;
final int runningPort;
final ApiServerState apiServerState;
final DownloaderConfig downloaderConfig;
final bool localBackendStarted;
final Object? startupError;
String runningAddress() {
if (startConfig.network == 'unix') {
return startConfig.address;
}
return '${startConfig.address.split(':').first}:$runningPort';
}
int get runningPort => apiServerState.runningPort;
String runningAddress() => apiServerState.runningAddress();
}
class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
static StartConfig? _defaultStartConfig;
Future<void> _preferenceWrites = Future<void>.value();
@override
Future<AppRuntimeState> build() async {
@@ -63,11 +62,18 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
Future<void> reloadConfig() async {
final current = state.value;
if (current == null) return;
final config = await ref.read(gopeedServiceProvider).getConfig();
final apiState = kIsWeb ? current.apiServerState : (await LibgopeedBoot.instance.getApiServerState()).state;
final startConfig = _copyStartConfig(current.startConfig)
..apiEnable = config.api.enable
..network = config.api.network
..address = config.api.address
..apiToken = config.api.token;
state = AsyncValue.data(
AppRuntimeState(
startConfig: current.startConfig,
runningPort: current.runningPort,
downloaderConfig: await ref.read(gopeedServiceProvider).getConfig(),
startConfig: startConfig,
apiServerState: apiState,
downloaderConfig: config,
localBackendStarted: current.localBackendStarted,
startupError: current.startupError,
),
@@ -80,7 +86,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
state = AsyncValue.data(
AppRuntimeState(
startConfig: current.startConfig,
runningPort: current.runningPort,
apiServerState: current.apiServerState,
downloaderConfig: DownloaderConfig.fromJson(config.toJson()),
localBackendStarted: current.localBackendStarted,
startupError: current.startupError,
@@ -90,7 +96,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
}
Future<AppRuntimeState> _init() async {
await AppInitializer.ensureDatabaseInitialized();
await AppInitializer.ensureStorageInitialized();
try {
await initPackageInfo();
} catch (_) {}
@@ -104,37 +110,57 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
}
Object? startupError;
var runningPort = 0;
var apiServerState = const ApiServerState(
enabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
);
var localBackendStarted = false;
try {
// Go's rest.Start is already idempotent: when its server is running it
// returns the existing port before rebuilding or touching the Unix
// socket. This is also what keeps Riverpod hot reload safe.
runningPort = await LibgopeedBoot.instance.start(startConfig);
await LibgopeedBoot.instance.subscribeTaskEvents({TaskEventType.done, TaskEventType.error});
if (!kIsWeb) {
final statusResult = await LibgopeedBoot.instance.getApiServerState();
apiServerState = statusResult.state;
if (statusResult.error.isNotEmpty) {
logger.w('read API server state failed: ${statusResult.error}');
}
}
localBackendStarted = true;
} catch (error, stackTrace) {
startupError = error;
logger.w('libgopeed init failed, falling back to external API', error, stackTrace);
if (startConfig.network == 'unix') {
startConfig.network = 'tcp';
startConfig.address = '127.0.0.1:9999';
}
runningPort = int.tryParse(startConfig.address.split(':').last) ?? 9999;
logger.e('libgopeed init failed', error, stackTrace);
rethrow;
}
api.init(
startConfig.network,
_runningAddress(startConfig, runningPort),
startConfig.apiToken,
onUnauthorized: ref.read(webAuthControllerProvider).requireLogin,
);
if (kIsWeb) {
final webAuth = ref.read(webAuthControllerProvider);
api.init('tcp', Uri.base.authority, '', onUnauthorized: webAuth.requireLogin);
} else {
api.init(startConfig.network, _runningAddress(startConfig, runningPort), startConfig.apiToken);
}
final config = await _loadDownloaderConfig();
if (!kIsWeb) {
startConfig
..apiEnable = config.api.enable
..network = config.api.network
..address = config.api.address
..apiToken = config.api.token;
}
unawaited(_initTrackerUpdate(config));
unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale)));
await _initAnalytics(config);
final result = AppRuntimeState(
startConfig: startConfig,
runningPort: runningPort,
apiServerState: apiServerState,
downloaderConfig: config,
localBackendStarted: localBackendStarted,
startupError: startupError,
@@ -158,11 +184,10 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
Future<StartConfig> _loadStartConfig() async {
final defaultCfg = await _initDefaultStartConfig();
final saved = Database.instance.getStartConfig();
final cfg = StartConfig()
..network = saved?.network ?? defaultCfg.network
..address = saved?.address ?? defaultCfg.address
..apiToken = saved?.apiToken ?? defaultCfg.apiToken
..network = defaultCfg.network
..address = defaultCfg.address
..apiToken = defaultCfg.apiToken
..storage = defaultCfg.storage
..storageDir = defaultCfg.storageDir
..refreshInterval = defaultCfg.refreshInterval;
@@ -178,6 +203,13 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
..storageDir = Util.getStorageDir()
..refreshInterval = 0
..apiToken = '';
if (kIsWeb) {
cfg
..network = 'tcp'
..address = Uri.base.authority;
_defaultStartConfig = cfg;
return cfg;
}
if (!Util.supportUnixSocket()) {
cfg
..network = 'tcp'
@@ -270,6 +302,20 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
}
}
Future<void> _initAnalytics(DownloaderConfig config) async {
if (!AnalyticsConfig.isConfigured || !config.extra.analyticsEnabled) return;
try {
await Analytics.instance.init(fallbackClientId: config.extra.analyticsClientId);
if (config.extra.analyticsClientId.isEmpty && Analytics.instance.clientId.isNotEmpty) {
config.extra.analyticsClientId = Analytics.instance.clientId;
await ref.read(gopeedServiceProvider).putConfig(config);
}
unawaited(Analytics.instance.logAppOpen());
} catch (error, stackTrace) {
logger.w('GA4 init failed', error, stackTrace);
}
}
Future<void> updateTrackers(DownloaderConfig config) async {
final btExtra = config.extra.bt;
final trackers = <String>[];
@@ -290,7 +336,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
state = AsyncValue.data(
AppRuntimeState(
startConfig: current.startConfig,
runningPort: current.runningPort,
apiServerState: current.apiServerState,
downloaderConfig: config,
localBackendStarted: current.localBackendStarted,
startupError: current.startupError,
@@ -322,11 +368,116 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
return '${config.address.split(':').first}:$runningPort';
}
Future<void> saveStartConfig(StartConfig config) async {
Database.instance.saveStartConfig(
StartConfigEntity(network: config.network, address: config.address, apiToken: config.apiToken),
Future<void> saveApiServerConfig(StartConfig config) async {
if (kIsWeb) return;
await _persistApiServerConfig(config);
}
Future<void> startApiServer() async {
if (kIsWeb) return;
await _persistApiServerEnabled(true);
await _applyApiServerOperation(LibgopeedBoot.instance.startApiServer());
}
Future<void> stopApiServer() async {
if (kIsWeb) return;
await _persistApiServerEnabled(false);
await _applyApiServerOperation(LibgopeedBoot.instance.stopApiServer());
}
Future<void> restartApiServer(StartConfig config) async {
if (kIsWeb) return;
final desired = _copyStartConfig(config)..apiEnable = true;
await _persistApiServerConfig(desired);
await _applyApiServerOperation(LibgopeedBoot.instance.restartApiServer());
}
Future<void> refreshApiServerState() async {
if (kIsWeb) return;
await _applyApiServerOperation(LibgopeedBoot.instance.getApiServerState());
}
Future<void> _persistApiServerConfig(StartConfig config) async {
final current = state.value;
if (current == null) return;
final nextConfig = _copyStartConfig(config);
final downloaderConfig = await ref.read(gopeedServiceProvider).getConfig();
downloaderConfig.api
..enable = nextConfig.apiEnable
..network = nextConfig.network
..address = nextConfig.address
..token = nextConfig.apiToken;
await ref.read(gopeedServiceProvider).putConfig(downloaderConfig);
final statusResult = await LibgopeedBoot.instance.getApiServerState();
_replaceApiRuntime(nextConfig, downloaderConfig, statusResult.state);
if (statusResult.error.isNotEmpty) {
throw StateError(statusResult.error);
}
}
Future<void> _persistApiServerEnabled(bool enabled) async {
final current = state.value;
if (current == null) return;
final downloaderConfig = await ref.read(gopeedServiceProvider).getConfig();
downloaderConfig.api.enable = enabled;
await ref.read(gopeedServiceProvider).putConfig(downloaderConfig);
final nextConfig = _copyStartConfig(current.startConfig)..apiEnable = enabled;
final statusResult = await LibgopeedBoot.instance.getApiServerState();
_replaceApiRuntime(nextConfig, downloaderConfig, statusResult.state);
if (statusResult.error.isNotEmpty) {
throw StateError(statusResult.error);
}
}
Future<void> _applyApiServerOperation(Future<ApiServerOperationResult> operation) async {
final result = await operation;
final current = state.value;
if (current != null) {
_replaceApiRuntime(current.startConfig, current.downloaderConfig, result.state);
}
if (result.error.isNotEmpty) {
throw StateError(result.error);
}
}
void _replaceApiRuntime(StartConfig config, DownloaderConfig downloaderConfig, ApiServerState apiState) {
final current = state.value;
if (current == null) return;
state = AsyncValue.data(
AppRuntimeState(
startConfig: _copyStartConfig(config),
apiServerState: apiState,
downloaderConfig: DownloaderConfig.fromJson(downloaderConfig.toJson()),
localBackendStarted: current.localBackendStarted,
startupError: current.startupError,
),
);
}
StartConfig _copyStartConfig(StartConfig config) {
return StartConfig()
..network = config.network
..address = config.address
..apiEnable = config.apiEnable
..storage = config.storage
..storageDir = config.storageDir
..refreshInterval = config.refreshInterval
..apiToken = config.apiToken
..webViewRpcConfig = config.webViewRpcConfig;
}
Future<void> updateExtraConfig(void Function(ExtraConfig extra) mutation) {
final operation = _preferenceWrites.then((_) async {
final current = state.value;
if (current == null) return;
final config = await ref.read(gopeedServiceProvider).getConfig();
mutation(config.extra);
await ref.read(gopeedServiceProvider).putConfig(config);
replaceDownloaderConfig(config);
});
_preferenceWrites = operation.then<void>((_) {}, onError: (_, _) {});
return operation;
}
}
const allTrackerSubscribeUrls = [
@@ -12,7 +12,6 @@ import '../../api/model/switch_extension.dart';
import '../../api/model/task.dart';
import '../../api/model/update_check_extension_resp.dart';
import '../../api/model/update_extension_settings.dart';
import '../../database/database.dart';
import '../window/app_window_appearance.dart';
import 'capability_rpc.dart';
import 'gopeed_capability.dart';
@@ -147,19 +146,33 @@ void _bindGopeed(CapabilityRegistry registry) {
void _bindStorage(CapabilityRegistry registry) {
registry
..bind(StorageMethods.getCreateHistory, (_) async => Database.instance.getCreateHistory() ?? const <String>[])
..bind(StorageMethods.getCreateHistory, (_) async {
final config = await api.getConfig();
return config.extra.createHistory;
})
..bind(StorageMethods.saveCreateHistory, (urls) async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory);
for (final url in urls) {
Database.instance.saveCreateHistory(url);
history.remove(url);
history.insert(0, url);
if (history.length > 64) history.removeLast();
}
config.extra.createHistory = history;
await api.putConfig(config);
return const RpcUnit();
})
..bind(StorageMethods.removeCreateHistory, (url) async {
Database.instance.removeCreateHistory(url);
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory)..remove(url);
config.extra.createHistory = history;
await api.putConfig(config);
return const RpcUnit();
})
..bind(StorageMethods.clearCreateHistory, (_) async {
Database.instance.clearCreateHistory();
final config = await api.getConfig();
config.extra.createHistory = const [];
await api.putConfig(config);
return const RpcUnit();
});
}
@@ -0,0 +1,64 @@
class ApiServerState {
const ApiServerState({
required this.enabled,
required this.running,
required this.network,
required this.address,
required this.runningPort,
required this.pendingApply,
required this.lastError,
});
final bool enabled;
final bool running;
final String network;
final String address;
final int runningPort;
final bool pendingApply;
final String lastError;
factory ApiServerState.fromJson(Map<String, dynamic> json) {
return ApiServerState(
enabled: json['enabled'] as bool? ?? false,
running: json['running'] as bool? ?? false,
network: json['network'] as String? ?? '',
address: json['address'] as String? ?? '',
runningPort: json['runningPort'] as int? ?? 0,
pendingApply: json['pendingApply'] as bool? ?? false,
lastError: json['lastError'] as String? ?? '',
);
}
String runningAddress() {
if (!running) return '';
if (network == 'unix') return address;
final separator = address.lastIndexOf(':');
final host = separator < 0 ? address : address.substring(0, separator);
return '$host:$runningPort';
}
}
class ApiServerOperationResult {
const ApiServerOperationResult({required this.state, required this.error});
final ApiServerState state;
final String error;
factory ApiServerOperationResult.fromJson(Map<String, dynamic> json) {
final rawState = json['state'] as Map<String, dynamic>?;
return ApiServerOperationResult(
state: rawState == null
? const ApiServerState(
enabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: true,
lastError: '',
)
: ApiServerState.fromJson(rawState),
error: json['error'] as String? ?? '',
);
}
}
@@ -1,12 +1,24 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'api_server_state.dart';
import 'libgopeed_interface.dart';
import 'start_config.dart';
import 'task_event.dart';
class LibgopeedChannel implements LibgopeedInterface {
static const _channel = MethodChannel('gopeed.com/libgopeed');
final _taskEvents = StreamController<TaskEvent>.broadcast();
LibgopeedChannel() {
_channel.setMethodCallHandler((call) async {
if (call.method != 'taskEvent') return;
final payload = jsonDecode(call.arguments as String) as Map<String, dynamic>;
_taskEvents.add(TaskEvent.fromJson(payload));
});
}
@override
Future<int> start(StartConfig cfg) async {
@@ -18,4 +30,40 @@ class LibgopeedChannel implements LibgopeedInterface {
Future<void> stop() async {
return await _channel.invokeMethod('stop');
}
Future<ApiServerOperationResult> _apiServerOperation(String method) async {
final payload = await _channel.invokeMethod<String>(method) ?? '';
return ApiServerOperationResult.fromJson(jsonDecode(payload) as Map<String, dynamic>);
}
@override
Future<ApiServerOperationResult> getApiServerState() => _apiServerOperation('getApiServerState');
@override
Future<ApiServerOperationResult> startApiServer() => _apiServerOperation('startApiServer');
@override
Future<ApiServerOperationResult> stopApiServer() => _apiServerOperation('stopApiServer');
@override
Future<ApiServerOperationResult> restartApiServer() => _apiServerOperation('restartApiServer');
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
final result = await _channel.invokeMethod<String>('invoke', {
'method': method,
'path': path,
'query': query,
'body': body,
});
return result ?? '';
}
@override
Stream<TaskEvent> get taskEvents => _taskEvents.stream;
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) {
return _channel.invokeMethod<void>('subscribeTaskEvents', {'mask': events.mask});
}
}
+77 -5
View File
@@ -5,24 +5,47 @@ import 'dart:ffi';
import 'package:ffi/ffi.dart';
import '../ffi/libgopeed_bind.dart';
import '../ffi/libgopeed_worker.dart';
import 'api_server_state.dart';
import 'libgopeed_interface.dart';
import 'start_config.dart';
import 'task_event.dart';
class LibgopeedFFi implements LibgopeedInterface {
late LibgopeedBind _libgopeed;
final _taskEvents = StreamController<TaskEvent>.broadcast();
final _worker = LibgopeedWorker();
late final NativeCallable<Void Function(Pointer<Char>)> _taskEventCallback;
LibgopeedFFi(LibgopeedBind libgopeed) {
_libgopeed = libgopeed;
_taskEventCallback = NativeCallable<Void Function(Pointer<Char>)>.listener(_onTaskEvent);
}
void _onTaskEvent(Pointer<Char> payload) {
try {
final decoded = jsonDecode(payload.cast<Utf8>().toDartString()) as Map<String, dynamic>;
_taskEvents.add(TaskEvent.fromJson(decoded));
} finally {
_libgopeed.FreeCString(payload);
}
}
@override
Future<int> start(StartConfig cfg) {
var completer = Completer<int>();
var result = _libgopeed.Start(jsonEncode(cfg).toNativeUtf8().cast());
if (result.r1 != nullptr) {
completer.completeError(Exception(result.r1.cast<Utf8>().toDartString()));
} else {
completer.complete(result.r0);
final cfgPtr = jsonEncode(cfg).toNativeUtf8();
try {
final result = _libgopeed.Start(cfgPtr.cast());
if (result.r1 != nullptr) {
final message = result.r1.cast<Utf8>().toDartString();
_libgopeed.FreeCString(result.r1);
completer.completeError(Exception(message));
} else {
completer.complete(result.r0);
}
} finally {
malloc.free(cfgPtr);
}
return completer.future;
}
@@ -34,4 +57,53 @@ class LibgopeedFFi implements LibgopeedInterface {
completer.complete();
return completer.future;
}
Future<ApiServerOperationResult> _apiServerOperation(String operation) async {
final payload = await _worker.apiServer(operation);
return ApiServerOperationResult.fromJson(jsonDecode(payload) as Map<String, dynamic>);
}
@override
Future<ApiServerOperationResult> getApiServerState() => _apiServerOperation('get');
@override
Future<ApiServerOperationResult> startApiServer() => _apiServerOperation('start');
@override
Future<ApiServerOperationResult> stopApiServer() => _apiServerOperation('stop');
@override
Future<ApiServerOperationResult> restartApiServer() => _apiServerOperation('restart');
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
final methodPtr = method.toNativeUtf8();
final pathPtr = path.toNativeUtf8();
final queryPtr = query.toNativeUtf8();
final bodyPtr = body.toNativeUtf8();
Pointer<Char>? resultPtr;
try {
resultPtr = _libgopeed.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast());
if (resultPtr == nullptr) {
throw StateError('Gopeed Invoke returned a null response');
}
return resultPtr.cast<Utf8>().toDartString();
} finally {
malloc.free(methodPtr);
malloc.free(pathPtr);
malloc.free(queryPtr);
malloc.free(bodyPtr);
if (resultPtr != null && resultPtr != nullptr) {
_libgopeed.FreeCString(resultPtr);
}
}
}
@override
Stream<TaskEvent> get taskEvents => _taskEvents.stream;
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) async {
_libgopeed.SubscribeTaskEvents(events.mask, events.isEmpty ? 0 : _taskEventCallback.nativeFunction.address);
}
}
@@ -1,7 +1,23 @@
import 'api_server_state.dart';
import 'start_config.dart';
import 'task_event.dart';
abstract class LibgopeedInterface {
Future<int> start(StartConfig cfg);
Future<void> stop();
Future<ApiServerOperationResult> getApiServerState();
Future<ApiServerOperationResult> startApiServer();
Future<ApiServerOperationResult> stopApiServer();
Future<ApiServerOperationResult> restartApiServer();
Future<String> invoke(String method, String path, {String query = '', String body = ''});
Stream<TaskEvent> get taskEvents;
Future<void> subscribeTaskEvents(Set<TaskEventType> events);
}
@@ -19,6 +19,7 @@ class WebViewRpcConfig {
class StartConfig {
late String network;
late String address;
bool apiEnable = false;
late String storage;
late String storageDir;
late int refreshInterval;
@@ -22,6 +22,7 @@ Map<String, dynamic> _$WebViewRpcConfigToJson(WebViewRpcConfig instance) =>
StartConfig _$StartConfigFromJson(Map<String, dynamic> json) => StartConfig()
..network = json['network'] as String
..address = json['address'] as String
..apiEnable = json['apiEnable'] as bool
..storage = json['storage'] as String
..storageDir = json['storageDir'] as String
..refreshInterval = (json['refreshInterval'] as num).toInt()
@@ -36,6 +37,7 @@ Map<String, dynamic> _$StartConfigToJson(StartConfig instance) =>
<String, dynamic>{
'network': instance.network,
'address': instance.address,
'apiEnable': instance.apiEnable,
'storage': instance.storage,
'storageDir': instance.storageDir,
'refreshInterval': instance.refreshInterval,
@@ -0,0 +1,38 @@
enum TaskEventType {
done(1, 'task.done'),
error(2, 'task.error');
const TaskEventType(this.mask, this.wireName);
final int mask;
final String wireName;
static TaskEventType fromWireName(String value) {
return values.firstWhere(
(type) => type.wireName == value,
orElse: () => throw FormatException('Unknown task event type: $value'),
);
}
}
extension TaskEventTypeSet on Set<TaskEventType> {
int get mask => fold(0, (value, type) => value | type.mask);
}
class TaskEvent {
const TaskEvent({required this.type, required this.taskId, required this.name, this.error});
final TaskEventType type;
final String taskId;
final String name;
final String? error;
factory TaskEvent.fromJson(Map<String, dynamic> json) {
return TaskEvent(
type: TaskEventType.fromWireName(json['type'] as String),
taskId: json['taskId'] as String,
name: json['name'] as String? ?? '',
error: json['error'] as String?,
);
}
}
@@ -1,20 +1,9 @@
import '../../database/database.dart';
import '../../util/util.dart';
class AppInitializer {
static Future<void>? _storageInitialization;
static Future<void>? _databaseInitialization;
static Future<void> ensureStorageInitialized() {
return _storageInitialization ??= Util.initStorageDir();
}
static Future<void> ensureDatabaseInitialized() {
return _databaseInitialization ??= _initializeDatabase();
}
static Future<void> _initializeDatabase() async {
await ensureStorageInitialized();
await Database.instance.init();
}
}
@@ -1,6 +1,8 @@
import 'dart:async';
import '../common/api_server_state.dart';
import '../common/start_config.dart';
import '../common/task_event.dart';
import '../libgopeed_boot.dart';
@@ -13,4 +15,40 @@ class LibgopeedBootBrowser implements LibgopeedBoot {
@override
Future<void> stop() async {}
@override
Future<ApiServerOperationResult> getApiServerState() async => _unsupportedApiServerResult;
@override
Future<ApiServerOperationResult> startApiServer() async => _unsupportedApiServerResult;
@override
Future<ApiServerOperationResult> stopApiServer() async => _unsupportedApiServerResult;
@override
Future<ApiServerOperationResult> restartApiServer() async => _unsupportedApiServerResult;
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) {
throw UnsupportedError('Native Gopeed invoke is unavailable on web');
}
@override
Stream<TaskEvent> get taskEvents => const Stream.empty();
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) async {}
}
const _unsupportedApiServerResult = ApiServerOperationResult(
state: ApiServerState(
enabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
),
error: '',
);
@@ -6,7 +6,9 @@ import '../../util/util.dart';
import '../common/libgopeed_channel.dart';
import '../common/libgopeed_ffi.dart';
import '../common/libgopeed_interface.dart';
import '../common/api_server_state.dart';
import '../common/start_config.dart';
import '../common/task_event.dart';
import '../ffi/libgopeed_bind.dart';
import '../libgopeed_boot.dart';
@@ -43,6 +45,32 @@ class LibgopeedBootNative implements LibgopeedBoot {
@override
Future<void> stop() async {
await _libgopeed.subscribeTaskEvents({});
await _libgopeed.stop();
}
@override
Future<ApiServerOperationResult> getApiServerState() => _libgopeed.getApiServerState();
@override
Future<ApiServerOperationResult> startApiServer() => _libgopeed.startApiServer();
@override
Future<ApiServerOperationResult> stopApiServer() => _libgopeed.stopApiServer();
@override
Future<ApiServerOperationResult> restartApiServer() => _libgopeed.restartApiServer();
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) {
return _libgopeed.invoke(method, path, query: query, body: body);
}
@override
Stream<TaskEvent> get taskEvents => _libgopeed.taskEvents;
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) {
return _libgopeed.subscribeTaskEvents(events);
}
}
@@ -29,6 +29,72 @@ class LibgopeedBind {
late final _StopPtr = _lookup<ffi.NativeFunction<ffi.Void Function()>>('Stop');
late final _Stop = _StopPtr.asFunction<void Function()>();
ffi.Pointer<ffi.Char> GetAPIServerState() => _GetAPIServerState();
late final _GetAPIServerStatePtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('GetAPIServerState');
late final _GetAPIServerState = _GetAPIServerStatePtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> StartAPIServer() => _StartAPIServer();
late final _StartAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('StartAPIServer');
late final _StartAPIServer = _StartAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> StopAPIServer() => _StopAPIServer();
late final _StopAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('StopAPIServer');
late final _StopAPIServer = _StopAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> RestartAPIServer() => _RestartAPIServer();
late final _RestartAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('RestartAPIServer');
late final _RestartAPIServer = _RestartAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> Invoke(
ffi.Pointer<ffi.Char> method,
ffi.Pointer<ffi.Char> path,
ffi.Pointer<ffi.Char> query,
ffi.Pointer<ffi.Char> body,
) {
return _Invoke(method, path, query, body);
}
late final _InvokePtr =
_lookup<
ffi.NativeFunction<
ffi.Pointer<ffi.Char> Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
)
>
>('Invoke');
late final _Invoke =
_InvokePtr.asFunction<
ffi.Pointer<ffi.Char> Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
)
>();
void SubscribeTaskEvents(int mask, int callback) {
return _SubscribeTaskEvents(mask, callback);
}
late final _SubscribeTaskEventsPtr = _lookup<ffi.NativeFunction<ffi.Void Function(ffi.Uint64, ffi.UintPtr)>>(
'SubscribeTaskEvents',
);
late final _SubscribeTaskEvents = _SubscribeTaskEventsPtr.asFunction<void Function(int, int)>();
void FreeCString(ffi.Pointer<ffi.Char> value) {
return _FreeCString(value);
}
late final _FreeCStringPtr = _lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Char>)>>('FreeCString');
late final _FreeCString = _FreeCStringPtr.asFunction<void Function(ffi.Pointer<ffi.Char>)>();
}
final class Start_return extends ffi.Struct {
@@ -0,0 +1,144 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
import 'libgopeed_bind.dart';
class LibgopeedWorker {
LibgopeedWorker() {
_receivePort.listen(_handleWorkerMessage);
_errorPort.listen(_handleWorkerError);
_isolateReady = Isolate.spawn(_workerMain, _receivePort.sendPort, onError: _errorPort.sendPort);
}
final ReceivePort _receivePort = ReceivePort();
final ReceivePort _errorPort = ReceivePort();
final Map<int, Completer<String>> _pending = {};
late final Future<Isolate> _isolateReady;
final Completer<SendPort> _sendPort = Completer<SendPort>();
int _requestID = 0;
Future<String> invoke(String method, String path, String query, String body) async {
await _isolateReady;
final sendPort = await _sendPort.future;
final requestID = _requestID++;
final completer = Completer<String>();
_pending[requestID] = completer;
sendPort.send(['invoke', requestID, method, path, query, body]);
return completer.future;
}
Future<String> apiServer(String operation) async {
await _isolateReady;
final sendPort = await _sendPort.future;
final requestID = _requestID++;
final completer = Completer<String>();
_pending[requestID] = completer;
sendPort.send(['apiServer', requestID, operation]);
return completer.future;
}
void _handleWorkerMessage(dynamic message) {
if (message is SendPort) {
if (!_sendPort.isCompleted) _sendPort.complete(message);
return;
}
final response = message as List<dynamic>;
final requestID = response[0] as int;
final completer = _pending.remove(requestID);
if (completer == null) return;
if (response[1] as bool) {
completer.complete(response[2] as String);
} else {
completer.completeError(StateError(response[2] as String));
}
}
void _handleWorkerError(dynamic message) {
final parts = message is List<dynamic> ? message : <dynamic>[message];
final error = StateError(parts.map((part) => part.toString()).join('\n'));
if (!_sendPort.isCompleted) _sendPort.completeError(error);
for (final completer in _pending.values) {
completer.completeError(error);
}
_pending.clear();
}
static void _workerMain(SendPort mainSendPort) {
final requests = ReceivePort();
final bindings = LibgopeedBind(DynamicLibrary.open(_libraryName()));
mainSendPort.send(requests.sendPort);
requests.listen((dynamic message) {
final request = message as List<dynamic>;
final type = request[0] as String;
final requestID = request[1] as int;
try {
final response = switch (type) {
'invoke' => _invoke(
bindings,
request[2] as String,
request[3] as String,
request[4] as String,
request[5] as String,
),
'apiServer' => _apiServer(bindings, request[2] as String),
_ => throw StateError('Unknown libgopeed worker request: $type'),
};
mainSendPort.send([requestID, true, response]);
} catch (error) {
mainSendPort.send([requestID, false, error.toString()]);
}
});
}
static String _apiServer(LibgopeedBind bindings, String operation) {
final resultPtr = switch (operation) {
'get' => bindings.GetAPIServerState(),
'start' => bindings.StartAPIServer(),
'stop' => bindings.StopAPIServer(),
'restart' => bindings.RestartAPIServer(),
_ => throw StateError('Unknown API server operation: $operation'),
};
if (resultPtr == nullptr) {
throw StateError('Gopeed API server operation returned a null response');
}
try {
return resultPtr.cast<Utf8>().toDartString();
} finally {
bindings.FreeCString(resultPtr);
}
}
static String _invoke(LibgopeedBind bindings, String method, String path, String query, String body) {
final methodPtr = method.toNativeUtf8();
final pathPtr = path.toNativeUtf8();
final queryPtr = query.toNativeUtf8();
final bodyPtr = body.toNativeUtf8();
Pointer<Char>? resultPtr;
try {
resultPtr = bindings.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast());
if (resultPtr == nullptr) {
throw StateError('Gopeed Invoke returned a null response');
}
return resultPtr.cast<Utf8>().toDartString();
} finally {
malloc.free(methodPtr);
malloc.free(pathPtr);
malloc.free(queryPtr);
malloc.free(bodyPtr);
if (resultPtr != null && resultPtr != nullptr) {
bindings.FreeCString(resultPtr);
}
}
}
static String _libraryName() {
if (Platform.isWindows) return 'libgopeed.dll';
if (Platform.isMacOS) return 'libgopeed.dylib';
if (Platform.isLinux) return 'libgopeed.so';
throw UnsupportedError('Desktop FFI is unavailable on ${Platform.operatingSystem}');
}
}
+16
View File
@@ -1,4 +1,6 @@
import 'common/api_server_state.dart';
import 'common/start_config.dart';
import 'common/task_event.dart';
import "libgopeed_boot_stub.dart"
if (dart.library.html) 'entry/libgopeed_boot_browser.dart'
if (dart.library.io) 'entry/libgopeed_boot_native.dart';
@@ -16,4 +18,18 @@ abstract class LibgopeedBoot {
Future<int> start(StartConfig cfg);
Future<void> stop();
Future<ApiServerOperationResult> getApiServerState();
Future<ApiServerOperationResult> startApiServer();
Future<ApiServerOperationResult> stopApiServer();
Future<ApiServerOperationResult> restartApiServer();
Future<String> invoke(String method, String path, {String query = '', String body = ''});
Stream<TaskEvent> get taskEvents;
Future<void> subscribeTaskEvents(Set<TaskEventType> events);
}
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../libgopeed_boot.dart';
import '../../../ffi/libgopeed_worker.dart';
import '../../../../util/util.dart';
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
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
final request = _RequestParts.from(path, queryParameters);
final requestBody = data == null ? '' : jsonEncode(data);
final payload = _ffiWorker != null
? await _ffiWorker.invoke(method.toUpperCase(), request.path, request.query, requestBody)
: await LibgopeedBoot.instance.invoke(
method.toUpperCase(),
request.path,
query: request.query,
body: requestBody,
);
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;
}
class _RequestParts {
const _RequestParts(this.path, this.query);
final String path;
final String query;
factory _RequestParts.from(String rawPath, Map<String, dynamic>? queryParameters) {
var path = rawPath;
var query = '';
final queryIndex = rawPath.indexOf('?');
if (queryIndex >= 0) {
path = rawPath.substring(0, queryIndex);
query = rawPath.substring(queryIndex + 1);
}
final extraQuery = _encodeQueryParameters(queryParameters);
if (extraQuery.isNotEmpty) {
query = query.isEmpty ? extraQuery : '$query&$extraQuery';
}
return _RequestParts(path, query);
}
static String _encodeQueryParameters(Map<String, dynamic>? queryParameters) {
if (queryParameters == null || queryParameters.isEmpty) return '';
final segments = <String>[];
queryParameters.forEach((key, value) {
if (value == null) return;
final values = value is Iterable ? value : [value];
for (final item in values) {
if (item == null) continue;
segments.add('${Uri.encodeQueryComponent(key)}=${Uri.encodeQueryComponent(item.toString())}');
}
});
return segments.join('&');
}
}
@@ -0,0 +1,5 @@
import '../gopeed_transport.dart';
GopeedTransport createTransport(GopeedTransportConfig config) {
throw UnsupportedError('Gopeed transport is unavailable on this platform');
}
@@ -0,0 +1,73 @@
import 'package:dio/dio.dart';
import '../gopeed_transport.dart';
GopeedTransport createTransport(GopeedTransportConfig config) => RestGopeedTransport(config);
class RestGopeedTransport implements GopeedTransport {
RestGopeedTransport(GopeedTransportConfig config)
: _dio = Dio(
BaseOptions(
baseUrl: '${Uri.base.origin}/',
contentType: Headers.jsonContentType,
sendTimeout: const Duration(seconds: 5),
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 120),
),
) {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (config.apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = config.apiToken;
}
handler.next(options);
},
onError: (error, handler) {
if (error.response?.statusCode == 401) {
config.onUnauthorized?.call();
}
handler.next(error);
},
),
);
}
final Dio _dio;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
final response = await _dio.request<dynamic>(
path,
data: data,
queryParameters: queryParameters,
options: Options(method: method),
);
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;
final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
return '$cleanBaseUrl/$cleanPath';
}
}
@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import 'entry/gopeed_transport_stub.dart'
if (dart.library.html) 'entry/gopeed_transport_web.dart'
if (dart.library.io) 'entry/gopeed_transport_native.dart';
class GopeedTransportConfig {
const GopeedTransportConfig({
required this.network,
required this.address,
required this.apiToken,
this.onUnauthorized,
});
final String network;
final String address;
final String apiToken;
final void Function()? onUnauthorized;
}
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);
}
GopeedTransport createGopeedTransport(GopeedTransportConfig config) => createTransport(config);
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart' show Colors;
import 'package:flutter/widgets.dart';
import 'package:window_manager/window_manager.dart';
import '../../database/database.dart';
import '../../l10n/l10n.dart';
import 'app_window_chrome.dart';
import 'app_window_payload.dart';
@@ -16,9 +15,8 @@ class AppWindowBootstrap {
await windowManager.ensureInitialized();
final windowState = Database.instance.getWindowState();
final windowOptions = WindowOptions(
size: Size(windowState?.width ?? 1024, windowState?.height ?? 768),
size: const Size(1024, 768),
center: true,
skipTaskbar: false,
backgroundColor: Colors.transparent,
-144
View File
@@ -1,144 +0,0 @@
import 'dart:convert';
import 'package:gopeed/util/util.dart';
import 'package:hive/hive.dart';
import 'entity.dart';
const String _startConfig = 'startConfig';
const String _windowState = 'windowState';
const String _bookmark = 'bookmark';
const String _createHistory = 'createHistory';
const String _runAsMenubarApp = 'runAsMenubarApp';
const String _analyticsEnabled = 'analyticsEnabled';
const String _analyticsClientId = 'analyticsClientId';
class Database {
static final Database _instance = Database._internal();
static Database get instance => _instance;
factory Database() {
return _instance;
}
late Box box;
bool _initialized = false;
Database._internal();
bool get initialized => _initialized;
Future<void> init() async {
if (_initialized) {
return;
}
Hive.init(Util.getStorageDir());
box = await Hive.openBox('database');
_initialized = true;
}
void save<T>(String key, T entity) {
box.put(key, jsonEncode(entity));
}
T? get<T>(String key, T Function(dynamic json) fromJsonT) {
final json = box.get(key);
if (json == null) {
return null;
}
return fromJsonT(jsonDecode(json));
}
void clear(String key) {
box.delete(key);
}
void saveStartConfig(StartConfigEntity entity) {
save<StartConfigEntity>(_startConfig, entity);
}
StartConfigEntity? getStartConfig() {
return get<StartConfigEntity>(_startConfig, (json) => StartConfigEntity.fromJson(json));
}
/// Patch non-null fields with the original value
void saveWindowState(WindowStateEntity entity) {
final state = getWindowState();
entity.isMaximized ??= state?.isMaximized;
entity.width ??= state?.width;
entity.height ??= state?.height;
save<WindowStateEntity>(_windowState, entity);
}
WindowStateEntity? getWindowState() {
return get<WindowStateEntity>(_windowState, (json) => WindowStateEntity.fromJson(json));
}
/// Use map to ensure that the same directory only saves the latest bookmark
void saveBookmark(MapEntry<String, String> entry) {
final map = getBookmark() ?? {};
map[entry.key] = entry.value;
save<Map<String, String>>(_bookmark, map);
}
Map<String, String>? getBookmark() {
return get<Map<String, String>>(_bookmark, (json) {
return (json as Map<String, dynamic>).map((key, value) => MapEntry(key, value.toString()));
});
}
void saveCreateHistory(String url) {
var list = getCreateHistory() ?? [];
list.remove(url);
list.insert(0, url);
if (list.length > 64) {
list.removeLast();
}
save<List<String>>(_createHistory, list);
}
List<String>? getCreateHistory() {
return get<List<String>>(_createHistory, (json) {
return (json as List<dynamic>).map((e) => e.toString()).toList();
});
}
void removeCreateHistory(String url) {
final list = getCreateHistory() ?? [];
list.remove(url);
if (list.isEmpty) {
clearCreateHistory();
} else {
save<List<String>>(_createHistory, list);
}
}
void clearCreateHistory() {
clear(_createHistory);
}
void saveRunAsMenubarApp(bool value) {
box.put(_runAsMenubarApp, value);
}
bool getRunAsMenubarApp() {
return box.get(_runAsMenubarApp, defaultValue: false) as bool;
}
void saveAnalyticsEnabled(bool value) {
box.put(_analyticsEnabled, value);
}
bool getAnalyticsEnabled() {
return box.get(_analyticsEnabled, defaultValue: true) as bool;
}
void saveAnalyticsClientId(String clientId) {
box.put(_analyticsClientId, clientId);
}
String? getAnalyticsClientId() {
return box.get(_analyticsClientId) as String?;
}
}
-27
View File
@@ -1,27 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'entity.g.dart';
@JsonSerializable()
class StartConfigEntity {
String network;
String address;
String apiToken;
StartConfigEntity({required this.network, required this.address, required this.apiToken});
factory StartConfigEntity.fromJson(Map<String, dynamic> json) => _$StartConfigEntityFromJson(json);
Map<String, dynamic> toJson() => _$StartConfigEntityToJson(this);
}
@JsonSerializable()
class WindowStateEntity {
bool? isMaximized;
double? width;
double? height;
WindowStateEntity({this.isMaximized, this.width, this.height});
factory WindowStateEntity.fromJson(Map<String, dynamic> json) => _$WindowStateEntityFromJson(json);
Map<String, dynamic> toJson() => _$WindowStateEntityToJson(this);
}
-35
View File
@@ -1,35 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'entity.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
StartConfigEntity _$StartConfigEntityFromJson(Map<String, dynamic> json) =>
StartConfigEntity(
network: json['network'] as String,
address: json['address'] as String,
apiToken: json['apiToken'] as String,
);
Map<String, dynamic> _$StartConfigEntityToJson(StartConfigEntity instance) =>
<String, dynamic>{
'network': instance.network,
'address': instance.address,
'apiToken': instance.apiToken,
};
WindowStateEntity _$WindowStateEntityFromJson(Map<String, dynamic> json) =>
WindowStateEntity(
isMaximized: json['isMaximized'] as bool?,
width: (json['width'] as num?)?.toDouble(),
height: (json['height'] as num?)?.toDouble(),
);
Map<String, dynamic> _$WindowStateEntityToJson(WindowStateEntity instance) =>
<String, dynamic>{
'isMaximized': ?instance.isMaximized,
'width': ?instance.width,
'height': ?instance.height,
};
@@ -33,6 +33,13 @@ class SettingsController extends AsyncNotifier<SettingsState> {
Future<void> save(DownloaderConfig config) async {
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));
@@ -17,9 +17,7 @@ import '../../../../app/application/app_runtime_controller.dart';
import '../../../../app/application/location_keep_alive.dart';
import '../../../../core/common/start_config.dart';
import '../../../../core/utils/breakpoints.dart';
import '../../../../core/utils/tcp_port_checker.dart';
import '../../../../core/window/app_window_chrome.dart';
import '../../../../database/database.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_choice_segmented_control.dart';
@@ -85,7 +83,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
bool _syncingStartControllers = false;
bool _savingStartConfig = false;
String? _apiNetworkDraft;
late bool _analyticsEnabled;
List<TextEditingController> get _textControllers => [
_downloadDirController,
@@ -117,7 +114,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
@override
void initState() {
super.initState();
_analyticsEnabled = Database.instance.initialized ? Database.instance.getAnalyticsEnabled() : true;
for (final controller in _textControllers) {
controller.addListener(_scheduleTextSave);
}
@@ -281,7 +277,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
AppPlatformState? platform,
AppRuntimeState? runtimeState,
) {
final apiConfigDirty = _startConfig != null && _startDraftSignature() != _startSignature(_startConfig!);
final apiConfigDirty = _startConfig != null && _startDraftSignature() != _loadedStartSignature;
final apiServerVisualState = _apiServerVisualState(runtimeState);
switch (section) {
case _SettingsSection.basic:
return _SettingsSectionStack(
@@ -410,11 +407,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
title: context.l10n.analyticsEnabled,
subtitle: context.l10n.analyticsEnabledDesc,
child: shad.Switch(
value: _analyticsEnabled,
onChanged: (value) {
setState(() => _analyticsEnabled = value);
if (Database.instance.initialized) Database.instance.saveAnalyticsEnabled(value);
},
value: config.extra.analyticsEnabled,
onChanged: (value) => _mutateConfig((next) => next.extra.analyticsEnabled = value),
),
),
SettingsItem(
@@ -768,62 +762,94 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
],
),
),
_SettingsBlock(
title: 'API',
child: _SettingsGroup(
children: [
SettingsItem(
title: context.l10n.protocol,
subtitle: context.l10n.restartRequired,
child: AppChoiceSegmentedControl<String>(
value: _apiNetworkValue,
buttonKeyPrefix: 'settings-choice',
options: [
const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined),
if (Util.supportUnixSocket())
const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined),
],
onChanged: _changeApiNetwork,
),
),
if (_apiNetworkValue == 'unix')
if (!kIsWeb)
_SettingsBlock(
title: 'API',
child: _SettingsGroup(
children: [
SettingsItem(
title: context.l10n.socketPath,
subtitle: context.l10n.restartRequired,
child: _TextSettingControl(controller: _apiAddressController),
)
else
SettingsItem(
title: context.l10n.listenAddress,
subtitle: context.l10n.restartRequired,
child: _HostPortControl(
hostController: _apiHostController,
portController: _apiPortController,
hostKey: const ValueKey('api-host-input'),
portKey: const ValueKey('api-port-input'),
title: context.l10n.apiServerRuntimeStatus,
subtitle: _apiServerRuntimeDetails(runtimeState),
child: _ApiServerStatusLight(
key: const ValueKey('api-server-runtime-status'),
state: apiServerVisualState,
label: switch (apiServerVisualState) {
_ApiServerVisualState.loading => context.l10n.readingRuntimeStatus,
_ApiServerVisualState.running => context.l10n.apiServerRunning,
_ApiServerVisualState.stopped => context.l10n.apiServerStopped,
_ApiServerVisualState.failed => context.l10n.apiServerFailed,
},
),
),
if (Util.isDesktop() && _apiNetworkValue == 'tcp')
SettingsItem(
title: context.l10n.apiToken,
child: _TextSettingControl(controller: _apiTokenController, obscureText: true),
title: context.l10n.protocol,
child: AppChoiceSegmentedControl<String>(
value: _apiNetworkValue,
buttonKeyPrefix: 'settings-choice',
options: [
const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined),
if (Util.supportUnixSocket())
const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined),
],
onChanged: _changeApiNetwork,
),
),
SettingsItem(
title: context.l10n.currentConnection,
subtitle: runtimeState == null
? context.l10n.readingRuntimeStatus
: '${runtimeState.startConfig.network}://${runtimeState.runningAddress()}',
child: AppLoadingButton(
key: const ValueKey('save-api-config-button'),
onPressed: runtimeState == null || !apiConfigDirty ? null : _saveStartConfig,
loading: _savingStartConfig,
icon: const Icon(Icons.save_outlined),
child: Text(context.l10n.save),
if (_apiNetworkValue == 'unix')
SettingsItem(
title: context.l10n.socketPath,
child: _TextSettingControl(controller: _apiAddressController),
)
else
SettingsItem(
title: context.l10n.listenAddress,
child: _HostPortControl(
hostController: _apiHostController,
portController: _apiPortController,
hostKey: const ValueKey('api-host-input'),
portKey: const ValueKey('api-port-input'),
),
),
if (_apiNetworkValue == 'tcp')
SettingsItem(
title: context.l10n.apiToken,
child: _TextSettingControl(controller: _apiTokenController, obscureText: true),
),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
AppLoadingButton(
key: const ValueKey('toggle-api-server-button'),
onPressed: runtimeState == null ? null : _toggleApiServer,
loading: _savingStartConfig,
icon: Icon(
runtimeState?.apiServerState.running ?? false
? Icons.stop_circle_outlined
: Icons.play_circle_outline,
),
child: Text(
runtimeState?.apiServerState.running ?? false
? context.l10n.stopApiServer
: context.l10n.startApiServer,
),
),
AppLoadingButton(
key: const ValueKey('save-api-config-button'),
onPressed: runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig,
loading: _savingStartConfig,
icon: const Icon(Icons.save_outlined),
variant: AppLoadingButtonVariant.primary,
child: Text(context.l10n.save),
),
],
),
),
),
],
],
),
),
),
_SettingsBlock(
title: context.l10n.developer,
child: _SettingsGroup(
@@ -925,6 +951,15 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
if (!force && _loadedStartSignature == signature && _startConfig != null) {
return;
}
if (!force &&
_startConfig != null &&
_startConfig!.network == config.network &&
_startConfig!.address == config.address &&
_startConfig!.apiToken == config.apiToken) {
_startConfig!.apiEnable = config.apiEnable;
_loadedStartSignature = signature;
return;
}
_loadedStartSignature = signature;
_startConfig = _copyStartConfig(config);
_apiNetworkDraft = config.network == 'unix' && Util.supportUnixSocket() ? 'unix' : 'tcp';
@@ -1042,24 +1077,31 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
}
Future<void> _saveStartConfig() async {
Future<void> _saveApiServerConfig() async {
final config = _startConfig;
final runtimeConfig = ref.read(appRuntimeControllerProvider).value?.startConfig;
if (config == null || runtimeConfig == null || _savingStartConfig) {
final runtimeState = ref.read(appRuntimeControllerProvider).value;
if (config == null || runtimeState == null || _savingStartConfig) {
return;
}
final snapshot = _copyStartConfig(config);
_applyStartControllers(snapshot);
final validationMessage = await _validateStartConfig(snapshot);
if (validationMessage != null) {
if (mounted) _toast(validationMessage);
return;
}
if (runtimeState.apiServerState.running && !await _confirmRestartApiServer()) {
return;
}
setState(() => _savingStartConfig = true);
try {
final validationMessage = await _validateStartConfig(snapshot, runtimeConfig);
if (validationMessage != null) {
if (mounted) _toast(validationMessage);
return;
final controller = ref.read(appRuntimeControllerProvider.notifier);
if (runtimeState.apiServerState.running) {
await controller.restartApiServer(snapshot);
} else {
await controller.saveApiServerConfig(snapshot);
}
await ref.read(appRuntimeControllerProvider.notifier).saveStartConfig(snapshot);
_startConfig = snapshot;
_apiNetworkDraft = snapshot.network;
_syncStartConfig(ref.read(appRuntimeControllerProvider).requireValue.startConfig, force: true);
if (mounted) _toast(context.l10n.apiConfigSaved, type: AppToastType.success);
} catch (error) {
if (mounted) _toast(error.toString());
@@ -1068,7 +1110,79 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
}
Future<String?> _validateStartConfig(StartConfig candidate, StartConfig current) async {
Future<void> _toggleApiServer() async {
final runtimeState = ref.read(appRuntimeControllerProvider).value;
if (runtimeState == null || _savingStartConfig) return;
if (runtimeState.apiServerState.running && !await _confirmDisableApiServer()) return;
setState(() => _savingStartConfig = true);
try {
final controller = ref.read(appRuntimeControllerProvider.notifier);
if (runtimeState.apiServerState.running) {
await controller.stopApiServer();
} else {
await controller.startApiServer();
}
} catch (error) {
if (mounted) _toast(error.toString());
} finally {
if (mounted) setState(() => _savingStartConfig = false);
}
}
Future<bool> _confirmDisableApiServer() async {
final overlay = const shad.DialogOverlayHandler().show<bool>(
context: context,
alignment: Alignment.center,
barrierDismissable: false,
builder: (dialogContext) => shad.AlertDialog(
key: const ValueKey('disable-api-server-dialog'),
title: Text(dialogContext.l10n.stopListening),
content: Text(dialogContext.l10n.disableApiServerConfirm),
actions: [
shad.SecondaryButton(
key: const ValueKey('cancel-disable-api-server-button'),
onPressed: () => shad.closeOverlay(dialogContext, false),
child: Text(dialogContext.l10n.cancel),
),
shad.DestructiveButton(
key: const ValueKey('confirm-disable-api-server-button'),
onPressed: () => shad.closeOverlay(dialogContext, true),
child: Text(dialogContext.l10n.confirm),
),
],
),
);
return await overlay.future ?? false;
}
Future<bool> _confirmRestartApiServer() async {
final overlay = const shad.DialogOverlayHandler().show<bool>(
context: context,
alignment: Alignment.center,
barrierDismissable: false,
builder: (dialogContext) => shad.AlertDialog(
key: const ValueKey('restart-api-server-dialog'),
title: Text(dialogContext.l10n.saveAndRestartApiServer),
content: Text(dialogContext.l10n.restartApiServerConfirm),
actions: [
shad.SecondaryButton(
key: const ValueKey('cancel-restart-api-server-button'),
onPressed: () => shad.closeOverlay(dialogContext, false),
child: Text(dialogContext.l10n.cancel),
),
shad.PrimaryButton(
key: const ValueKey('confirm-restart-api-server-button'),
onPressed: () => shad.closeOverlay(dialogContext, true),
child: Text(dialogContext.l10n.saveAndRestartApiServer),
),
],
),
);
return await overlay.future ?? false;
}
Future<String?> _validateStartConfig(StartConfig candidate) async {
final l10n = context.l10n;
if (candidate.network == 'unix') {
return candidate.address.trim().isEmpty ? l10n.socketPathRequired : null;
@@ -1079,10 +1193,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
final port = int.tryParse(address.port);
if (port == null || port < 0 || port > 65535) return l10n.portRangeError;
final addressChanged = current.network != candidate.network || current.address != candidate.address;
if (addressChanged && port != 0 && await isTcpPortInUse(address.host, port)) {
return l10n.portInUseValue(port);
}
return null;
}
@@ -1312,7 +1422,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
String _startSignature(StartConfig config) {
return '${config.network}|${config.address}|${config.apiToken}';
return '${config.apiEnable}|${config.network}|${config.address}|${config.apiToken}';
}
String _startDraftSignature() {
@@ -1320,13 +1430,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
final address = network == 'unix'
? _apiAddressController.text.trim()
: _joinHostPort(_apiHostController.text, _apiPortController.text);
return '$network|$address|${_apiTokenController.text.trim()}';
return '${_startConfig?.apiEnable ?? false}|$network|$address|${_apiTokenController.text.trim()}';
}
StartConfig _copyStartConfig(StartConfig config) {
return StartConfig()
..network = config.network
..address = config.address
..apiEnable = config.apiEnable
..storage = config.storage
..storageDir = config.storageDir
..refreshInterval = config.refreshInterval
@@ -1443,6 +1554,29 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
};
}
String? _apiServerRuntimeDetails(AppRuntimeState? runtimeState) {
if (runtimeState == null) return null;
final apiState = runtimeState.apiServerState;
final details = <String>[];
if (apiState.running) {
details.add('${apiState.network}://${apiState.runningAddress()}');
}
if (apiState.lastError.isNotEmpty) {
details.add(apiState.lastError);
} else if (apiState.pendingApply) {
details.add(context.l10n.apiConfigPendingApply);
}
return details.isEmpty ? null : details.join('\n');
}
_ApiServerVisualState _apiServerVisualState(AppRuntimeState? runtimeState) {
if (runtimeState == null) return _ApiServerVisualState.loading;
final apiState = runtimeState.apiServerState;
if (apiState.running) return _ApiServerVisualState.running;
if (apiState.lastError.isNotEmpty) return _ApiServerVisualState.failed;
return _ApiServerVisualState.stopped;
}
String _languageValue(DownloaderConfig config) {
final locale = supportedLocaleFromConfig(config.extra.locale);
return locale == null ? 'system' : localeConfigValue(locale);
@@ -1453,6 +1587,150 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
}
enum _ApiServerVisualState { loading, running, stopped, failed }
class _ApiServerStatusLight extends StatefulWidget {
const _ApiServerStatusLight({super.key, required this.state, required this.label});
final _ApiServerVisualState state;
final String label;
@override
State<_ApiServerStatusLight> createState() => _ApiServerStatusLightState();
}
class _ApiServerStatusLightState extends State<_ApiServerStatusLight> with SingleTickerProviderStateMixin {
late final AnimationController _pulseController;
bool _disableAnimations = false;
@override
void initState() {
super.initState();
_pulseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1400), value: 0.35);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_disableAnimations = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
_syncPulseAnimation();
}
@override
void didUpdateWidget(covariant _ApiServerStatusLight oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.state != widget.state) _syncPulseAnimation();
}
void _syncPulseAnimation() {
final shouldPulse =
!_disableAnimations &&
(widget.state == _ApiServerVisualState.running || widget.state == _ApiServerVisualState.loading);
if (shouldPulse) {
if (!_pulseController.isAnimating) _pulseController.repeat(reverse: true);
return;
}
_pulseController
..stop()
..value = switch (widget.state) {
_ApiServerVisualState.running => 0.55,
_ApiServerVisualState.loading => 0.3,
_ApiServerVisualState.failed => 0.5,
_ApiServerVisualState.stopped => 0,
};
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final color = switch (widget.state) {
_ApiServerVisualState.running => palette.success,
_ApiServerVisualState.failed => palette.error,
_ApiServerVisualState.loading || _ApiServerVisualState.stopped => palette.textMuted,
};
final labelColor = widget.state == _ApiServerVisualState.stopped ? palette.textSecondary : color;
return Semantics(
label: widget.label,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
final pulse = Curves.easeInOut.transform(_pulseController.value);
final glowSize = switch (widget.state) {
_ApiServerVisualState.running => 12.0 + pulse * 8,
_ApiServerVisualState.loading => 10.0 + pulse * 5,
_ApiServerVisualState.failed => 16.0,
_ApiServerVisualState.stopped => 10.0,
};
final glowAlpha = switch (widget.state) {
_ApiServerVisualState.running => 0.08 + pulse * 0.12,
_ApiServerVisualState.loading => 0.05 + pulse * 0.06,
_ApiServerVisualState.failed => 0.13,
_ApiServerVisualState.stopped => 0.08,
};
final glowShadow = switch (widget.state) {
_ApiServerVisualState.running => [
BoxShadow(
color: color.withValues(alpha: 0.18 + pulse * 0.2),
blurRadius: 4 + pulse * 8,
spreadRadius: pulse * 1.5,
),
],
_ApiServerVisualState.failed => [
BoxShadow(color: color.withValues(alpha: 0.28), blurRadius: 7, spreadRadius: 0.5),
],
_ApiServerVisualState.loading || _ApiServerVisualState.stopped => const <BoxShadow>[],
};
return SizedBox.square(
dimension: 24,
child: Stack(
alignment: Alignment.center,
children: [
Container(
key: const ValueKey('api-server-status-glow'),
width: glowSize,
height: glowSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: glowAlpha),
boxShadow: glowShadow,
),
),
child!,
],
),
);
},
child: AnimatedContainer(
key: const ValueKey('api-server-status-dot'),
duration: const Duration(milliseconds: 300),
width: widget.state == _ApiServerVisualState.loading ? 6 : 8,
height: widget.state == _ApiServerVisualState.loading ? 6 : 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.state == _ApiServerVisualState.stopped ? color.withValues(alpha: 0.72) : color,
),
),
),
const SizedBox(width: 6),
Text(
widget.label,
style: TextStyle(color: labelColor, fontSize: 12, fontWeight: FontWeight.w700),
),
],
),
);
}
}
class _SettingsGroup extends StatelessWidget {
const _SettingsGroup({required this.children});
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "متقدم",
"general": "عام",
"downloadDir": "مجلد التنزيل",
"rememberLastDownloadDir": "حفظ دليل آخر مهمة تلقائيًا",
"rememberLastDownloadDir": "حفظ كمجلد التنزيل الافتراضي",
"downloadDirValid": "الرجاء تحديد مجلد التنزيل",
"connections": "الاتصالات",
"useServerCtime": "استخدام وقت الخادم لإنشاء الملف",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "هل تريد حذف مهام {count}؟",
"keepFilesDescription": "قم فقط بإزالة سجل المهمة من Gopeed",
"readingRuntimeStatus": "قراءة حالة وقت العمل...",
"apiConfigSaved": "تم إنقاذها، أعيدي تطبيقها",
"apiConfigSaved": "تم حفظ إعدادات API",
"apiConfigPendingApply": "لم يتم تطبيق الإعدادات المحفوظة بعد على الخدمة قيد التشغيل",
"apiServerRunning": "قيد التشغيل",
"apiServerStopped": "متوقفة",
"apiServerFailed": "خطأ",
"apiServerRuntimeStatus": "حالة التشغيل",
"startApiServer": "بدء",
"stopApiServer": "إيقاف",
"saveAndRestartApiServer": "حفظ وإعادة تشغيل",
"restartApiServerConfirm": "خدمة REST API قيد التشغيل. سيؤدي تطبيق الإعدادات إلى إعادة تشغيلها وقطع اتصالات API الحالية.",
"disableApiServerConfirm": "هل تريد تعطيل خادم REST API؟ سيتم قطع اتصالات API الحالية.",
"socketPathRequired": "الطريق السريع مطلوب",
"serverRequired": "مطلوب من الموظف",
"portRangeError": "أدخل ميناء ما بين 0 و 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Avançat",
"general": "General",
"downloadDir": "Carpeta de baixades",
"rememberLastDownloadDir": "Desa automàticament l'últim directori de tasca",
"rememberLastDownloadDir": "Desa com a carpeta de baixades predeterminada",
"downloadDirValid": "Seleccioneu la carpeta de baixades",
"connections": "Connexions",
"useServerCtime": "Utilitza l'hora del servidor per a la creació de fitxers",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Vols suprimir les tasques {count}?",
"keepFilesDescription": "Elimina només el registre de la tasca de Gopeed",
"readingRuntimeStatus": "S' està llegint l' estat de l' hora d' execució...",
"apiConfigSaved": "S' han desat els paràmetres de l' API. Reinicieu l' aplicació per aplicar- los",
"apiConfigSaved": "La configuració de l'API s'ha desat",
"apiConfigPendingApply": "La configuració desada encara no s'ha aplicat al servei en execució",
"apiServerRunning": "En execució",
"apiServerStopped": "Aturat",
"apiServerFailed": "Error",
"apiServerRuntimeStatus": "Estat d'execució",
"startApiServer": "Inicia",
"stopApiServer": "Atura",
"saveAndRestartApiServer": "Desa i reinicia",
"restartApiServerConfirm": "El servei REST API està en execució. Aplicar aquesta configuració el reiniciarà i interromprà les connexions API existents.",
"disableApiServerConfirm": "Voleu desactivar el servidor REST API? Les connexions API existents s'interrompran.",
"socketPathRequired": "Cal la ruta del sòcol",
"serverRequired": "Es requereix el servidor",
"portRangeError": "Introduïu un port entre 0 i 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Fortschrittlich",
"general": "Allgemein",
"downloadDir": "Download-Verzeichnis",
"rememberLastDownloadDir": "Letztes Aufgabenverzeichnis automatisch speichern",
"rememberLastDownloadDir": "Als Standard-Downloadordner speichern",
"downloadDirValid": "Bitte wählen Sie das Download-Verzeichnis",
"connections": "Anschlüsse",
"useServerCtime": "Serverzeit für die Dateierstellung verwenden",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "{count}-Aufgaben löschen?",
"keepFilesDescription": "Entfernen Sie nur den Aufgabendatensatz aus Gopeed",
"readingRuntimeStatus": "Laufzeitstatus wird gelesen…",
"apiConfigSaved": "API-Einstellungen gespeichert. Starten Sie die App neu, um sie anzuwenden",
"apiConfigSaved": "API-Einstellungen gespeichert",
"apiConfigPendingApply": "Gespeicherte Einstellungen wurden noch nicht auf den laufenden Dienst angewendet",
"apiServerRunning": "Wird ausgeführt",
"apiServerStopped": "Gestoppt",
"apiServerFailed": "Fehler",
"apiServerRuntimeStatus": "Laufzeitstatus",
"startApiServer": "Starten",
"stopApiServer": "Stoppen",
"saveAndRestartApiServer": "Speichern und neu starten",
"restartApiServerConfirm": "Der REST-API-Dienst läuft. Das Anwenden dieser Einstellungen startet ihn neu und unterbricht bestehende API-Verbindungen.",
"disableApiServerConfirm": "REST-API-Server deaktivieren? Bestehende API-Verbindungen werden unterbrochen.",
"socketPathRequired": "Socket-Pfad ist erforderlich",
"serverRequired": "-Server ist erforderlich",
"portRangeError": "Geben Sie einen Port zwischen 0 und 65535 ein",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Advanced",
"general": "General",
"downloadDir": "Download Directory",
"rememberLastDownloadDir": "Automatically save last task directory",
"rememberLastDownloadDir": "Save as default download directory",
"downloadDirValid": "Please select the download directory",
"connections": "Connections",
"useServerCtime": "Use server time for file creation",
@@ -407,7 +407,17 @@
"deleteTasksTitle": "{count, plural, =1{Delete task?} other{Delete {count} tasks?}}",
"keepFilesDescription": "Only remove the task record from Gopeed",
"readingRuntimeStatus": "Reading runtime status…",
"apiConfigSaved": "API settings saved. Restart the app to apply them",
"apiConfigSaved": "API settings saved",
"apiConfigPendingApply": "Saved settings have not been applied to the running service",
"apiServerRunning": "Running",
"apiServerStopped": "Stopped",
"apiServerFailed": "Error",
"apiServerRuntimeStatus": "Runtime status",
"startApiServer": "Start",
"stopApiServer": "Stop",
"saveAndRestartApiServer": "Save and restart",
"restartApiServerConfirm": "The REST API service is running. Applying these settings will restart it and interrupt existing API connections.",
"disableApiServerConfirm": "Disable the REST API server? Existing API connections will be interrupted.",
"socketPathRequired": "Socket path is required",
"serverRequired": "Server is required",
"portRangeError": "Enter a port between 0 and 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Avanzado",
"general": "Generalidades",
"downloadDir": "Directorio de Descarga",
"rememberLastDownloadDir": "Guardar automáticamente el último directorio de tarea",
"rememberLastDownloadDir": "Guardar como carpeta de descargas predeterminada",
"downloadDirValid": "Por favor, selecciona el directorio de descarga",
"connections": "Conexiones",
"useServerCtime": "Usar tiempo del servidor para la creación de archivos",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "¿Eliminar tareas {count}?",
"keepFilesDescription": "Eliminar únicamente el registro de tarea de Gopeed",
"readingRuntimeStatus": "Leyendo el estado de la carrera...",
"apiConfigSaved": "Ajustes de API guardados. reiniciar la aplicación para aplicarlos",
"apiConfigSaved": "Ajustes de API guardados",
"apiConfigPendingApply": "Los ajustes guardados aún no se han aplicado al servicio en ejecución",
"apiServerRunning": "En ejecución",
"apiServerStopped": "Detenido",
"apiServerFailed": "Error",
"apiServerRuntimeStatus": "Estado de ejecución",
"startApiServer": "Iniciar",
"stopApiServer": "Detener",
"saveAndRestartApiServer": "Guardar y reiniciar",
"restartApiServerConfirm": "El servicio REST API está en ejecución. Aplicar estos ajustes lo reiniciará e interrumpirá las conexiones API existentes.",
"disableApiServerConfirm": "¿Desactivar el servidor REST API? Las conexiones API existentes se interrumpirán.",
"socketPathRequired": "Se requiere un camino de bolsillo",
"serverRequired": "Se requiere servidor",
"portRangeError": "Introduzca un puerto entre 0 y 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "پیشرفته",
"general": "عمومی",
"downloadDir": "دایرکتوری دانلود",
"rememberLastDownloadDir": "ذخیره خودکار پوشه آخرین وظیفه",
"rememberLastDownloadDir": "ذخیره به‌عنوان پوشه پیش‌فرض دانلود",
"downloadDirValid": "لطفا دایرکتوری دانلود را انتخاب کنید",
"connections": "اتصالات",
"useServerCtime": "استفاده از زمان سرور برای ایجاد فایل",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "وظایف {count} حذف شود؟",
"keepFilesDescription": "فقط رکورد وظیفه را از Gopeed حذف کنید",
"readingRuntimeStatus": "خواندن وضعیت زمان اجرا ...",
"apiConfigSaved": "تنظیمات API ذخیره شده است. دوباره برنامه را برای اعمال آنها شروع کنید",
"apiConfigSaved": "تنظیمات API ذخیره شد",
"apiConfigPendingApply": "تنظیمات ذخیره‌شده هنوز روی سرویس در حال اجرا اعمال نشده است",
"apiServerRunning": "در حال اجرا",
"apiServerStopped": "متوقف",
"apiServerFailed": "خطا",
"apiServerRuntimeStatus": "وضعیت اجرا",
"startApiServer": "شروع",
"stopApiServer": "توقف",
"saveAndRestartApiServer": "ذخیره و راه‌اندازی مجدد",
"restartApiServerConfirm": "سرویس REST API در حال اجرا است. اعمال تنظیمات، سرویس را دوباره راه‌اندازی کرده و اتصال‌های API موجود را قطع می‌کند.",
"disableApiServerConfirm": "سرور REST API غیرفعال شود؟ اتصال‌های فعلی API قطع خواهند شد.",
"socketPathRequired": "مسیر سوکت مورد نیاز است",
"serverRequired": "سرور مورد نیاز است",
"portRangeError": "یک پورت بین 0 تا 65۳۵ را وارد کنید",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Avancé",
"general": "Général",
"downloadDir": "Répertoire de téléchargement",
"rememberLastDownloadDir": "Enregistrer automatiquement le dernier dossier de tâche",
"rememberLastDownloadDir": "Enregistrer comme dossier de téléchargement par défaut",
"downloadDirValid": "Veuillez sélectionner le répertoire de téléchargement",
"connections": "Connexions",
"useServerCtime": "Utiliser l'heure du serveur pour la création de fichier",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Supprimer les tâches {count} ?",
"keepFilesDescription": "Supprimez uniquement l'enregistrement de tâche de Gopeed",
"readingRuntimeStatus": "Lire le statut d'exécution...",
"apiConfigSaved": "Paramètres de l'API sauvegardés. Redémarrer l'application pour les appliquer",
"apiConfigSaved": "Paramètres de l'API enregistrés",
"apiConfigPendingApply": "Les paramètres enregistrés ne sont pas encore appliqués au service en cours d'exécution",
"apiServerRunning": "En cours d'exécution",
"apiServerStopped": "Arrêté",
"apiServerFailed": "Erreur",
"apiServerRuntimeStatus": "État d'exécution",
"startApiServer": "Démarrer",
"stopApiServer": "Arrêter",
"saveAndRestartApiServer": "Enregistrer et redémarrer",
"restartApiServerConfirm": "Le service REST API est en cours d'exécution. L'application de ces paramètres le redémarrera et interrompra les connexions API existantes.",
"disableApiServerConfirm": "Désactiver le serveur REST API ? Les connexions API existantes seront interrompues.",
"socketPathRequired": "Le chemin de la prise est nécessaire",
"serverRequired": "Serveur requis",
"portRangeError": "Entrez un port entre 0 et 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Haladó",
"general": "Általános",
"downloadDir": "Letöltési könyvtár",
"rememberLastDownloadDir": "Legutóbbi feladatmappa automatikus mentése",
"rememberLastDownloadDir": "Mentés alapértelmezett letöltési mappaként",
"downloadDirValid": "Kérjük, válassza ki a letöltési könyvtárat",
"connections": "Kapcsolatok",
"useServerCtime": "Használja a szerveridőt a fájl létrehozásához",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Törli a {count} feladatokat?",
"keepFilesDescription": "Csak a feladatrekordot távolítsa el a Gopeed alkalmazásból",
"readingRuntimeStatus": "Futási állapot olvasása…",
"apiConfigSaved": "API beállítások mentve. Az alkalmazáshoz indítsa újra az alkalmazást",
"apiConfigSaved": "Az API-beállítások mentve",
"apiConfigPendingApply": "A mentett beállítások még nincsenek alkalmazva a futó szolgáltatásra",
"apiServerRunning": "Fut",
"apiServerStopped": "Leállítva",
"apiServerFailed": "Hiba",
"apiServerRuntimeStatus": "Futási állapot",
"startApiServer": "Indítás",
"stopApiServer": "Leállítás",
"saveAndRestartApiServer": "Mentés és újraindítás",
"restartApiServerConfirm": "A REST API szolgáltatás fut. A beállítások alkalmazása újraindítja, és megszakítja a meglévő API-kapcsolatokat.",
"disableApiServerConfirm": "Letiltja a REST API-kiszolgálót? A meglévő API-kapcsolatok megszakadnak.",
"socketPathRequired": "Aljzatútvonal szükséges",
"serverRequired": "szerver szükséges",
"portRangeError": "Adjon meg egy portot 0 és 65535 között",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Lanjutan",
"general": "Umum",
"downloadDir": "Direktori Unduhan",
"rememberLastDownloadDir": "Simpan otomatis direktori tugas terakhir",
"rememberLastDownloadDir": "Simpan sebagai folder unduhan default",
"downloadDirValid": "Silahkan pilih direktori unduhan",
"connections": "Koneksi",
"useServerCtime": "Gunakan waktu server untuk pembuatan file",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Hapus tugas {count}?",
"keepFilesDescription": "Hanya menghapus catatan tugas dari Gopeed",
"readingRuntimeStatus": "Membaca status waktu-jalan...",
"apiConfigSaved": "Pengaturan API disimpan. Jalankan ulang aplikasi untuk menerapkannya",
"apiConfigSaved": "Pengaturan API disimpan",
"apiConfigPendingApply": "Pengaturan tersimpan belum diterapkan ke layanan yang sedang berjalan",
"apiServerRunning": "Berjalan",
"apiServerStopped": "Berhenti",
"apiServerFailed": "Kesalahan",
"apiServerRuntimeStatus": "Status berjalan",
"startApiServer": "Mulai",
"stopApiServer": "Hentikan",
"saveAndRestartApiServer": "Simpan dan mulai ulang",
"restartApiServerConfirm": "Layanan REST API sedang berjalan. Menerapkan pengaturan ini akan memulai ulang layanan dan memutus koneksi API yang ada.",
"disableApiServerConfirm": "Nonaktifkan server REST API? Koneksi API yang ada akan terputus.",
"socketPathRequired": "Path socket dibutuhkan",
"serverRequired": "Server diperlukan",
"portRangeError": "Masukkan sebuah port antara 0 dan 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Avanzato",
"general": "Generale",
"downloadDir": "Directory di scaricamento",
"rememberLastDownloadDir": "Salva automaticamente l'ultima cartella dell'attività",
"rememberLastDownloadDir": "Salva come cartella di download predefinita",
"downloadDirValid": "Seleziona la directory di scaricamento",
"connections": "Connessioni",
"useServerCtime": "Utilizza l'ora del server per la creazione dei file",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Eliminare le attività {count}?",
"keepFilesDescription": "Rimuovi solo il record dell'attività da Gopeed",
"readingRuntimeStatus": "Leggere lo stato di runtime...",
"apiConfigSaved": "Impostazioni API salvate. Riavviare l'app per applicarle",
"apiConfigSaved": "Impostazioni API salvate",
"apiConfigPendingApply": "Le impostazioni salvate non sono ancora state applicate al servizio in esecuzione",
"apiServerRunning": "In esecuzione",
"apiServerStopped": "Arrestato",
"apiServerFailed": "Errore",
"apiServerRuntimeStatus": "Stato di esecuzione",
"startApiServer": "Avvia",
"stopApiServer": "Arresta",
"saveAndRestartApiServer": "Salva e riavvia",
"restartApiServerConfirm": "Il servizio REST API è in esecuzione. L'applicazione di queste impostazioni lo riavvierà e interromperà le connessioni API esistenti.",
"disableApiServerConfirm": "Disattivare il server REST API? Le connessioni API esistenti verranno interrotte.",
"socketPathRequired": "Il percorso di presa è richiesto",
"serverRequired": "Il server è richiesto",
"portRangeError": "Inserisci una porta tra 0 e 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "高度",
"general": "全般",
"downloadDir": "ダウンロードディレクトリ",
"rememberLastDownloadDir": "前回のタスク保存先を自動保存",
"rememberLastDownloadDir": "デフォルトのダウンロード先として保存",
"downloadDirValid": "ダウンロードディレクトリを選択してください",
"connections": "接続",
"useServerCtime": "ファイル作成時にサーバー時間を使用する",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "{count}タスクを削除しますか?",
"keepFilesDescription": "Gopeed からタスクレコードを削除するだけです",
"readingRuntimeStatus": "実行時間ステータスを読み込みます...",
"apiConfigSaved": "API の設定を保存します。アプリを再起動して、それらを適用します",
"apiConfigSaved": "API 設定を保存しました",
"apiConfigPendingApply": "保存した設定は実行中のサービスにまだ適用されていません",
"apiServerRunning": "実行中",
"apiServerStopped": "停止中",
"apiServerFailed": "エラー",
"apiServerRuntimeStatus": "稼働状態",
"startApiServer": "開始",
"stopApiServer": "停止",
"saveAndRestartApiServer": "保存して再起動",
"restartApiServerConfirm": "REST API サービスは実行中です。新しい設定を適用するとサービスが再起動し、既存の API 接続が切断されます。",
"disableApiServerConfirm": "REST API サーバーを無効にしますか?既存の API 接続は切断されます。",
"socketPathRequired": "ソケットパスが必要です",
"serverRequired": "サーバが必要です",
"portRangeError": "0と65535の間のポートを入力してください",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "고급",
"general": "일반",
"downloadDir": "다운로드 폴더",
"rememberLastDownloadDir": "마지막 작업 디렉터리 자동 저장",
"rememberLastDownloadDir": "기본 다운로드 폴더로 저장",
"downloadDirValid": "다운로드 폴더를 선택해 주세요",
"connections": "연결 수",
"useServerCtime": "파일 생성 시간에 서버 시간 사용",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "{count} 작업을 삭제?",
"keepFilesDescription": "Gopeed에서 작업 기록을 제거합니다",
"readingRuntimeStatus": "읽기 런타임 상태 ...",
"apiConfigSaved": "API 설정 저장. 앱을 다시 실행하여 적용할 수 있습니다",
"apiConfigSaved": "API 설정을 저장했습니다",
"apiConfigPendingApply": "저장된 설정이 실행 중인 서비스에 아직 적용되지 않았습니다",
"apiServerRunning": "실행 중",
"apiServerStopped": "중지됨",
"apiServerFailed": "오류",
"apiServerRuntimeStatus": "실행 상태",
"startApiServer": "시작",
"stopApiServer": "중지",
"saveAndRestartApiServer": "저장하고 다시 시작",
"restartApiServerConfirm": "REST API 서비스가 실행 중입니다. 새 설정을 적용하면 서비스가 다시 시작되고 기존 API 연결이 끊어집니다.",
"disableApiServerConfirm": "REST API 서버를 비활성화할까요? 기존 API 연결이 끊어집니다.",
"socketPathRequired": "소켓 경로가 필요합니다",
"serverRequired": "서버가 필요합니다",
"portRangeError": "0과 65535 사이의 포트를 입력",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Zaawansowane",
"general": "Ogólne",
"downloadDir": "Folder Pobierania",
"rememberLastDownloadDir": "Automatycznie zapisuj ostatni katalog zadania",
"rememberLastDownloadDir": "Zapisz jako domyślny folder pobierania",
"downloadDirValid": "Wskaż folder pobierania",
"connections": "Połączenia",
"useServerCtime": "Użyj czasu serwera do utworzenia pliku",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Usuwa {count} zadania?",
"keepFilesDescription": "Usuń tylko wpis zadania z Gopeed",
"readingRuntimeStatus": "Odczyt statusu runtime...",
"apiConfigSaved": "Zapisane ustawienia API. Przywróć aplikację, aby je zastosować",
"apiConfigSaved": "Ustawienia API zapisano",
"apiConfigPendingApply": "Zapisane ustawienia nie zostały jeszcze zastosowane do działającej usługi",
"apiServerRunning": "Uruchomiono",
"apiServerStopped": "Zatrzymano",
"apiServerFailed": "Błąd",
"apiServerRuntimeStatus": "Stan działania",
"startApiServer": "Uruchom",
"stopApiServer": "Zatrzymaj",
"saveAndRestartApiServer": "Zapisz i uruchom ponownie",
"restartApiServerConfirm": "Usługa REST API działa. Zastosowanie ustawień uruchomi ją ponownie i przerwie istniejące połączenia API.",
"disableApiServerConfirm": "Wyłączyć serwer REST API? Istniejące połączenia API zostaną przerwane.",
"socketPathRequired": "Ścieżka gniazda jest wymagana",
"serverRequired": "Wymagany jest serwer",
"portRangeError": "Wprowadź port pomiędzy 0 a 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Configurações Avançada",
"general": "Principal",
"downloadDir": "Onde você deseja salvar?",
"rememberLastDownloadDir": "Salvar automaticamente o último diretório da tarefa",
"rememberLastDownloadDir": "Salvar como pasta de download padrão",
"downloadDirValid": "Por favor, selecione a pasta",
"connections": "Conexões",
"useServerCtime": "Usar o tempo de servidor para a criação de ficheiros",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Apagar {count} tarefas?",
"keepFilesDescription": "Remova apenas o registro de tarefas do Gopeed",
"readingRuntimeStatus": "A ler o estado da execução...",
"apiConfigSaved": "Configurações da API salvas. Reinicie o aplicativo para aplicá- las",
"apiConfigSaved": "Configurações da API salvas",
"apiConfigPendingApply": "As configurações salvas ainda não foram aplicadas ao serviço em execução",
"apiServerRunning": "Em execução",
"apiServerStopped": "Parado",
"apiServerFailed": "Erro",
"apiServerRuntimeStatus": "Estado de execução",
"startApiServer": "Iniciar",
"stopApiServer": "Parar",
"saveAndRestartApiServer": "Salvar e reiniciar",
"restartApiServerConfirm": "O serviço REST API está em execução. Aplicar estas configurações reiniciará o serviço e interromperá as conexões API existentes.",
"disableApiServerConfirm": "Desativar o servidor REST API? As conexões API existentes serão interrompidas.",
"socketPathRequired": "O caminho do 'socket' é necessário",
"serverRequired": "O servidor é necessário",
"portRangeError": "Digite uma porta entre 0 e 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Расширенные",
"general": "Общие",
"downloadDir": "Папка загрузки",
"rememberLastDownloadDir": "Автоматически сохранять последнюю папку задачи",
"rememberLastDownloadDir": "Сохранить как папку загрузок по умолчанию",
"downloadDirValid": "Пожалуйста, выберите папку для загрузки",
"connections": "Соединений",
"useServerCtime": "Использовать время сервера для создания файла",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Удалить {count} задачи?",
"keepFilesDescription": "Удалить только запись задачи из Gopeed",
"readingRuntimeStatus": "Статус чтения Runtime...",
"apiConfigSaved": "Сохранены настройки API. Перезагрузите приложение, чтобы применить их",
"apiConfigSaved": "Настройки API сохранены",
"apiConfigPendingApply": "Сохранённые настройки ещё не применены к работающему сервису",
"apiServerRunning": "Работает",
"apiServerStopped": "Остановлен",
"apiServerFailed": "Ошибка",
"apiServerRuntimeStatus": "Состояние работы",
"startApiServer": "Запустить",
"stopApiServer": "Остановить",
"saveAndRestartApiServer": "Сохранить и перезапустить",
"restartApiServerConfirm": "Сервис REST API работает. Применение настроек перезапустит его и прервёт текущие API-соединения.",
"disableApiServerConfirm": "Отключить сервер REST API? Текущие API-соединения будут прерваны.",
"socketPathRequired": "Требуется Socket Path",
"serverRequired": "Сервер необходим",
"portRangeError": "Введите порт между 0 и 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "மேம்பட்ட",
"general": "பொதுவான",
"downloadDir": "பதிவிரக்குமிடம்",
"rememberLastDownloadDir": "கடைசி பணி கோப்பகத்தைத் தானாகச் சேமி",
"rememberLastDownloadDir": "இயல்புநிலை பதிவிறக்க கோப்புறையாக சேமி",
"downloadDirValid": "பதிவிரக்குமிடத்தை தேர்வு செயுங்கள்",
"connections": "தொடர்புகள்",
"useServerCtime": "கோப்பு உருவாக்க சேவையக நேரத்தைப் பயன்படுத்தவும்",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "{count} பணிகளை நீக்கவா?",
"keepFilesDescription": "Gopeed இலிருந்து பணி பதிவை மட்டுமே அகற்றவும்",
"readingRuntimeStatus": "இயக்கநேர நிலையைப் படிக்கிறது...",
"apiConfigSaved": "API அமைப்புகள் சேமிக்கப்பட்டன. அவற்றைப் பயன்படுத்த ஆப்பை மறுதொடக்கம் செய்யவும்",
"apiConfigSaved": "API அமைப்புகள் சேமிக்கப்பட்டன",
"apiConfigPendingApply": "சேமித்த அமைப்புகள் இயங்கும் சேவையில் இன்னும் பயன்படுத்தப்படவில்லை",
"apiServerRunning": "இயங்குகிறது",
"apiServerStopped": "நிறுத்தப்பட்டது",
"apiServerFailed": "பிழை",
"apiServerRuntimeStatus": "இயக்க நிலை",
"startApiServer": "தொடங்கு",
"stopApiServer": "நிறுத்து",
"saveAndRestartApiServer": "சேமித்து மறுதொடக்கம் செய்",
"restartApiServerConfirm": "REST API சேவை இயங்குகிறது. புதிய அமைப்புகளைப் பயன்படுத்தினால் சேவை மறுதொடக்கம் செய்யப்பட்டு தற்போதைய API இணைப்புகள் துண்டிக்கப்படும்.",
"disableApiServerConfirm": "REST API சேவையகத்தை முடக்கவா? தற்போதைய API இணைப்புகள் துண்டிக்கப்படும்.",
"socketPathRequired": "சாக்கெட் பாதை தேவை",
"serverRequired": "சேவையகம் தேவை",
"portRangeError": "0 மற்றும் 65535 க்கு இடையில் ஒரு போர்ட்டை உள்ளிடவும்",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Gelişmiş",
"general": "Genel",
"downloadDir": "İndirme Dizini",
"rememberLastDownloadDir": "Son görev dizinini otomatik kaydet",
"rememberLastDownloadDir": "Varsayılan indirme klasörü olarak kaydet",
"downloadDirValid": "Lütfen indirme dizinini seçin",
"connections": "Bağlantılar",
"useServerCtime": "Dosya oluşturma tarihi için sunucu zamanını kullan",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "{count} görevi mi?",
"keepFilesDescription": "Sadece Gopeed'den görev kayıtlarını kaldır",
"readingRuntimeStatus": "Okuma runtime statüsü...",
"apiConfigSaved": "API ayarları kurtarıldı. Uygulamayı onları uygulamak için yeniden başlat",
"apiConfigSaved": "API ayarları kaydedildi",
"apiConfigPendingApply": "Kaydedilen ayarlar çalışan hizmete henüz uygulanmadı",
"apiServerRunning": "Çalışıyor",
"apiServerStopped": "Durduruldu",
"apiServerFailed": "Hata",
"apiServerRuntimeStatus": "Çalışma durumu",
"startApiServer": "Başlat",
"stopApiServer": "Durdur",
"saveAndRestartApiServer": "Kaydet ve yeniden başlat",
"restartApiServerConfirm": "REST API hizmeti çalışıyor. Bu ayarları uygulamak hizmeti yeniden başlatır ve mevcut API bağlantılarını keser.",
"disableApiServerConfirm": "REST API sunucusu devre dışı bırakılsın mı? Mevcut API bağlantıları kesilecek.",
"socketPathRequired": "CPU yolu gereklidir",
"serverRequired": "Server gereklidir",
"portRangeError": "0 ile 65535 arasında bir liman girin",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Розширені",
"general": "Загальні",
"downloadDir": "Каталог завантажень",
"rememberLastDownloadDir": "Автоматично зберігати останню папку завдання",
"rememberLastDownloadDir": "Зберегти як типову папку завантажень",
"downloadDirValid": "Будь ласка, виберіть каталог завантажень",
"connections": "З’єднання",
"useServerCtime": "Використовувати час сервера для створення файлу",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Видалення задач {count}?",
"keepFilesDescription": "Тільки видаліть запис задачі від Надії",
"readingRuntimeStatus": "Читання статусу робочого часу ...",
"apiConfigSaved": "Налаштування API, збережені. Перезапустіть додаток, щоб застосувати їх",
"apiConfigSaved": "Налаштування API збережено",
"apiConfigPendingApply": "Збережені налаштування ще не застосовано до запущеного сервісу",
"apiServerRunning": "Працює",
"apiServerStopped": "Зупинено",
"apiServerFailed": "Помилка",
"apiServerRuntimeStatus": "Стан роботи",
"startApiServer": "Запустити",
"stopApiServer": "Зупинити",
"saveAndRestartApiServer": "Зберегти й перезапустити",
"restartApiServerConfirm": "Сервіс REST API працює. Застосування налаштувань перезапустить його та перерве поточні API-з’єднання.",
"disableApiServerConfirm": "Вимкнути сервер REST API? Поточні API-з’єднання буде перервано.",
"socketPathRequired": "План розетки необхідний",
"serverRequired": "Сервер необхідно",
"portRangeError": "Введіть порт між 0 і 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "Nâng cao",
"general": "Chung",
"downloadDir": "Thư mục tải về",
"rememberLastDownloadDir": "Tự động lưu thư mục tác vụ gần nhất",
"rememberLastDownloadDir": "Lưu làm thư mục tải xuống mặc định",
"downloadDirValid": "Vui lòng chọn thư mục tải về",
"connections": "Kết nối",
"useServerCtime": "Sử dụng thời gian máy chủ cho việc tạo tệp",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "Xóa {count} tác vụ?",
"keepFilesDescription": "Chỉ gỡ bỏ hồ sơ tác vụ từ Gopeed",
"readingRuntimeStatus": "Đang đọc trạng thái thời gian chạy...",
"apiConfigSaved": "Thiết lập i đã lưu. Hãy khởi động lại ứng dụng",
"apiConfigSaved": "Đã lưu cài đặt API",
"apiConfigPendingApply": "Cài đặt đã lưu chưa được áp dụng cho dịch vụ đang chạy",
"apiServerRunning": "Đang chạy",
"apiServerStopped": "Đã dừng",
"apiServerFailed": "Lỗi",
"apiServerRuntimeStatus": "Trạng thái hoạt động",
"startApiServer": "Khởi động",
"stopApiServer": "Dừng",
"saveAndRestartApiServer": "Lưu và khởi động lại",
"restartApiServerConfirm": "Dịch vụ REST API đang chạy. Áp dụng cài đặt sẽ khởi động lại dịch vụ và ngắt các kết nối API hiện có.",
"disableApiServerConfirm": "Tắt máy chủ REST API? Các kết nối API hiện có sẽ bị gián đoạn.",
"socketPathRequired": "Cần thiết phải có đường dẫn ổ cắm điện",
"serverRequired": "Cần thiết máy phục vụ",
"portRangeError": "Nhập một cổng từ 0 đến 65535",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "高级",
"general": "通用",
"downloadDir": "下载目录",
"rememberLastDownloadDir": "自动保存上次任务目录",
"rememberLastDownloadDir": "保存为默认下载目录",
"downloadDirValid": "请选择下载目录",
"connections": "连接数",
"useServerCtime": "创建文件使用服务器时间",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "删除 {count} 个任务?",
"keepFilesDescription": "仅从 Gopeed 中移除任务记录",
"readingRuntimeStatus": "正在读取运行状态…",
"apiConfigSaved": "API 配置已保存,重启应用后生效",
"apiConfigSaved": "API 配置已保存",
"apiConfigPendingApply": "配置已保存,但尚未应用到当前运行的服务",
"apiServerRunning": "运行中",
"apiServerStopped": "已停止",
"apiServerFailed": "异常",
"apiServerRuntimeStatus": "运行状态",
"startApiServer": "启动",
"stopApiServer": "停止",
"saveAndRestartApiServer": "保存并重启",
"restartApiServerConfirm": "REST API 服务正在运行,应用新配置需要重启服务,并会中断现有 API 连接。",
"disableApiServerConfirm": "确定关闭 REST API 服务吗?现有的 API 连接将会中断。",
"socketPathRequired": "Socket 路径不能为空",
"serverRequired": "服务器不能为空",
"portRangeError": "请输入 0–65535 之间的端口",
+12 -2
View File
@@ -34,7 +34,7 @@
"advanced": "進階",
"general": "一般",
"downloadDir": "下載目錄",
"rememberLastDownloadDir": "自動儲存上次任務目錄",
"rememberLastDownloadDir": "儲存為預設下載目錄",
"downloadDirValid": "請選擇下載目錄",
"connections": "連接數",
"useServerCtime": "使用伺服器時間作為檔案建立時間",
@@ -393,7 +393,17 @@
"deleteTasksTitle": "刪除 {count} 個任務?",
"keepFilesDescription": "僅從 Gopeed 中移除任務記錄",
"readingRuntimeStatus": "正在讀取執行狀態…",
"apiConfigSaved": "API 配置已儲存,重啟應用後生效",
"apiConfigSaved": "API 配置已儲存",
"apiConfigPendingApply": "配置已儲存,但尚未套用到目前執行中的服務",
"apiServerRunning": "執行中",
"apiServerStopped": "已停止",
"apiServerFailed": "異常",
"apiServerRuntimeStatus": "執行狀態",
"startApiServer": "啟動",
"stopApiServer": "停止",
"saveAndRestartApiServer": "儲存並重新啟動",
"restartApiServerConfirm": "REST API 服務正在執行,套用新配置需要重新啟動服務,並會中斷現有 API 連線。",
"disableApiServerConfirm": "確定要關閉 REST API 服務嗎?現有的 API 連線將會中斷。",
"socketPathRequired": "Socket 路徑不能為空",
"serverRequired": "伺服器不能為空",
"portRangeError": "請輸入 0–65535 之間的埠",
+1 -15
View File
@@ -13,9 +13,6 @@ import 'core/entry/app_startup_options.dart';
import 'core/window/app_window_bootstrap.dart';
import 'core/window/app_window_launch_context.dart';
import 'core/window/window_capability_transport.dart';
import 'database/database.dart';
import 'util/analytics.dart';
import 'util/log_util.dart';
import 'util/util.dart';
Future<void> main(List<String> args) async {
@@ -46,18 +43,7 @@ Future<void> main(List<String> args) async {
} catch (_) {}
}
await AppInitializer.ensureDatabaseInitialized();
await AppInitializer.ensureStorageInitialized();
await AppWindowBootstrap.setupMainWindow(hidden: startupOptions.hidden);
runApp(const ProviderScope(child: GopeedApp()));
unawaited(_logAppOpen());
}
Future<void> _logAppOpen() async {
if (!AnalyticsConfig.isConfigured || !Database.instance.getAnalyticsEnabled()) return;
try {
await Analytics.instance.init();
await Analytics.instance.logAppOpen();
} catch (error) {
logger.w('GA4 init failed: $error');
}
}
+9 -9
View File
@@ -4,7 +4,6 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../database/database.dart';
import 'log_util.dart';
import 'package_info.dart';
@@ -18,8 +17,8 @@ class AnalyticsConfig {
static bool get isConfigured => measurementId.isNotEmpty && apiSecret.isNotEmpty;
}
/// Sends only anonymous application events. The preference is stored outside
/// downloader configuration so it remains local to the current installation.
/// Sends only anonymous application events. Its preference and fallback
/// client ID are persisted by the Go downloader configuration.
class Analytics {
Analytics._();
@@ -30,9 +29,11 @@ class Analytics {
late final Dio _dio;
bool _initialized = false;
Future<void> init() async {
String get clientId => _initialized ? _clientId : '';
Future<void> init({String fallbackClientId = ''}) async {
if (_initialized || !AnalyticsConfig.isConfigured) return;
_clientId = await _getDeviceId();
_clientId = await _getDeviceId(fallbackClientId);
_sessionId = DateTime.now().millisecondsSinceEpoch ~/ 1000;
_dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10)));
_initialized = true;
@@ -69,7 +70,7 @@ class Analytics {
}
}
Future<String> _getDeviceId() async {
Future<String> _getDeviceId(String fallbackClientId) async {
String? deviceId;
try {
final info = DeviceInfoPlugin();
@@ -91,12 +92,11 @@ class Analytics {
}
if (deviceId == null || deviceId.isEmpty) {
deviceId = Database.instance.getAnalyticsClientId();
if (deviceId == null || deviceId.isEmpty) {
deviceId = fallbackClientId;
if (deviceId.isEmpty) {
final random = DateTime.now().microsecondsSinceEpoch % 2147483647;
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
deviceId = '$random.$timestamp';
Database.instance.saveAnalyticsClientId(deviceId);
}
}
return deviceId;