Files
gopeed/ui/flutter/lib/app/application/app_runtime_controller.dart
T

416 lines
14 KiB
Dart

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, AppRuntimeState>(
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<AppRuntimeState> {
static StartConfig? _defaultStartConfig;
Future<void> _preferenceWrites = Future<void>.value();
@override
Future<AppRuntimeState> build() async {
ref.onDispose(() => unawaited(WebViewRpcService.instance.stop()));
return _init();
}
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);
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<AppRuntimeState> _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<void> _initAndroidForegroundService(AppLocalizations l10n) async {
try {
await AndroidForegroundService.ensureRunning(l10n);
} catch (error, stackTrace) {
logger.w('Android foreground service initialization failed', error, stackTrace);
}
}
Future<StartConfig> _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<StartConfig> _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<DownloaderConfig> _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<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() async {
final service = ref.read(gopeedServiceProvider);
try {
await service.triggerProtocolJob('bt', 'update-trackers');
} finally {
replaceDownloaderConfig(await service.getConfig());
}
}
Future<String> _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<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
..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<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
..mcpEnable = config.mcpEnable
..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;
}
}