diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart index 51c1e424..eaeb5a61 100644 --- a/apps/weblibre/lib/core/routing/routes.dart +++ b/apps/weblibre/lib/core/routing/routes.dart @@ -74,6 +74,7 @@ import 'package:weblibre/features/settings/presentation/screens/bang_settings.da import 'package:weblibre/features/settings/presentation/screens/browsing_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/contextual_toolbar_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/custom_tracking_protection.dart'; +import 'package:weblibre/features/settings/presentation/screens/desktop_mode_sites_screen.dart'; import 'package:weblibre/features/settings/presentation/screens/doh_settings.dart'; import 'package:weblibre/features/settings/presentation/screens/error_logs_screen.dart'; import 'package:weblibre/features/settings/presentation/screens/experimental_settings.dart'; diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart index 2a6db346..04a16a47 100644 --- a/apps/weblibre/lib/core/routing/routes.g.dart +++ b/apps/weblibre/lib/core/routing/routes.g.dart @@ -1674,6 +1674,11 @@ RouteBase get $settingsRoute => GoRouteData.$route( name: 'ContextualToolbarSettingsRoute', factory: $ContextualToolbarSettingsRoute._fromState, ), + GoRouteData.$route( + path: 'desktop_mode_sites', + name: 'DesktopModeSitesRoute', + factory: $DesktopModeSitesRoute._fromState, + ), GoRouteData.$route( path: 'singbox_proxy_profiles', name: 'SingboxProxyProfilesRoute', @@ -2280,6 +2285,27 @@ mixin $ContextualToolbarSettingsRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $DesktopModeSitesRoute on GoRouteData { + static DesktopModeSitesRoute _fromState(GoRouterState state) => + const DesktopModeSitesRoute(); + + @override + String get location => GoRouteData.$location('/settings/desktop_mode_sites'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + mixin $SingboxProxyProfilesRoute on GoRouteData { static SingboxProxyProfilesRoute _fromState(GoRouterState state) => const SingboxProxyProfilesRoute(); diff --git a/apps/weblibre/lib/core/routing/routes.settings.dart b/apps/weblibre/lib/core/routing/routes.settings.dart index 1d9f767f..b2e4d94d 100644 --- a/apps/weblibre/lib/core/routing/routes.settings.dart +++ b/apps/weblibre/lib/core/routing/routes.settings.dart @@ -117,6 +117,10 @@ part of 'routes.dart'; name: 'ContextualToolbarSettingsRoute', path: 'contextual_toolbar', ), + TypedGoRoute( + name: 'DesktopModeSitesRoute', + path: 'desktop_mode_sites', + ), TypedGoRoute( name: 'SingboxProxyProfilesRoute', path: 'singbox_proxy_profiles', @@ -350,6 +354,15 @@ class ContextualToolbarSettingsRoute extends GoRouteData } } +class DesktopModeSitesRoute extends GoRouteData with $DesktopModeSitesRoute { + const DesktopModeSitesRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return const DesktopModeSitesScreen(); + } +} + class SingboxProxyProfilesRoute extends GoRouteData with $SingboxProxyProfilesRoute { const SingboxProxyProfilesRoute(); diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.dart b/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.dart index 74f006f3..50e8bf3b 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.dart @@ -17,9 +17,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; + import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/utils/host_rules.dart'; part 'desktop_mode.g.dart'; @@ -34,6 +39,16 @@ class DesktopMode extends _$DesktopMode { state = !state; } + /// Resolves the desktop-mode state a tab on [host] should have, honouring the + /// per-site rule list first and falling back to the browser-wide default. + bool _resolveForHost(Uri? url) { + final settings = ref.read(generalSettingsWithDefaultsProvider); + if (url != null && hostMatchesRule(url, settings.desktopModeSites)) { + return true; + } + return settings.globalDesktopMode; + } + @override bool build(String tabId) { listenSelf((previous, next) async { @@ -44,11 +59,62 @@ class DesktopMode extends _$DesktopMode { } }); - // Seed the initial value from the browser-wide default so a newly opened - // tab's menu checkbox matches the desktop mode it was actually created with - // natively (GeckoTabsApi seeds new tabs from BrowserState.desktopMode). - // Read (not watch) so toggling the global default never clobbers an - // existing tab's per-tab override. - return ref.read(generalSettingsWithDefaultsProvider).globalDesktopMode; + // Re-apply the per-site rule whenever the tab's host changes. A manual + // toggle from the menu therefore lasts only for the current host-visit: + // landing on a ruled host forces desktop on again, and leaving it reverts + // to the browser-wide default. Watching only the host keeps in-page + // navigations (path/query changes) from clobbering a manual override. + ref.listen( + tabStateProvider(tabId), + (previous, next) { + if (previous?.url.host != next?.url.host) { + state = _resolveForHost(next?.url); + } + }, + ); + + // Seed the initial value from the per-site rule (falling back to the + // browser-wide default) so a newly opened tab's menu checkbox matches the + // desktop mode it was actually created with natively (GeckoTabsApi seeds + // new tabs from BrowserState.desktopMode). Read (not watch) so toggling the + // global default never clobbers an existing tab's per-tab override. + final resolved = _resolveForHost(ref.read(tabStateProvider(tabId))?.url); + + // The engine seeds a tab's desktop mode from the browser-wide default at + // creation, and a global-default change re-applies to every tab, so at the + // moment this notifier (re)builds the engine holds [globalDesktopMode]. When + // a per-site rule resolves to a different value, push it now: the listenSelf + // guard above skips the initial seed, so without this the rule would never + // reach Gecko unless the host later changed while this notifier was alive + // (which only happens when a menu/sheet kept it mounted across a navigation). + final globalDesktopMode = ref + .read(generalSettingsWithDefaultsProvider) + .globalDesktopMode; + if (resolved != globalDesktopMode) { + unawaited( + Future.microtask(() async { + if (!ref.mounted) return; + await ref + .read(tabSessionProvider(tabId: tabId).notifier) + .requestDesktopSite(resolved); + }), + ); + } + + return resolved; + } +} + +/// Always-mounted helper that instantiates the selected tab's [DesktopMode] +/// notifier so the per-site desktop-mode rule is applied on navigation even when +/// no tab menu or site sheet is open. Mounted by the browser view. [DesktopMode] +/// is keepAlive, so once built for a tab its navigation listener keeps running +/// for that tab's lifetime; this just guarantees it gets built when a tab +/// becomes selected (e.g. after opening a ruled site from the address bar). +@Riverpod(keepAlive: true) +void desktopModeRuleApplier(Ref ref) { + final selectedTabId = ref.watch(selectedTabProvider); + if (selectedTabId != null) { + ref.watch(desktopModeProvider(selectedTabId)); } } diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.g.dart index 1634b6c1..ed86e350 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/desktop_mode.g.dart @@ -57,7 +57,7 @@ final class DesktopModeProvider extends $NotifierProvider { } } -String _$desktopModeHash() => r'727c8a884de5c21499f4b3ae6835af1115f649f2'; +String _$desktopModeHash() => r'e755b0a36f7079006047896cb8f2a7be484dea90'; final class DesktopModeFamily extends $Family with $ClassFamilyOverride { @@ -97,3 +97,65 @@ abstract class _$DesktopMode extends $Notifier { element.handleCreate(ref, () => build(_$args)); } } + +/// Always-mounted helper that instantiates the selected tab's [DesktopMode] +/// notifier so the per-site desktop-mode rule is applied on navigation even when +/// no tab menu or site sheet is open. Mounted by the browser view. [DesktopMode] +/// is keepAlive, so once built for a tab its navigation listener keeps running +/// for that tab's lifetime; this just guarantees it gets built when a tab +/// becomes selected (e.g. after opening a ruled site from the address bar). + +@ProviderFor(desktopModeRuleApplier) +final desktopModeRuleApplierProvider = DesktopModeRuleApplierProvider._(); + +/// Always-mounted helper that instantiates the selected tab's [DesktopMode] +/// notifier so the per-site desktop-mode rule is applied on navigation even when +/// no tab menu or site sheet is open. Mounted by the browser view. [DesktopMode] +/// is keepAlive, so once built for a tab its navigation listener keeps running +/// for that tab's lifetime; this just guarantees it gets built when a tab +/// becomes selected (e.g. after opening a ruled site from the address bar). + +final class DesktopModeRuleApplierProvider + extends $FunctionalProvider + with $Provider { + /// Always-mounted helper that instantiates the selected tab's [DesktopMode] + /// notifier so the per-site desktop-mode rule is applied on navigation even when + /// no tab menu or site sheet is open. Mounted by the browser view. [DesktopMode] + /// is keepAlive, so once built for a tab its navigation listener keeps running + /// for that tab's lifetime; this just guarantees it gets built when a tab + /// becomes selected (e.g. after opening a ruled site from the address bar). + DesktopModeRuleApplierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'desktopModeRuleApplierProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$desktopModeRuleApplierHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + void create(Ref ref) { + return desktopModeRuleApplier(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$desktopModeRuleApplierHash() => + r'4a99102da5dc11869bfb96d439cc88652a190b39'; diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart index 1cb047e0..074d0155 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart @@ -44,7 +44,7 @@ final class EngineSettingsReplicationServiceProvider } String _$engineSettingsReplicationServiceHash() => - r'36f43a382419203ec53cac71fe51b1d30df2a0c6'; + r'00439944023e8336dc879c70bb578120e221676d'; abstract class _$EngineSettingsReplicationService extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 68ea7291..40ab84a1 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -36,6 +36,7 @@ import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.d import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart'; +import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; @@ -613,6 +614,19 @@ class _BrowserViewState extends ConsumerState }, ); + ref.listenManual( + fireImmediately: true, + desktopModeRuleApplierProvider, + (previous, next) {}, + onError: (error, stackTrace) { + logger.e( + 'Error listening to desktopModeRuleApplierProvider', + error: error, + stackTrace: stackTrace, + ); + }, + ); + ref.listenManual( fireImmediately: true, gestureControlServiceProvider, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart new file mode 100644 index 00000000..69b5f76b --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart @@ -0,0 +1,113 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/utils/host_rules.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +/// Section widget toggling whether the current site should always load in +/// desktop mode. Adds/removes the current host from the persisted rule list and +/// immediately reflects the change on the current tab. +class DesktopModeSection extends HookConsumerWidget { + final String tabId; + final Uri url; + + const DesktopModeSection({required this.tabId, required this.url, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final desktopModeSites = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.desktopModeSites), + ); + + final host = normalizeRuleHost(url.toString()); + final isRuled = hostMatchesRule(url, desktopModeSites); + // A broader entry (e.g. `example.com` while on `m.example.com`) governs this + // page; a per-site toggle for the exact host can't override it. + final parentRule = coveringParentRule(url, desktopModeSites); + + return SwitchListTile.adaptive( + value: isRuled, + // Disabled when no valid host, or when a broader rule governs the page + // (which the exact-host toggle could not override). + onChanged: (host != null && parentRule == null) + ? (enabled) => _toggleRule(context, ref, host, enabled) + : null, + title: const Text('Always use desktop site'), + subtitle: Text( + host == null + ? 'Unavailable on this page' + : parentRule != null + ? 'Set by a rule for $parentRule' + : isRuled + ? 'This site always loads in desktop mode' + : 'This site follows the default mode', + ), + secondary: Icon( + MdiIcons.monitor, + color: isRuled + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + Future _toggleRule( + BuildContext context, + WidgetRef ref, + String host, + bool enabled, + ) async { + try { + await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings( + (current) { + final next = current.desktopModeSites.toList(); + if (enabled) { + if (!next.contains(host)) next.add(host); + } else { + next.remove(host); + } + return current.copyWith.desktopModeSites(next); + }, + ); + + // Adding/removing a rule does not change the tab's host, so the + // host-change listener in DesktopMode won't fire. Apply the resolved + // state to the current tab directly: a new rule forces desktop on, while + // removing it reverts to the browser-wide default. + final globalDesktopMode = ref + .read(generalSettingsWithDefaultsProvider) + .globalDesktopMode; + ref + .read(desktopModeProvider(tabId).notifier) + .enabled(enabled || globalDesktopMode); + } catch (e, s) { + logger.e('Failed to toggle desktop mode rule', error: e, stackTrace: s); + if (context.mounted) { + showErrorMessage(context, 'Failed to toggle desktop mode: $e'); + } + } + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart new file mode 100644 index 00000000..2c5328a1 --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart @@ -0,0 +1,113 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; +import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; +import 'package:weblibre/utils/host_rules.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +/// Section widget toggling whether touch gestures are enabled on the current +/// site. Mirrors the excluded-sites list managed in settings, but scoped to the +/// page currently shown in the sheet. +class GestureExclusionSection extends HookConsumerWidget { + final Uri url; + + const GestureExclusionSection({required this.url, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final masterEnabled = ref.watch( + gestureSettingsWithDefaultsProvider.select((s) => s.enabled), + ); + final excludedSites = ref.watch( + gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites), + ); + + final host = normalizeRuleHost(url.toString()); + final isExcluded = hostMatchesRule(url, excludedSites); + // A broader entry (e.g. `example.com` while on `m.example.com`) governs this + // page; a per-site toggle for the exact host can't override it. + final parentRule = coveringParentRule(url, excludedSites); + // Enabled on this site when gestures are globally on, the host is valid, + // and the host is not in the exclusion list. + final isEnabledHere = masterEnabled && host != null && !isExcluded; + + final String subtitle; + if (!masterEnabled) { + subtitle = 'Gestures are turned off globally'; + } else if (host == null) { + subtitle = 'Gestures are unavailable on this page'; + } else if (parentRule != null) { + subtitle = 'Disabled by a rule for $parentRule'; + } else if (isExcluded) { + subtitle = 'Gestures are disabled on this site'; + } else { + subtitle = 'Gestures are enabled on this site'; + } + + return SwitchListTile.adaptive( + value: isEnabledHere, + // Only actionable when gestures are globally enabled, we have a valid host + // to add to / remove from the exclusion list, and no broader rule governs + // the page (which the exact-host toggle could not override). + onChanged: (masterEnabled && host != null && parentRule == null) + ? (enabled) => _toggleExclusion(context, ref, host, enabled) + : null, + title: const Text('Gestures'), + subtitle: Text(subtitle), + secondary: Icon( + isEnabledHere ? Icons.gesture : Icons.do_not_touch_outlined, + color: isEnabledHere + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + Future _toggleExclusion( + BuildContext context, + WidgetRef ref, + String host, + bool enabled, + ) async { + try { + await ref.read(gestureSettingsRepositoryProvider.notifier).updateSettings( + (current) { + final next = current.excludedSites.toList(); + if (enabled) { + // Enabling gestures here => remove the host from the exclusion list. + next.remove(host); + } else if (!next.contains(host)) { + // Disabling gestures here => add the host to the exclusion list. + next.add(host); + } + return current.copyWith.excludedSites(next); + }, + ); + } catch (e, s) { + logger.e('Failed to toggle gesture exclusion', error: e, stackTrace: s); + if (context.mounted) { + showErrorMessage(context, 'Failed to toggle gestures: $e'); + } + } + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart index f41cf16d..46615aea 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart @@ -27,6 +27,8 @@ import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/permissions_section.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; @@ -153,6 +155,15 @@ class ViewTabSheetWidget extends HookConsumerWidget { // Tracking Protection Section TrackingProtectionSection(tabId: initialTabState.id), const Divider(), + // Gesture Exclusion Section + GestureExclusionSection(url: initialTabState.url), + const Divider(), + // Desktop Mode Section + DesktopModeSection( + tabId: initialTabState.id, + url: initialTabState.url, + ), + const Divider(), // Permissions Section PermissionsSection( origin: initialTabState.url.origin, diff --git a/apps/weblibre/lib/features/gestures/data/models/gesture_settings.dart b/apps/weblibre/lib/features/gestures/data/models/gesture_settings.dart index cd6991f2..57bce7a1 100644 --- a/apps/weblibre/lib/features/gestures/data/models/gesture_settings.dart +++ b/apps/weblibre/lib/features/gestures/data/models/gesture_settings.dart @@ -21,7 +21,6 @@ import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:fast_equatable/fast_equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:weblibre/features/gestures/data/models/gesture_action.dart'; -import 'package:weblibre/utils/uri_input_parser.dart'; part 'gesture_settings.g.dart'; @@ -103,7 +102,7 @@ class GestureSettings with FastEquatable { final int minSuggestionStroke; /// Hosts on which gestures are disabled. A page is excluded when its host - /// equals or is a subdomain of any entry (see `isGestureSiteExcluded`). + /// equals or is a subdomain of any entry (see `hostMatchesRule`). final List excludedSites; /// Canonical gesture key → action. Keys follow the grammar documented on @@ -177,34 +176,3 @@ class GestureSettings with FastEquatable { bindings, ]; } - -/// Normalises a user-entered site into a bare lowercase host, e.g. -/// `https://News.example.com/foo` → `news.example.com`. Accepts either a full -/// URL or a bare host, and validates the result with the same rules the address -/// bar uses ([isValidHostCandidate]). Returns null for input without a valid -/// host. -String? normalizeGestureSiteHost(String input) { - final trimmed = input.trim().toLowerCase(); - if (trimmed.isEmpty) return null; - - final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; - final host = Uri.tryParse(candidate)?.host; - if (host == null || host.isEmpty) return null; - - return isValidHostCandidate(host) ? host : null; -} - -/// Whether [url] is covered by any entry in [excludedSites]. An entry matches -/// the URL's host exactly or as a parent domain (so `example.com` also covers -/// `m.example.com`). -bool isGestureSiteExcluded(Uri url, List excludedSites) { - final host = url.host.toLowerCase(); - if (host.isEmpty) return false; - - for (final entry in excludedSites) { - final pattern = entry.toLowerCase(); - if (pattern.isEmpty) continue; - if (host == pattern || host.endsWith('.$pattern')) return true; - } - return false; -} diff --git a/apps/weblibre/lib/features/gestures/domain/services/gesture_control.dart b/apps/weblibre/lib/features/gestures/domain/services/gesture_control.dart index aa7a28c9..77b0036e 100644 --- a/apps/weblibre/lib/features/gestures/domain/services/gesture_control.dart +++ b/apps/weblibre/lib/features/gestures/domain/services/gesture_control.dart @@ -52,6 +52,7 @@ 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_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/utils/exit_app.dart'; +import 'package:weblibre/utils/host_rules.dart'; import 'package:weblibre/utils/move_to_background.dart'; part 'gesture_control.g.dart'; @@ -311,7 +312,7 @@ bool gestureSiteExcluded(Ref ref) { final url = ref.watch(tabStateProvider(tabId).select((state) => state?.url)); if (url == null) return false; - return isGestureSiteExcluded(url, excludedSites); + return hostMatchesRule(url, excludedSites); } /// The effective native recognizer configuration: the user's settings with the diff --git a/apps/weblibre/lib/features/gestures/domain/services/gesture_control.g.dart b/apps/weblibre/lib/features/gestures/domain/services/gesture_control.g.dart index 10f2f3b4..9fc28a17 100644 --- a/apps/weblibre/lib/features/gestures/domain/services/gesture_control.g.dart +++ b/apps/weblibre/lib/features/gestures/domain/services/gesture_control.g.dart @@ -143,7 +143,7 @@ final class GestureSiteExcludedProvider } String _$gestureSiteExcludedHash() => - r'15dd371a176303a7c52c37df560c93bbb03c5039'; + r'c7c9f8128a7262b858c954b7217d6a00f1d062e4'; /// The effective native recognizer configuration: the user's settings with the /// recognizer disabled while the current site is excluded. diff --git a/apps/weblibre/lib/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart index dccbcb70..ef9e98fb 100644 --- a/apps/weblibre/lib/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart +++ b/apps/weblibre/lib/features/gestures/presentation/screens/gesture_excluded_sites_screen.dart @@ -21,8 +21,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; -import 'package:weblibre/features/gestures/presentation/widgets/string_list_editor.dart'; -import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; +import 'package:weblibre/features/settings/presentation/widgets/string_list_settings_screen.dart'; +import 'package:weblibre/utils/host_rules.dart'; /// Manages the list of sites on which gestures are disabled. class GestureExcludedSitesScreen extends HookConsumerWidget { @@ -34,38 +34,21 @@ class GestureExcludedSitesScreen extends HookConsumerWidget { gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites), ); - return SettingsCustomScrollScaffold( + return StringListSettingsScreen( title: 'Excluded sites', - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Text( - 'Gestures are disabled on these sites. Subdomains are included ' - '(e.g. "example.com" also covers "m.example.com").', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ), - SliverToBoxAdapter( - child: StringListEditor( - values: excludedSites, - hintText: 'example.com', - itemIcon: Icons.public_off, - emptyLabel: 'No sites excluded.', - normalize: normalizeGestureSiteHost, - onChanged: (next) async { - await ref - .read(gestureSettingsRepositoryProvider.notifier) - .updateSettings( - (current) => current.copyWith.excludedSites(next), - ); - }, - ), - ), - ], + description: + 'Gestures are disabled on these sites. Subdomains are included ' + '(e.g. "example.com" also covers "m.example.com").', + values: excludedSites, + hintText: 'example.com', + itemIcon: Icons.public_off, + emptyLabel: 'No sites excluded.', + normalize: normalizeRuleHost, + onChanged: (next) async { + await ref + .read(gestureSettingsRepositoryProvider.notifier) + .updateSettings((current) => current.copyWith.excludedSites(next)); + }, ); } } diff --git a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart index 70b33bdd..7484516c 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart @@ -105,12 +105,23 @@ const List browsingSettingsSections = [ keywords: ['app links', 'external apps'], child: _AppLinksModeSection(), ), + ], + ), + SettingsSectionDefinition( + title: 'Desktop Mode', + entries: [ SettingsEntryDefinition( title: 'Always Request Desktop Site', subtitle: 'Open new tabs in desktop mode by default', keywords: ['desktop mode', 'user agent', 'mobile site', 'tablet'], child: _GlobalDesktopModeTile(), ), + SettingsEntryDefinition( + title: 'Desktop Mode Sites', + subtitle: 'Sites that always load in desktop mode', + keywords: ['desktop mode', 'per-site', 'user agent', 'exceptions'], + child: _DesktopModeSitesTile(), + ), ], ), SettingsSectionDefinition( @@ -764,6 +775,23 @@ class _GlobalDesktopModeTile extends HookConsumerWidget { } } +class _DesktopModeSitesTile extends StatelessWidget { + const _DesktopModeSitesTile(); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.desktop_windows), + title: const Text('Desktop Mode Sites'), + subtitle: const Text('Sites that always load in desktop mode'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await const DesktopModeSitesRoute().push(context); + }, + ); + } +} + class _PullToRefreshTile extends HookConsumerWidget { const _PullToRefreshTile(); diff --git a/apps/weblibre/lib/features/settings/presentation/screens/desktop_mode_sites_screen.dart b/apps/weblibre/lib/features/settings/presentation/screens/desktop_mode_sites_screen.dart new file mode 100644 index 00000000..a525e212 --- /dev/null +++ b/apps/weblibre/lib/features/settings/presentation/screens/desktop_mode_sites_screen.dart @@ -0,0 +1,56 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/string_list_settings_screen.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/utils/host_rules.dart'; + +/// Manages the list of sites that always load in desktop mode. +class DesktopModeSitesScreen extends HookConsumerWidget { + const DesktopModeSitesScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final desktopModeSites = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.desktopModeSites), + ); + + return StringListSettingsScreen( + title: 'Desktop mode sites', + description: + 'These sites always load in desktop mode, overriding the default. ' + 'Subdomains are included (e.g. "example.com" also covers ' + '"m.example.com").', + values: desktopModeSites, + hintText: 'example.com', + itemIcon: Icons.desktop_windows, + emptyLabel: 'No sites added.', + normalize: normalizeRuleHost, + onChanged: (next) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save((current) => current.copyWith.desktopModeSites(next)); + }, + ); + } +} diff --git a/apps/weblibre/lib/features/gestures/presentation/widgets/string_list_editor.dart b/apps/weblibre/lib/features/settings/presentation/widgets/string_list_editor.dart similarity index 100% rename from apps/weblibre/lib/features/gestures/presentation/widgets/string_list_editor.dart rename to apps/weblibre/lib/features/settings/presentation/widgets/string_list_editor.dart diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/string_list_settings_screen.dart b/apps/weblibre/lib/features/settings/presentation/widgets/string_list_settings_screen.dart new file mode 100644 index 00000000..99a7c951 --- /dev/null +++ b/apps/weblibre/lib/features/settings/presentation/widgets/string_list_settings_screen.dart @@ -0,0 +1,92 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; +import 'package:weblibre/features/settings/presentation/widgets/string_list_editor.dart'; + +/// Settings sub-screen that manages a list of unique string entries: an +/// optional description followed by a [StringListEditor]. Callers own the +/// state — pass [values] and handle persistence in [onChanged] — keeping this +/// screen provider-agnostic and reusable across features (e.g. gesture-excluded +/// sites, per-site desktop mode). +class StringListSettingsScreen extends StatelessWidget { + final String title; + + /// Optional explanatory text shown above the editor. + final String? description; + + final List values; + final ValueChanged> onChanged; + + /// Hint shown in the add field. + final String hintText; + + /// Canonicalises raw input before adding. Returns null to reject the value. + final String? Function(String input) normalize; + + /// Leading icon for each entry row. + final IconData itemIcon; + + /// Message shown when the list is empty. + final String emptyLabel; + + const StringListSettingsScreen({ + required this.title, + required this.values, + required this.onChanged, + required this.hintText, + required this.normalize, + this.description, + this.itemIcon = Icons.link, + this.emptyLabel = 'Nothing added yet.', + super.key, + }); + + @override + Widget build(BuildContext context) { + return SettingsCustomScrollScaffold( + title: title, + slivers: [ + if (description != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + description!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + SliverToBoxAdapter( + child: StringListEditor( + values: values, + hintText: hintText, + itemIcon: itemIcon, + emptyLabel: emptyLabel, + normalize: normalize, + onChanged: onChanged, + ), + ), + ], + ); + } +} diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index da12248f..6f364ddc 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -159,6 +159,11 @@ class GeneralSettings with FastEquatable { /// still overrides this for an individual tab. Defaults to false. final bool globalDesktopMode; + /// Hosts that should always load in desktop mode. A tab navigating to a + /// matching host (or any of its subdomains) is switched to desktop mode, + /// overriding [globalDesktopMode] for that visit. See `hostMatchesRule`. + final List desktopModeSites; + GeneralSettings({ required this.themeMode, required this.uiScaleFactor, @@ -220,6 +225,7 @@ class GeneralSettings with FastEquatable { required this.acceptSuggestionOnSubmit, required this.pureBlack, required this.globalDesktopMode, + required this.desktopModeSites, }); GeneralSettings.withDefaults({ @@ -283,6 +289,7 @@ class GeneralSettings with FastEquatable { bool? acceptSuggestionOnSubmit, bool? pureBlack, bool? globalDesktopMode, + List? desktopModeSites, }) : themeMode = themeMode ?? ThemeMode.dark, uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor, disableAnimations = disableAnimations ?? false, @@ -354,7 +361,8 @@ class GeneralSettings with FastEquatable { indexPrivateTabs = indexPrivateTabs ?? false, acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? false, pureBlack = pureBlack ?? false, - globalDesktopMode = globalDesktopMode ?? false; + globalDesktopMode = globalDesktopMode ?? false, + desktopModeSites = desktopModeSites ?? const []; factory GeneralSettings.fromJson(Map json) { // Migrate legacy `newTabPosition` setting to direction settings. @@ -468,5 +476,6 @@ class GeneralSettings with FastEquatable { acceptSuggestionOnSubmit, pureBlack, globalDesktopMode, + desktopModeSites, ]; } diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index e5202bac..9f2229b5 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -149,6 +149,8 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings globalDesktopMode(bool globalDesktopMode); + GeneralSettings desktopModeSites(List desktopModeSites); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. /// @@ -217,6 +219,7 @@ abstract class _$GeneralSettingsCWProxy { bool acceptSuggestionOnSubmit, bool pureBlack, bool globalDesktopMode, + List desktopModeSites, }); } @@ -481,6 +484,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings globalDesktopMode(bool globalDesktopMode) => call(globalDesktopMode: globalDesktopMode); + @override + GeneralSettings desktopModeSites(List desktopModeSites) => + call(desktopModeSites: desktopModeSites); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. @@ -551,6 +558,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(), Object? pureBlack = const $CopyWithPlaceholder(), Object? globalDesktopMode = const $CopyWithPlaceholder(), + Object? desktopModeSites = const $CopyWithPlaceholder(), }) { return GeneralSettings( themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null @@ -905,6 +913,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.globalDesktopMode // ignore: cast_nullable_to_non_nullable : globalDesktopMode as bool, + desktopModeSites: + desktopModeSites == const $CopyWithPlaceholder() || + desktopModeSites == null + ? _value.desktopModeSites + // ignore: cast_nullable_to_non_nullable + : desktopModeSites as List, ); } } @@ -1034,6 +1048,9 @@ GeneralSettings _$GeneralSettingsFromJson( acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?, pureBlack: json['pureBlack'] as bool?, globalDesktopMode: json['globalDesktopMode'] as bool?, + desktopModeSites: (json['desktopModeSites'] as List?) + ?.map((e) => e as String) + .toList(), ); Map _$GeneralSettingsToJson( @@ -1112,6 +1129,7 @@ Map _$GeneralSettingsToJson( 'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit, 'pureBlack': instance.pureBlack, 'globalDesktopMode': instance.globalDesktopMode, + 'desktopModeSites': instance.desktopModeSites, }; const _$ThemeModeEnumMap = { diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index 0e743097..ab278bae 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -277,6 +277,9 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.bool, db.typeMapping, ), + 'desktopModeSites': settings['desktopModeSites'] + ?.readAs(DriftSqlType.string, db.typeMapping) + .mapNotNull(jsonDecode), }); } diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index 5dbcdb27..007e0758 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'6cf0832a8497c94a813e06556c048a1010be74e1'; + r'0f3991899956a779f18615fa7c8c172d9086af1c'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/apps/weblibre/lib/utils/host_rules.dart b/apps/weblibre/lib/utils/host_rules.dart new file mode 100644 index 00000000..fa6fae52 --- /dev/null +++ b/apps/weblibre/lib/utils/host_rules.dart @@ -0,0 +1,77 @@ +/* + * 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 'package:weblibre/utils/uri_input_parser.dart'; + +/// Helpers for per-site rule lists keyed by host (e.g. gesture exclusions, +/// per-site desktop mode). Entries are stored as bare lowercase hosts and match +/// a page's host exactly or as a parent domain. +/// +/// Note: this is intentionally distinct from `uri_parser.dart`'s `normalizeHost` +/// /`hostVariants`, which strip generic `www`/`m`/`mobile` prefixes for fuzzy +/// bang-template matching — a different semantic that must not be conflated. + +/// Normalises a user-entered site into a bare lowercase host, e.g. +/// `https://News.example.com/foo` → `news.example.com`. Accepts either a full +/// URL or a bare host, and validates the result with the same rules the address +/// bar uses ([isValidHostCandidate]). Returns null for input without a valid +/// host. +String? normalizeRuleHost(String input) { + final trimmed = input.trim().toLowerCase(); + if (trimmed.isEmpty) return null; + + final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; + final host = Uri.tryParse(candidate)?.host; + if (host == null || host.isEmpty) return null; + + return isValidHostCandidate(host) ? host : null; +} + +/// Whether [url] is covered by any entry in [patterns]. An entry matches the +/// URL's host exactly or as a parent domain (so `example.com` also covers +/// `m.example.com`). +bool hostMatchesRule(Uri url, List patterns) { + final host = url.host.toLowerCase(); + if (host.isEmpty) return false; + + for (final entry in patterns) { + final pattern = entry.toLowerCase(); + if (pattern.isEmpty) continue; + if (host == pattern || host.endsWith('.$pattern')) return true; + } + return false; +} + +/// Returns an entry in [patterns] that covers [url]'s host as a *parent domain* +/// (a strict suffix, not an exact match), or null if none does. Used to detect +/// when a page is governed by a broader rule that a per-site toggle for the +/// exact host cannot override — e.g. `example.com` covering `m.example.com`. +/// (If both the exact host and a parent are listed, the parent is still +/// returned, since removing the exact entry would not clear the page.) +String? coveringParentRule(Uri url, List patterns) { + final host = url.host.toLowerCase(); + if (host.isEmpty) return null; + + for (final entry in patterns) { + final pattern = entry.toLowerCase(); + if (pattern.isEmpty || pattern == host) continue; + if (host.endsWith('.$pattern')) return pattern; + } + return null; +}