import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import '../../api/api.dart' as api; import '../../api/model/downloader_config.dart'; import 'downloader_config_init.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 '../../core/network/gopeed/gopeed_transport.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'; import 'android_foreground_service.dart'; import 'location_keep_alive.dart'; import 'continued_processing.dart'; const unixSocketPath = 'gopeed.sock'; final appRuntimeControllerProvider = AsyncNotifierProvider( AppRuntimeController.new, ); class AppRuntimeState { const AppRuntimeState({ required this.startConfig, required this.apiServerState, required this.downloaderConfig, this.localBackendStarted = false, this.startupError, }); final StartConfig startConfig; final ApiServerState apiServerState; final DownloaderConfig downloaderConfig; final bool localBackendStarted; final Object? startupError; int get runningPort => apiServerState.runningPort; String runningAddress() => apiServerState.runningAddress(); } class AppRuntimeController extends AsyncNotifier { static StartConfig? _defaultStartConfig; Future _preferenceWrites = Future.value(); @override Future build() async { ref.onDispose(() => unawaited(WebViewRpcService.instance.stop())); return _init(); } Future 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); if (kIsWeb) { // The Web client connection may point at a separate development server // from the page itself. Downloader config describes the remote process // and must not replace that client-side endpoint after reload. startConfig ..apiEnable = true ..mcpEnable = config.api.mcpEnable; } else { startConfig ..apiEnable = config.api.enable ..mcpEnable = config.api.mcpEnable ..network = config.api.network ..address = config.api.address ..apiToken = config.api.token; } state = AsyncValue.data( AppRuntimeState( startConfig: startConfig, apiServerState: apiState, downloaderConfig: config, localBackendStarted: current.localBackendStarted, startupError: current.startupError, ), ); } void replaceDownloaderConfig(DownloaderConfig config) { final current = state.value; if (current == null) return; state = AsyncValue.data( AppRuntimeState( startConfig: current.startConfig, apiServerState: current.apiServerState, downloaderConfig: DownloaderConfig.fromJson(config.toJson()), localBackendStarted: current.localBackendStarted, startupError: current.startupError, ), ); unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive)); unawaited(ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing)); } Future _init() async { await AppInitializer.ensureStorageInitialized(); try { await initPackageInfo(); } catch (_) {} initLogger(); final startConfig = await _loadStartConfig(); try { startConfig.webViewRpcConfig = await WebViewRpcService.instance.start(); } catch (error, stackTrace) { logger.w('webview RPC initialization failed', error, stackTrace); } Object? startupError; var runningPort = 0; var apiServerState = const ApiServerState( enabled: false, mcpEnabled: 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) { logger.e('libgopeed init failed', error, stackTrace); rethrow; } if (kIsWeb) { final webAuth = ref.read(webAuthControllerProvider); api.init('tcp', startConfig.address, '', onUnauthorized: webAuth.requireLogin); } else { api.init(startConfig.network, _runningAddress(startConfig, runningPort), startConfig.apiToken); } final config = await _loadDownloaderConfig(); if (!kIsWeb) { startConfig ..apiEnable = config.api.enable ..mcpEnable = config.api.mcpEnable ..network = config.api.network ..address = config.api.address ..apiToken = config.api.token; } unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale))); await _initAnalytics(config); final result = AppRuntimeState( startConfig: startConfig, apiServerState: apiServerState, downloaderConfig: config, localBackendStarted: localBackendStarted, startupError: startupError, ); if (Util.isIOS()) { // Make sure the native Continued Processing manager has the // persisted setting before app initialization completes. This // avoids the first download racing ahead of setEnabled(true). await ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing); LocationKeepAliveCoordinator.instance.start( () => state.value?.downloaderConfig.extra.backgroundLocationKeepAlive ?? config.extra.backgroundLocationKeepAlive, ); } return result; } Future _initAndroidForegroundService(AppLocalizations l10n) async { try { await AndroidForegroundService.ensureRunning(l10n); } catch (error, stackTrace) { logger.w('Android foreground service initialization failed', error, stackTrace); } } Future _loadStartConfig() async { final defaultCfg = await _initDefaultStartConfig(); final cfg = StartConfig() ..network = defaultCfg.network ..address = defaultCfg.address ..apiToken = defaultCfg.apiToken ..storage = defaultCfg.storage ..storageDir = defaultCfg.storageDir ..refreshInterval = defaultCfg.refreshInterval; return cfg; } Future _initDefaultStartConfig() async { if (_defaultStartConfig != null) { return _defaultStartConfig!; } final cfg = StartConfig() ..storage = 'bolt' ..storageDir = Util.getStorageDir() ..refreshInterval = 0 ..apiToken = ''; if (kIsWeb) { cfg ..network = 'tcp' ..address = webApiBaseUrl(Uri.base); _defaultStartConfig = cfg; return cfg; } if (!Util.supportUnixSocket()) { cfg ..network = 'tcp' ..address = '127.0.0.1:0'; } else { cfg ..network = 'unix' ..address = '${(await getTemporaryDirectory()).path}/$unixSocketPath'; } _defaultStartConfig = cfg; return cfg; } Future _loadDownloaderConfig() { return loadDownloaderConfig( load: () => ref.read(gopeedServiceProvider).getConfig(), save: (config) => ref.read(gopeedServiceProvider).putConfig(config), defaultDownloadDir: _defaultDownloadDir, onLoadError: (error, stackTrace) => logger.w('load downloader config failed', error, stackTrace), onSaveError: (error, stackTrace) => logger.w('persist downloader config failed', error, stackTrace), ); } Future _initAnalytics(DownloaderConfig config) async { if (!AnalyticsConfig.isConfigured || !config.extra.analyticsEnabled) return; try { final generatedClientId = await Analytics.instance.init(fallbackClientId: config.extra.deviceId); if (generatedClientId != null) { config.extra.deviceId = generatedClientId; await ref.read(gopeedServiceProvider).putConfig(config); } unawaited(Analytics.instance.logAppOpen()); } catch (error, stackTrace) { logger.w('GA4 init failed', error, stackTrace); } } Future updateTrackers() async { final service = ref.read(gopeedServiceProvider); try { await service.triggerProtocolJob('bt', 'update-trackers'); } finally { replaceDownloaderConfig(await service.getConfig()); } } Future _defaultDownloadDir() async { // An unknown directory stays empty: the config is sent to the backend, and // a relative path would point at the backend working directory. if (kIsWeb) { return ''; } if (Util.isDesktop()) { return (await getDownloadsDirectory())?.path ?? ''; } if (Util.isAndroid()) { return (await getExternalStorageDirectory())?.path ?? (await getApplicationDocumentsDirectory()).path; } if (Util.isIOS()) { return (await getApplicationDocumentsDirectory()).path; } return ''; } String _runningAddress(StartConfig config, int runningPort) { if (config.network == 'unix') { return config.address; } return '${config.address.split(':').first}:$runningPort'; } Future saveApiServerConfig(StartConfig config) async { if (kIsWeb) return; await _persistApiServerConfig(config); } Future startApiServer() async { if (kIsWeb) return; await _persistApiServerEnabled(true); await _applyApiServerOperation(LibgopeedBoot.instance.startApiServer()); } Future stopApiServer() async { if (kIsWeb) return; await _persistApiServerEnabled(false); await _applyApiServerOperation(LibgopeedBoot.instance.stopApiServer()); } Future restartApiServer(StartConfig config) async { if (kIsWeb) return; final desired = _copyStartConfig(config)..apiEnable = true; await _persistApiServerConfig(desired); await _applyApiServerOperation(LibgopeedBoot.instance.restartApiServer()); } Future refreshApiServerState() async { if (kIsWeb) return; await _applyApiServerOperation(LibgopeedBoot.instance.getApiServerState()); } Future _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 ..mcpEnable = nextConfig.mcpEnable ..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 _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 _applyApiServerOperation(Future 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 ..mcpEnable = config.mcpEnable ..storage = config.storage ..storageDir = config.storageDir ..refreshInterval = config.refreshInterval ..apiToken = config.apiToken ..webViewRpcConfig = config.webViewRpcConfig; } Future 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((_) {}, onError: (_, _) {}); return operation; } }