feat: move native communication to FFI (#1457)

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

No files matched your search

@@ -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 = [