import 'dart:async'; import 'dart:io'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:fl_clash/common/boot_guard.dart'; import 'package:fl_clash/common/boot_record.dart'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/common/launch.dart'; import 'package:fl_clash/common/migration.dart'; import 'package:fl_clash/common/tray.dart'; import 'package:fl_clash/common/window.dart'; import 'package:fl_clash/database/database.dart'; import 'package:fl_clash/enum/enum.dart'; import 'package:fl_clash/l10n/l10n.dart'; import 'package:fl_clash/models/models.dart'; import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; import 'package:fl_clash/views/disclaimer.dart'; import 'package:fl_clash/views/navigation.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:material_color_utilities/palettes/tonal_palette.dart'; import 'package:package_info_plus/package_info_plus.dart'; class Bootstrap { static Bootstrap? _instance; Bootstrap._internal(); factory Bootstrap() { _instance ??= Bootstrap._internal(); return _instance!; } BootDecision _bootDecision = const BootDecision(); Future init(int version) async { globalState.appEnv = const String.fromEnvironment( 'APP_ENV', defaultValue: 'pre', ); windowPort = window; trayPort = appTray; navigationPort = navigation; final dynamicColor = await _initDynamicColor(); return _initData(version, dynamicColor); } Future _initDynamicColor() async { TonalPalette? primaryPalette; Color? accentColor; try { primaryPalette = await _getSystemPrimaryPalette(); } catch (error) { commonPrint.log( 'Failed to get core palette: $error', logLevel: LogLevel.warning, ); } try { accentColor = await DynamicColorPlugin.getAccentColor(); } catch (error) { commonPrint.log( 'Failed to get accent color: $error', logLevel: LogLevel.warning, ); } return ( lightSeed: primaryPalette != null ? Color(primaryPalette.get(40)) : null, darkSeed: primaryPalette != null ? Color(primaryPalette.get(80)) : null, accentColor: accentColor ?? const Color(defaultPrimaryColor), ); } /// Runs before migration and the database: a copy started from another /// install path gets past the platform's single-instance hand-off. Future _claimDataDirectory() async { if (!system.isDesktop || await singleInstanceLock.acquire()) { return; } commonPrint.log('another instance owns the data directory, exiting'); exit(0); } Future _getSystemPrimaryPalette() async { final raw = await DynamicColorPlugin.channel.invokeMethod>( DynamicColorPlugin.methodName, ); if (raw == null || raw.length < TonalPalette.commonSize) { return null; } return TonalPalette.fromList( raw.sublist(0, TonalPalette.commonSize).cast(), ); } Future _initData( int version, DynamicColorSeeds dynamicColor, ) async { await _claimDataDirectory(); globalState.packageInfo = await PackageInfo.fromPlatform(); var config = await migration.run(); _bootDecision = await bootGuard.evaluate( profileId: config.currentProfileId, ); if (_bootDecision.recovery == BootRecovery.clearProfile) { config = config.copyWith(currentProfileId: null); await preferences.saveConfig(config); } final patchClashConfig = config.patchClashConfig.ensureControllerSecret(); if (patchClashConfig != config.patchClashConfig) { config = config.copyWith(patchClashConfig: patchClashConfig); await preferences.saveConfig(config); } final appState = AppState( brightness: WidgetsBinding.instance.platformDispatcher.platformBrightness, version: version, viewSize: Size.zero, requests: FixedList(maxRequestsLength), logs: FixedList(maxLogsLength), traffics: FixedList(trafficSampleLength), totalTraffic: const Traffic(), systemUiOverlayStyle: const SystemUiOverlayStyle(), ); final appStateOverrides = buildAppStateOverrides(appState); final configOverrides = buildConfigOverrides(config); final container = ProviderContainer( overrides: [...appStateOverrides, ...configOverrides], ); globalState.container = container; container .read(dynamicColorProvider.notifier) .seed( lightSeed: dynamicColor.lightSeed, darkSeed: dynamicColor.darkSeed, accentColor: dynamicColor.accentColor, ); final profiles = await database.profilesDao.query().get(); container.read(profilesProvider.notifier).reorder(profiles); await AppLocalizations.load( getLocaleForString(config.appSettingProps.locale) ?? WidgetsBinding.instance.platformDispatcher.locale, ); await window?.init(version, config.windowProps); if (system.isAndroid) { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } return container; } Future attach() async { if (globalState.isAttach == true) { return; } await _initApp(); globalState.isAttach = true; } ProviderContainer get _container => globalState.container; Future _initApp() async { unawaited(_container.read(systemActionProvider.notifier).updateTray()); unawaited( _container.read(profilesActionProvider.notifier).autoUpdateProfiles(), ); unawaited(_container.read(commonActionProvider.notifier).autoCheckUpdate()); unawaited( autoLaunch?.updateStatus(_container.read(appSettingProvider).autoLaunch), ); if (!_container.read(appSettingProvider).silentLaunch) { unawaited(window?.show()); } else { unawaited(window?.hide()); } await _handleFailedPreference(); if (!await _handlerDisclaimer()) { return; } await _showCrashRecoveryTip(); await _container.read(coreActionProvider.notifier).startCore(); if (!_bootDecision.isDegraded) { await _container.read(setupActionProvider.notifier).initStatus(); } _container.read(initProvider.notifier).value = true; unawaited(_container.read(resourcesActionProvider.notifier).autoUpdate()); await bootGuard.markRunning(); unawaited(_container.read(locationPermissionsProvider.notifier).refresh()); } Future _showCrashRecoveryTip() async { switch (_bootDecision.recovery) { case BootRecovery.none: return; case BootRecovery.skipAutoSetup: await dialogs.showMessage( title: currentAppLocalizations.launchInterrupted, cancelable: false, dismissible: false, message: TextSpan(text: currentAppLocalizations.launchInterruptedTip), ); case BootRecovery.clearProfile: await dialogs.showMessage( title: currentAppLocalizations.crashDetected, cancelable: false, dismissible: false, message: TextSpan( text: currentAppLocalizations.crashDetectedTip(_failedProfileLabel), ), ); } } String get _failedProfileLabel { final profileId = _bootDecision.failedProfileId; if (profileId == null) { return ''; } final profile = _container.read(profilesProvider).getProfile(profileId); return profile?.label.takeFirstValid(['$profileId']) ?? '$profileId'; } Future _handleFailedPreference() async { if (await preferences.isInit) return; final res = await dialogs.showMessage( title: currentAppLocalizations.tip, message: TextSpan(text: currentAppLocalizations.cacheCorrupt), ); if (res == true) { final file = File(await appPath.sharedPreferencesPath); await file.safeDelete(); } // Saving here would rewrite the preferences file the user just chose to delete. await _container.read(systemActionProvider.notifier).handleExit(false); } /// Android's exit only finishes the activity, so Dart keeps running after /// [SystemAction.handleExit] returns and must not go on to start the app. Future _handlerDisclaimer() async { final acceptedVersion = _container.read( appSettingProvider.select((state) => state.acceptedDisclaimerVersion), ); if (acceptedVersion >= disclaimerVersion) { return true; } final isDisclaimerAccepted = await requestDisclaimerConsent(); if (!isDisclaimerAccepted) { await _container.read(systemActionProvider.notifier).handleExit(); return false; } _container .read(appSettingProvider.notifier) .update( (state) => state.copyWith(acceptedDisclaimerVersion: disclaimerVersion), ); return true; } } final bootstrap = Bootstrap();