mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 10:00:27 +00:00
feat: move native communication to FFI (#1457)
This commit is contained in:
86 files changed
+4558
-886
No files matched your search
+45
-105
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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}');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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۳۵ را وارد کنید",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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の間のポートを入力してください",
|
||||
|
||||
@@ -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 사이의 포트를 입력",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 க்கு இடையில் ஒரு போர்ட்டை உள்ளிடவும்",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 之间的端口",
|
||||
|
||||
@@ -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 之間的埠",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user