/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:background_fetch/background_fetch.dart';
import 'package:country_codes/country_codes.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoBrowserService, GeckoLoggingService, LogLevel;
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logger/logger.dart';
import 'package:material_color_utilities/material_color_utilities.dart';
import 'package:nullability/nullability.dart';
import 'package:privacypass_client/privacypass_client.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/error_observer.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/providers/app_state.dart';
import 'package:weblibre/core/providers/defaults.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/domain/services/app_initialization.dart';
import 'package:weblibre/features/account/domain/services/account_callback_handler.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/engine_settings_replication.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart';
import 'package:weblibre/features/proxy/domain/services/browser_dns_leak_guard.dart';
import 'package:weblibre/features/proxy/domain/services/singbox_proxy_endpoint_sync.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/web_feed/presentation/controllers/fetch_articles.dart';
import 'package:weblibre/features/web_feed/utils/fetch_entrypoint.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
import 'package:weblibre/presentation/hooks/on_initialization.dart';
import 'package:weblibre/presentation/main_app.dart';
ColorScheme _fixSurfaceContainerColors(
ColorScheme scheme,
TonalPalette neutralPalette,
Brightness brightness,
) {
if (brightness == Brightness.light) {
return scheme.copyWith(
surfaceContainerLowest: Color(neutralPalette.get(100)),
surfaceContainerLow: Color(neutralPalette.get(96)),
surfaceContainer: Color(neutralPalette.get(94)),
surfaceContainerHigh: Color(neutralPalette.get(92)),
surfaceContainerHighest: Color(neutralPalette.get(90)),
);
} else {
return scheme.copyWith(
surfaceContainerLowest: Color(neutralPalette.get(4)),
surfaceContainerLow: Color(neutralPalette.get(10)),
surfaceContainer: Color(neutralPalette.get(12)),
surfaceContainerHigh: Color(neutralPalette.get(17)),
surfaceContainerHighest: Color(neutralPalette.get(22)),
);
}
}
bool _hasBrokenSurfaceContainerColors(ColorScheme scheme) {
return scheme.surfaceContainerLowest == scheme.surface &&
scheme.surfaceContainerLow == scheme.surface &&
scheme.surfaceContainer == scheme.surface &&
scheme.surfaceContainerHigh == scheme.surface &&
scheme.surfaceContainerHighest == scheme.surface;
}
class _NoAnimationPageTransitionsBuilder extends PageTransitionsBuilder {
const _NoAnimationPageTransitionsBuilder();
@override
Widget buildTransitions(
PageRoute route,
BuildContext context,
Animation animation,
Animation secondaryAnimation,
Widget child,
) {
return child;
}
}
const _noAnimationPageTransitionsTheme = PageTransitionsTheme(
builders: {
TargetPlatform.android: _NoAnimationPageTransitionsBuilder(),
TargetPlatform.iOS: _NoAnimationPageTransitionsBuilder(),
TargetPlatform.linux: _NoAnimationPageTransitionsBuilder(),
TargetPlatform.macOS: _NoAnimationPageTransitionsBuilder(),
TargetPlatform.windows: _NoAnimationPageTransitionsBuilder(),
},
);
class _MainWidget extends HookConsumerWidget {
const _MainWidget();
@override
Widget build(BuildContext context, WidgetRef ref) {
// Keep the sandbox capture controller alive for the lifetime of the app
// so it can react to pigeon events even when no UI subscribes to it.
ref.watch(sandboxCaptureControllerProvider);
// Keep proxy/Tor log subscriptions active from app start so startup
// messages reach the ring buffer before the browser view (or logs
// screen) mounts and would otherwise drop them.
ref.watch(singboxProxyLogsProvider.select((_) => null));
final rootKey = ref.watch(appStateKeyProvider);
final pauseTime = useRef(null);
useOnAppLifecycleStateChange((previous, current) async {
switch (current) {
case AppLifecycleState.resumed:
if (pauseTime.value != null &&
DateTime.now().difference(pauseTime.value!) >
const Duration(minutes: 30)) {
final routerConfig = ref
.read(routerProvider)
.value
?.routerDelegate
.currentConfiguration;
//Rebuild widget tree after long time of inactivity
ref.read(appStateKeyProvider.notifier).reset();
//Wait for the new router to start
await Future.delayed(
const Duration(milliseconds: 250),
).whenComplete(() {
routerConfig.mapNotNull(
(routerConfig) =>
ref.read(routerProvider).value?.restore(routerConfig),
);
});
logger.i('UI reset');
}
pauseTime.value = null;
case AppLifecycleState.detached:
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
pauseTime.value ??= DateTime.now();
}
});
final themeMode = ref.watch(
generalSettingsWithDefaultsProvider.select((value) => value.themeMode),
);
final uiScaleFactor = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.uiScaleFactor,
),
);
final disableAnimations = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.disableAnimations,
),
);
final showModalBarrier = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.showModalBarrier,
),
);
useOnInitialization(() async {
await CountryCodes.init();
final engineSettings = await ref
.read(engineSettingsRepositoryProvider.notifier)
.fetchSettings();
final generalSettings = await ref
.read(generalSettingsRepositoryProvider.notifier)
.fetchSettings();
final startupUBlockFilterListsPref =
engineSettings.ublockFilterListSettings.enabled
? jsonEncode(
engineSettings.ublockFilterListSettings.resolveFinalList(),
)
: null;
final clearStartupUBlockFilterListsPref =
!engineSettings.ublockFilterListSettings.enabled;
try {
await GeckoBrowserService().initialize(
filesystem.relativeProfilePath,
kDebugMode ? LogLevel.debug : LogLevel.warn,
engineSettings.contentBlocking,
engineSettings.addonCollection,
generalSettings.syncServerOverride,
generalSettings.syncTokenServerOverride,
engineSettings,
startupUBlockFilterListsPref,
clearStartupUBlockFilterListsPref,
);
} on PlatformException catch (e, s) {
logger.e(
'Platform exception during Gecko initialization',
error: e,
stackTrace: s,
);
rethrow;
} catch (e, s) {
logger.e(
'Failed to initialize Gecko browser service',
error: e,
stackTrace: s,
);
rethrow;
}
// Mirror the startup uBO pref into the fixator so later pref changes are
// still observed and enforced after native startup initialization.
try {
await syncUBlockFilterLists(
ref.read(preferenceFixatorProvider.notifier),
engineSettings,
);
} catch (e, s) {
logger.w(
'Failed to sync uBlock filter list pref at startup',
error: e,
stackTrace: s,
);
}
await ref.read(appInitializationServiceProvider.notifier).initialize();
Future preloadUrlCleanerCatalog() async {
if (!generalSettings.urlCleanerEnabled) {
return;
}
try {
await ref.read(urlCleanerCatalogServiceProvider.future);
} catch (e, s) {
logger.w(
'Failed preloading URL cleaner catalog',
error: e,
stackTrace: s,
);
}
}
unawaited(preloadUrlCleanerCatalog());
// Wire settings → local_index_setting (tab.db) so the trigger gate
// is in sync from the moment tabs start writing.
ref.read(localIndexSettingsSyncProvider);
// Cold-start prune of the local search index — drops rows the engine
// has forgotten (Places retention, user-initiated clears). Cheap and
// background; failures are logged and ignored.
unawaited(ref.read(localIndexPrunerProvider.notifier).prune());
// Activate account callback deep link handler
ref.read(accountCallbackHandlerProvider);
// Activate the proxy DNS leak guard: when any sing-box profile is
// running AND the user has opted in, GeckoView TRR is forced to
// off so browser DNS follows the SOCKS proxyDNS path instead of
// resolving outside the proxy.
ref.read(browserDnsLeakGuardProvider);
// Mirror the sing-box runtime's SOCKS endpoints into Gecko's
// container-proxy registry. Side-effect-only notifier.
ref.read(singboxProxyEndpointSyncProvider);
if (!kDebugMode) {
await BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
enableHeadless: true,
stopOnTerminate: false,
requiredNetworkType: NetworkType.ANY,
startOnBoot: true,
),
(String taskId) async {
try {
await ref
.read(fetchArticlesControllerProvider.notifier)
.fetchAllArticles();
logger.i('Fetched articles in foreground');
} catch (e, s) {
logger.e('Failed fetching articles', error: e, stackTrace: s);
} finally {
await BackgroundFetch.finish(taskId);
}
},
);
}
});
final corePaletteSnapshot = useFuture(
useMemoized(() => DynamicColorPlugin.getCorePalette()),
);
return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) {
ColorScheme lightColorScheme;
ColorScheme darkColorScheme;
if (lightDynamic != null && darkDynamic != null) {
final corePalette = corePaletteSnapshot.data;
// On Android S+ devices, use the provided dynamic color scheme.
// (Recommended) Harmonize the dynamic color scheme' built-in semantic colors.
final harmonizedLight = lightDynamic.harmonized();
final harmonizedDark = darkDynamic.harmonized();
// Workaround for https://github.com/material-foundation/flutter-packages/issues/649
// dynamic_color package returns broken surfaceContainer* colors.
// Fix them using the neutral tonal palette from CorePalette.
if (corePalette != null) {
lightColorScheme = _hasBrokenSurfaceContainerColors(harmonizedLight)
? _fixSurfaceContainerColors(
harmonizedLight,
corePalette.neutral,
Brightness.light,
)
: harmonizedLight;
darkColorScheme = _hasBrokenSurfaceContainerColors(harmonizedDark)
? _fixSurfaceContainerColors(
harmonizedDark,
corePalette.neutral,
Brightness.dark,
)
: harmonizedDark;
} else {
lightColorScheme = harmonizedLight;
darkColorScheme = harmonizedDark;
}
} else {
// Otherwise, use fallback schemes.
lightColorScheme = ColorScheme.fromSeed(
seedColor: ref.read(lightSeedColorFallbackProvider),
);
darkColorScheme = ColorScheme.fromSeed(
seedColor: ref.read(darkSeedColorFallbackProvider),
brightness: Brightness.dark,
);
}
return MainApp(
key: rootKey,
theme: ThemeData(
useMaterial3: true,
colorScheme: lightColorScheme,
pageTransitionsTheme: disableAnimations
? _noAnimationPageTransitionsTheme
: null,
dialogTheme: DialogThemeData(
barrierColor: showModalBarrier ? null : Colors.transparent,
),
bottomSheetTheme: BottomSheetThemeData(
modalBarrierColor: showModalBarrier ? null : Colors.transparent,
),
extensions: const >[AppColors.light],
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkColorScheme,
pageTransitionsTheme: disableAnimations
? _noAnimationPageTransitionsTheme
: null,
dialogTheme: DialogThemeData(
barrierColor: showModalBarrier ? null : Colors.transparent,
),
bottomSheetTheme: BottomSheetThemeData(
modalBarrierColor: showModalBarrier ? null : Colors.transparent,
),
extensions: const >[AppColors.dark],
),
themeMode: themeMode,
uiScaleFactor: uiScaleFactor,
disableAnimations: disableAnimations,
);
},
);
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (e) {
logger.e(e.toString(), error: e.exception, stackTrace: e.stack);
};
PlatformDispatcher.instance.onError = (error, stack) {
logger.e('Unhandled Error', error: error, stackTrace: stack);
return true;
};
await filesystem.init();
await RustLib.init();
if (!kDebugMode) {
await BackgroundFetch.registerHeadlessTask(backgroundFetch);
}
if (kDebugMode) {
final serviceProtocolInfo = await Service.getInfo();
logger.d('VM: ${serviceProtocolInfo.serverUri}');
}
//Ensure everything is ready
await Future.delayed(Duration.zero);
GeckoLoggingService.setUp((level, message) {
logger.log(switch (level) {
LogLevel.debug => Level.debug,
LogLevel.info => Level.info,
LogLevel.warn => Level.warning,
LogLevel.error => Level.error,
}, message);
});
await HomeWidget.setAppGroupId('weblibre');
runApp(
const ProviderScope(observers: [ErrorObserver()], child: _MainWidget()),
);
}