diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/apps/AppData.kt b/android/app/src/main/kotlin/org/getlantern/lantern/apps/AppData.kt index 30d1560eb8..cb669682ef 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/apps/AppData.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/apps/AppData.kt @@ -7,9 +7,10 @@ internal data class AppData( val label: String, val lastUpdateTime: Long, val appPath: String, - val iconPath: String = "" + val iconPath: String = "", + val isBrowser: Boolean = false ) { - fun hasCachedIcon(ctx: Context, sizePx: Int, dpi: Int): Boolean = + fun hasCachedIcon(ctx: Context, sizePx: Int, dpi: Int): Boolean = IconCache.pathFor(ctx, packageName, lastUpdateTime, sizePx, dpi).exists() fun withIconPathIfCached(ctx: Context, sizePx: Int, dpi: Int): AppData { @@ -23,6 +24,7 @@ internal data class AppData( "label" to label, "name" to label, "appPath" to appPath, - "iconPath" to iconPath + "iconPath" to iconPath, + "isBrowser" to isBrowser ) } \ No newline at end of file diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/apps/AppDataHandler.kt b/android/app/src/main/kotlin/org/getlantern/lantern/apps/AppDataHandler.kt index cc2f5b7513..c35aed897d 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/apps/AppDataHandler.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/apps/AppDataHandler.kt @@ -5,6 +5,7 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.* +import android.net.Uri import android.os.Build import androidx.core.content.ContextCompat import io.flutter.plugin.common.EventChannel @@ -110,13 +111,14 @@ internal class AppDataHandler( Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), PackageManager.MATCH_ALL ) + val browsers = browserPackages(pm) val entries = launchables.mapNotNull { ri -> val pkg = ri.activityInfo?.packageName ?: return@mapNotNull null if (AppFilters.shouldSkip(pkg, lanternPkg)) return@mapNotNull null val label = runCatching { ri.loadLabel(pm).toString() }.getOrDefault(pkg) val lastUpdate = runCatching { pm.getPackageInfoCompat(pkg).lastUpdateTime }.getOrDefault(0L) - AppData(pkg, label, lastUpdate, appPath = "") + AppData(pkg, label, lastUpdate, appPath = "", isBrowser = pkg in browsers) } .distinctBy { it.packageName } .sortedBy { it.label.lowercase(Locale.getDefault()) } @@ -227,9 +229,26 @@ internal class AppDataHandler( pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrDefault(pkg) val lastUpdate = runCatching { pm.getPackageInfoCompat(pkg).lastUpdateTime }.getOrDefault(0L) - return AppData(pkg, label, lastUpdate, appPath = "") + return AppData(pkg, label, lastUpdate, appPath = "", isBrowser = pkg in browserPackages(pm)) } + /** + * Packages that register themselves as http(s) handlers — i.e. browsers. + * Dynamic per device, so any installed browser is detected, not just a + * hardcoded list of the common ones. + */ + private fun browserPackages(pm: PackageManager): Set = runCatching { + // Query both schemes: an app registering only https would otherwise + // slip through with isBrowser = false. + listOf("http", "https").flatMap { scheme -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$scheme://example.com")) + .addCategory(Intent.CATEGORY_BROWSABLE) + pm.queryIntentActivities(intent, PackageManager.MATCH_ALL) + } + .mapNotNull { it.activityInfo?.packageName } + .toSet() + }.getOrDefault(emptySet()) + private fun isSystemApp(pm: PackageManager, pkg: String): Boolean = runCatching { val ai = pm.getApplicationInfo(pkg, 0) (ai.flags and ApplicationInfo.FLAG_SYSTEM) != 0 || diff --git a/assets/locales/en.po b/assets/locales/en.po index 63bd253531..9a98094049 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -1727,3 +1727,37 @@ msgstr "Order Total" msgid "smart_routing_mode_description" msgstr "Smart Location picks the fastest server and switches automatically as it finds better routes." + + +msgid "bypass_browser_warning_title" +msgstr "Add %s to the bypass list?" + +msgid "bypass_browser_warning_body" +msgstr "%s will connect directly, without Lantern's protection. In countries that block certain websites, some sites will stop loading." + +msgid "add_anyway" +msgstr "Add Anyway" + +msgid "bypass_all_warning_title" +msgstr "Add all apps to the bypass list?" + +msgid "bypass_all_warning_body" +msgstr "This includes browsers like %s. Apps on the bypass list connect directly, without Lantern's protection. In countries that block certain websites, some sites will stop loading." + +msgid "add_all_except_browsers" +msgstr "Add All Except Browsers" + +msgid "add_all_anyway" +msgstr "Add All Anyway" + +msgid "bypass_app_first_time_title" +msgstr "Bypass the VPN for this app?" + +msgid "bypass_app_first_time_body" +msgstr "%s will connect directly, without Lantern's protection. If it's blocked in your country, it may stop working." + +msgid "bypass_website_first_time_title" +msgstr "Bypass the VPN for this website?" + +msgid "bypass_website_first_time_body" +msgstr "%s will connect directly, without Lantern's protection. If it's blocked in your country, it may stop working." diff --git a/lantern-core/apps/apps.go b/lantern-core/apps/apps.go index e24e2e6617..15263fbe5a 100644 --- a/lantern-core/apps/apps.go +++ b/lantern-core/apps/apps.go @@ -234,6 +234,8 @@ func LoadInstalledAppsWithDirs(dataDir string, appDirs []string, excludeDirs []s var deferredWindowsCache []*AppData if cached, err := loadCacheFromFile(dataDir); err == nil { + // Recompute rather than trust flags persisted by an older cache. + markBrowsers(cached) for _, app := range cached { if app == nil { continue @@ -273,6 +275,11 @@ func LoadInstalledAppsWithDirs(dataDir string, appDirs []string, excludeDirs []s } found := loadInstalledAppsPlatform(appDirs, seen, excludeDirs, cb) + // The callback has already streamed these pointers to the caller + // (LanternCore.LoadInstalledApps collects them and marshals after we + // return), so mutating them here is visible in the final JSON and in + // the cache saved below. + markBrowsers(found) if runtime.GOOS == "windows" { for _, app := range deferredWindowsCache { if app == nil { diff --git a/lantern-core/apps/apps_data.go b/lantern-core/apps/apps_data.go index 21ad2a8a1c..5d44075d77 100644 --- a/lantern-core/apps/apps_data.go +++ b/lantern-core/apps/apps_data.go @@ -1,10 +1,10 @@ package apps type AppData struct { - Name string `json:"name"` - BundleID string `json:"bundleId"` - AppPath string `json:"appPath"` - IconPath string `json:"iconPath"` - + Name string `json:"name"` + BundleID string `json:"bundleId"` + AppPath string `json:"appPath"` + IconPath string `json:"iconPath"` + IsBrowser bool `json:"isBrowser"` IconBytes []byte `json:"iconBytes,omitempty"` } diff --git a/lantern-core/apps/browsers_other.go b/lantern-core/apps/browsers_other.go new file mode 100644 index 0000000000..d5bf287708 --- /dev/null +++ b/lantern-core/apps/browsers_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package apps + +// markBrowsers is a no-op outside Windows: Android and macOS detect +// browsers in their platform layers (AppDataHandler.kt queries browsable +// http intent handlers; AppStreamHandler.swift asks LaunchServices). +func markBrowsers([]*AppData) {} diff --git a/lantern-core/apps/browsers_windows.go b/lantern-core/apps/browsers_windows.go new file mode 100644 index 0000000000..4d50313b97 --- /dev/null +++ b/lantern-core/apps/browsers_windows.go @@ -0,0 +1,199 @@ +//go:build windows + +package apps + +import ( + "log/slog" + "path/filepath" + "sort" + "strings" + "sync" + + "golang.org/x/sys/windows/registry" +) + +// browserExeIndex holds the executables of the browsers registered on this +// machine, keyed by normalized full path and by lowercase basename. +type browserExeIndex struct { + paths map[string]bool + basenames map[string]bool +} + +// registryRoot pairs a registry hive with the access flags used to read it. +type registryRoot struct { + root registry.Key + flags uint32 +} + +// browserRegistryRoots covers the hives/views installed browsers register +// under: both WOW64 views of HKLM (machine-wide installs) plus HKCU +// (per-user installs, and packaged browsers whose default-app registration +// lives under the current user). +var browserRegistryRoots = []registryRoot{ + {registry.LOCAL_MACHINE, registry.READ | registry.WOW64_64KEY}, + {registry.LOCAL_MACHINE, registry.READ | registry.WOW64_32KEY}, + {registry.CURRENT_USER, registry.READ | registry.WOW64_64KEY}, +} + +// loadBrowserIndexOnce builds the set of registered browser executables from +// the modern default-app registration, so any properly installed browser is +// detected without a hardcoded list: +// +// Software\RegisteredApplications -> Capabilities\URLAssociations +// +// Each RegisteredApplications value points at a Capabilities key, and an app +// is a browser exactly when its URLAssociations declare an http/https handler. +// This covers both classic Win32 browsers (Chrome, Firefox, Edge, ...) and +// packaged/MSIX browsers (e.g. Arc), which register here rather than under the +// legacy Software\Clients\StartMenuInternet key. +// +// Portable browsers that skip registration entirely (e.g. Tor Browser) are +// not caught. +var loadBrowserIndexOnce = sync.OnceValue(func() browserExeIndex { + idx := browserExeIndex{ + paths: map[string]bool{}, + basenames: map[string]bool{}, + } + + scanRegisteredApplicationBrowsers(&idx) + + slog.Info("browser registry scan complete", + "browsers", len(idx.paths), + "basenames", sortedKeys(idx.basenames), + ) + return idx +}) + +// addExe records a browser executable under both its full path and basename. +func (idx *browserExeIndex) addExe(exe string) { + if exe == "" { + return + } + idx.paths[normalizeKey(filepath.Clean(exe))] = true + idx.basenames[strings.ToLower(filepath.Base(exe))] = true +} + +// scanRegisteredApplicationBrowsers indexes browsers via the modern +// default-app registration: each Software\RegisteredApplications value points +// at a Capabilities key, and an app is a browser exactly when its +// Capabilities\URLAssociations declares an http/https handler. This catches +// classic Win32 browsers as well as packaged/MSIX browsers (Arc, +// Store-installed Chromium forks, ...). +func scanRegisteredApplicationBrowsers(idx *browserExeIndex) { + const regAppsKey = `Software\RegisteredApplications` + for _, r := range browserRegistryRoots { + k, err := registry.OpenKey(r.root, regAppsKey, r.flags) + if err != nil { + continue + } + valueNames, _ := k.ReadValueNames(-1) + capPaths := make([]string, 0, len(valueNames)) + for _, vn := range valueNames { + capPath, _, err := k.GetStringValue(vn) + if err == nil && strings.TrimSpace(capPath) != "" { + capPaths = append(capPaths, strings.TrimSpace(capPath)) + } + } + k.Close() + + for _, capPath := range capPaths { + addBrowserFromCapabilities(idx, r, capPath) + } + } +} + +// addBrowserFromCapabilities inspects a Capabilities key and, when it +// declares an http/https URL handler, resolves that handler's executable into +// the index. +func addBrowserFromCapabilities(idx *browserExeIndex, r registryRoot, capPath string) { + ua, err := registry.OpenKey(r.root, capPath+`\URLAssociations`, r.flags) + if err != nil { + return + } + defer ua.Close() + + for _, scheme := range []string{"http", "https"} { + progID, _, err := ua.GetStringValue(scheme) + if err != nil || strings.TrimSpace(progID) == "" { + continue + } + idx.addExe(browserExeFromProgID(r, strings.TrimSpace(progID))) + } +} + +// browserExeFromProgID resolves a URL-handler ProgID to its executable via +// Software\Classes\\shell\open\command. It checks the ProgID's own +// hive first, then the opposite hive, since a per-user default may point at a +// machine-registered ProgID (or vice versa). +func browserExeFromProgID(r registryRoot, progID string) string { + roots := []registry.Key{r.root} + if r.root == registry.CURRENT_USER { + roots = append(roots, registry.LOCAL_MACHINE) + } else { + roots = append(roots, registry.CURRENT_USER) + } + for _, root := range roots { + ck, err := registry.OpenKey( + root, `Software\Classes\`+progID+`\shell\open\command`, r.flags) + if err != nil { + continue + } + cmd, _, _ := ck.GetStringValue("") + ck.Close() + if exe := browserCommandExe(cmd); exe != "" { + return exe + } + } + return "" +} + +// browserCommandExe extracts the executable path from a shell\open\command +// value (typically a quoted exe path, sometimes with arguments). +func browserCommandExe(cmd string) string { + tokens := parseWindowsCommandTokens(cmd) + if len(tokens) == 0 { + return "" + } + exe := strings.Trim(strings.TrimSpace(tokens[0]), `"`) + if exe == "" { + return "" + } + exe = filepath.Clean(expandPercentEnv(exe)) + if !filepath.IsAbs(exe) || !strings.EqualFold(filepath.Ext(exe), ".exe") { + return "" + } + return exe +} + +// markBrowsers flags apps whose executable is a registered browser. Matches +// by full path first, then by basename — installs sometimes surface through +// a different discovery source (Start Menu vs App Paths) with an equivalent +// but not byte-identical path. +func markBrowsers(list []*AppData) { + idx := loadBrowserIndexOnce() + for _, app := range list { + if app == nil { + continue + } + // Recompute from the live registry index rather than trusting a flag + // persisted by an older cache (e.g. a browser uninstalled since). + app.IsBrowser = false + p := strings.Trim(strings.TrimSpace(app.AppPath), `"`) + if p == "" { + continue + } + p = filepath.Clean(p) + if idx.paths[normalizeKey(p)] || idx.basenames[strings.ToLower(filepath.Base(p))] { + app.IsBrowser = true + } + } +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/lib/core/common/app_dialog.dart b/lib/core/common/app_dialog.dart index 518204cbe4..c4c9ddbbd8 100644 --- a/lib/core/common/app_dialog.dart +++ b/lib/core/common/app_dialog.dart @@ -16,6 +16,9 @@ class AppDialog { String? secondaryLabel, OnPressed? onSecondaryPressed, bool centered = false, + // Centers just the title while the body stays start-aligned (the + // browser-warning layout: centered icon + title, left-aligned body). + bool centeredTitle = false, bool barrierDismissible = false, // When false the primary callback is responsible for dismissing the // dialog itself (e.g. vpnConflictDialog callers already pop). @@ -47,7 +50,9 @@ class AppDialog { Text( title, style: textTheme.headlineMedium, - textAlign: centered ? TextAlign.center : TextAlign.start, + textAlign: (centered || centeredTitle) + ? TextAlign.center + : TextAlign.start, ), SizedBox(height: 8), ], @@ -246,6 +251,24 @@ class AppDialog { ); } + /// Show warning dialog when the user tries to add any browser. + static Future browserBypassWarningDialog({ + required BuildContext context, + required String browserName, + required VoidCallback onAddAnyway, + }) { + return show( + context: context, + header: Center(child: AppImage(path: AppImagePaths.warning, height: 45)), + centeredTitle: true, + title: 'bypass_browser_warning_title'.i18n.fill([browserName]), + body: 'bypass_browser_warning_body'.i18n.fill([browserName]), + primaryLabel: 'cancel'.i18n, + secondaryLabel: 'add_anyway'.i18n, + onSecondaryPressed: onAddAnyway, + ); + } + static void dialog({ required BuildContext context, required String title, diff --git a/lib/core/models/app_data.dart b/lib/core/models/app_data.dart index 480520aa96..0d2f8b7c50 100644 --- a/lib/core/models/app_data.dart +++ b/lib/core/models/app_data.dart @@ -12,6 +12,11 @@ class AppData { final int lastUpdateTime; final bool removed; + /// True when the app registers itself as an http(s) handler (a browser). + /// Populated on Android (intent handlers), macOS (LaunchServices) and + /// Windows (registry default-app scan); defaults to false elsewhere. + final bool isBrowser; + const AppData({ required this.name, required this.bundleId, @@ -21,6 +26,7 @@ class AppData { this.isEnabled = false, this.lastUpdateTime = 0, this.removed = false, + this.isBrowser = false, }); AppData copyWith({ @@ -32,6 +38,7 @@ class AppData { bool? isEnabled, int? lastUpdateTime, bool? removed, + bool? isBrowser, }) { return AppData( name: name ?? this.name, @@ -42,6 +49,7 @@ class AppData { isEnabled: isEnabled ?? this.isEnabled, lastUpdateTime: lastUpdateTime ?? this.lastUpdateTime, removed: removed ?? this.removed, + isBrowser: isBrowser ?? this.isBrowser, ); } @@ -60,6 +68,7 @@ class AppData { iconBytes: iconToBytes(m['icon'] ?? m['iconBytes']), lastUpdateTime: (m['lastUpdateTime'] as num?)?.toInt() ?? 0, removed: m['removed'] == true || m['isRemoved'] == true, + isBrowser: m['isBrowser'] == true, ); } @@ -72,6 +81,7 @@ class AppData { iconBytes: iconToBytes(json['icon'] ?? json['iconBytes']), lastUpdateTime: (json['lastUpdateTime'] as num?)?.toInt() ?? 0, removed: json['removed'] == true || json['isRemoved'] == true, + isBrowser: json['isBrowser'] == true, ); Map toJson() => { @@ -83,5 +93,6 @@ class AppData { 'iconBytes': iconBytes, // or base64 if you serialize across FFI 'lastUpdateTime': lastUpdateTime, 'removed': removed, + 'isBrowser': isBrowser, }; } diff --git a/lib/core/services/local_storage_service.dart b/lib/core/services/local_storage_service.dart index 5387819d0a..47213b575c 100644 --- a/lib/core/services/local_storage_service.dart +++ b/lib/core/services/local_storage_service.dart @@ -21,6 +21,8 @@ class LocalStorageService { static const _developerModeKey = 'developer_mode_json'; static const _serverLocationKey = 'server_location_json'; static const _seenReferralsKey = 'seen_converted_referrals'; + static const _seenBypassAppDialogKey = 'seen_bypass_app_dialog'; + static const _seenBypassWebsiteDialogKey = 'seen_bypass_website_dialog'; Future init() async { _prefs = await SharedPreferencesWithCache.create( @@ -112,6 +114,24 @@ class LocalStorageService { Future saveSeenConvertedReferrals(List userIds) => setStringList(_seenReferralsKey, userIds); + // ── Split tunneling ─────────────────────────────────────────────────────── + + /// Whether the one-time "Bypass the VPN for this app?" explainer was already + /// shown when adding an app to the bypass list. + bool get hasSeenBypassAppDialog => + getBool(_seenBypassAppDialogKey) ?? false; + + Future markBypassAppDialogSeen() => + setBool(_seenBypassAppDialogKey, true); + + /// Whether the one-time "Bypass the VPN for this website?" explainer was + /// already shown when adding a website to the bypass list. + bool get hasSeenBypassWebsiteDialog => + getBool(_seenBypassWebsiteDialogKey) ?? false; + + Future markBypassWebsiteDialogSeen() => + setBool(_seenBypassWebsiteDialogKey, true); + // Helper methods for basic types String? getString(String key) => _prefs.getString(key); @@ -124,6 +144,16 @@ class LocalStorageService { } } + bool? getBool(String key) => _prefs.getBool(key); + + Future setBool(String key, bool value) async { + try { + await _prefs.setBool(key, value); + } catch (e, st) { + appLogger.error('LocalStorage setBool($key) failed', e, st); + } + } + List? getStringList(String key) => _prefs.getStringList(key); Future setStringList(String key, List value) async { diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index dd635512e5..7f5f026f76 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:auto_route/auto_route.dart'; @@ -7,6 +8,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_data.dart'; +import 'package:lantern/core/services/injection_container.dart'; +import 'package:lantern/core/services/local_storage_service.dart'; import 'package:lantern/core/widgets/loading_indicator.dart'; import 'package:lantern/core/widgets/search_bar.dart'; import 'package:lantern/core/widgets/section_label.dart'; @@ -136,11 +139,11 @@ class AppsSplitTunneling extends ConsumerWidget { trailing: AppTextButton( label: 'select_all'.i18n, fontSize: 14, - onPressed: () async { - await notifier.selectApps( - filteredDisabled, - ); - }, + onPressed: () => onTapSelectAll( + ctx, + notifier, + filteredDisabled, + ), ), ); } @@ -148,7 +151,7 @@ class AppsSplitTunneling extends ConsumerWidget { return AppRow( app: app, enabled: false, - onToggle: () => notifier.toggleApp(app), + onToggle: () => onTapAddApp(ctx, notifier, app), ); }, ), @@ -158,6 +161,120 @@ class AppsSplitTunneling extends ConsumerWidget { ), ); } + + /// Show info dialog for first time user + Future onTapAddApp( + BuildContext context, + SplitTunnelingApps notifier, + AppData app, + ) async { + if (app.isBrowser) { + await AppDialog.browserBypassWarningDialog( + context: context, + browserName: app.name, + onAddAnyway: () => notifier.toggleApp(app), + ); + return; + } + final storage = sl(); + if (!storage.hasSeenBypassAppDialog) { + await AppDialog.show( + context: context, + header: Center(child: AppImage(path: AppImagePaths.info, height: 40)), + centeredTitle: true, + title: 'bypass_app_first_time_title'.i18n, + body: 'bypass_app_first_time_body'.i18n.fill([app.name]), + primaryLabel: 'add'.i18n, + onPrimaryPressed: () { + // Mark seen only on confirm; cancelling should show the + // explainer again next time. + unawaited(storage.markBypassAppDialogSeen()); + notifier.toggleApp(app); + }, + secondaryLabel: 'cancel'.i18n, + ); + return; + } + notifier.toggleApp(app); + } + + /// Warn when Select All would put browsers on the bypass list + Future onTapSelectAll( + BuildContext context, + SplitTunnelingApps notifier, + List apps, + ) async { + final browsers = apps.where((a) => a.isBrowser).toList(); + if (browsers.isEmpty) { + await notifier.selectApps(apps); + return; + } + await _showSelectAllBypassWarning( + context: context, + browserName: browsers.first.name, + onAddAllExceptBrowsers: () => + notifier.selectApps(apps.where((a) => !a.isBrowser).toList()), + onAddAllAnyway: () => notifier.selectApps(apps), + ); + } +} + +/// Warning shown when "Select All" would add browsers to the bypass list +Future _showSelectAllBypassWarning({ + required BuildContext context, + required String browserName, + required Future Function() onAddAllExceptBrowsers, + required Future Function() onAddAllAnyway, +}) { + final textTheme = Theme.of(context).textTheme; + return AppDialog.customDialog( + context: context, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: size24), + Center(child: AppImage(path: AppImagePaths.warning, height: 45)), + SizedBox(height: size24), + Text( + 'bypass_all_warning_title'.i18n, + style: textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + SizedBox(height: 8), + Text( + 'bypass_all_warning_body'.i18n.fill([browserName]), + style: textTheme.bodyMedium?.copyWith( + color: context.textSecondary, + height: 23 / 16, + ), + ), + ], + ), + action: [ + PrimaryButton( + label: 'add_all_except_browsers'.i18n, + onPressed: () async { + appRouter.pop(); + await onAddAllExceptBrowsers(); + }, + ), + SecondaryButton( + label: 'add_all_anyway'.i18n, + onPressed: () async { + appRouter.pop(); + await onAddAllAnyway(); + }, + ), + Center( + child: AppTextButton( + label: 'cancel'.i18n, + textColor: context.textPrimary, + onPressed: () => appRouter.pop(), + ), + ), + ], + ); } class AppRow extends ConsumerWidget { diff --git a/lib/features/split_tunneling/split_tunneling.dart b/lib/features/split_tunneling/split_tunneling.dart index 5381e18d5e..01e9343224 100644 --- a/lib/features/split_tunneling/split_tunneling.dart +++ b/lib/features/split_tunneling/split_tunneling.dart @@ -1,4 +1,5 @@ import 'package:auto_route/auto_route.dart'; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; @@ -53,10 +54,11 @@ class SplitTunneling extends HookConsumerWidget { fontSize: 16, color: context.textPrimary, ), - subtitle: Text( + subtitle: AutoSizeText( 'add_apps_websites_bypass_vpn'.i18n, - maxLines: 1, - overflow: TextOverflow.ellipsis, + minFontSize: 10, + maxLines: 2, + maxFontSize: 12, style: textTheme.labelMedium!.copyWith( color: context.textTertiary, letterSpacing: 0.0, @@ -88,7 +90,7 @@ class SplitTunneling extends HookConsumerWidget { actionText: '${enabledWebsites.length} Added', onPressed: () => appRouter.push(WebsiteSplitTunneling()), ), - } + }, ], ), ), diff --git a/lib/features/split_tunneling/website_domain_input.dart b/lib/features/split_tunneling/website_domain_input.dart index a8c19e9c5e..96709c8cec 100644 --- a/lib/features/split_tunneling/website_domain_input.dart +++ b/lib/features/split_tunneling/website_domain_input.dart @@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/website.dart'; +import 'package:lantern/core/services/injection_container.dart'; +import 'package:lantern/core/services/local_storage_service.dart'; import 'package:lantern/features/split_tunneling/provider/website_notifier.dart'; class WebsiteDomainInput extends HookConsumerWidget { @@ -43,9 +45,27 @@ class WebsiteDomainInput extends HookConsumerWidget { return website; } + Future addValidatedWebsites(List added) async { + if (added.isEmpty) { + return; + } + textController.clear(); + + final failures = await ref + .read(splitTunnelingWebsitesProvider.notifier) + .addWebsites(added); + + if (!context.mounted || failures.isEmpty) { + return; + } + + showSnackbar( + failures.map((failure) => failure.localizedErrorMessage).join('\n'), + ); + } + Future validateAndExtractDomain() async { final inputText = textController.text.trim(); - if (inputText.isEmpty) { showSnackbar("Please enter a URL or domain."); return; @@ -67,26 +87,34 @@ class WebsiteDomainInput extends HookConsumerWidget { } } - if (added.isNotEmpty) { - textController.clear(); - } - if (errors.isNotEmpty) { showSnackbar(errors.join('\n')); return; } - final failures = await ref - .read(splitTunnelingWebsitesProvider.notifier) - .addWebsites(added); - - if (!context.mounted || failures.isEmpty) { + final storage = sl(); + if (!storage.hasSeenBypassWebsiteDialog) { + await AppDialog.show( + context: context, + header: Center(child: AppImage(path: AppImagePaths.info, height: 40)), + centeredTitle: true, + title: 'bypass_website_first_time_title'.i18n, + body: 'bypass_website_first_time_body'.i18n.fill([ + added.map((website) => website.domain).join(', '), + ]), + primaryLabel: 'add'.i18n, + onPrimaryPressed: () { + // Mark seen only on confirm; cancelling should show the + // explainer again next time. + unawaited(storage.markBypassWebsiteDialogSeen()); + unawaited(addValidatedWebsites(added)); + }, + secondaryLabel: 'cancel'.i18n, + ); return; } - showSnackbar( - failures.map((failure) => failure.localizedErrorMessage).join('\n'), - ); + await addValidatedWebsites(added); } return Column( diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 5477077c6a..84d71a854a 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -297,6 +297,7 @@ class LanternFFIService implements LanternCoreService { iconPath: raw["iconPath"] as String? ?? '', iconBytes: iconToBytes(raw["icon"] ?? raw["iconBytes"]), isEnabled: enabledKeys.contains(key), + isBrowser: raw["isBrowser"] == true, ); }).toList(); } catch (e, st) { @@ -499,8 +500,15 @@ class LanternFFIService implements LanternCoreService { if (result == nullptr) { return right(unit); } - final resultStr = result.cast().toDartString(); - malloc.free(result); + // `result` is a Go-allocated C string, so Dart's malloc.free would + // hard-crash (0xC0000409); hand it back to Go via freeCString after + // copying it into a Dart string. + final String resultStr; + try { + resultStr = result.cast().toDartString(); + } finally { + _ffiService.freeCString(result); + } // The Go FFI returns a non-null C string like "ok" on success; only // treat unexpected payloads as errors. if (_ffiOkResults.contains(resultStr)) { diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 40661a06d3..22838bce0f 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -369,6 +369,7 @@ class LanternPlatformService implements LanternCoreService { lastUpdateTime: lastUpdateTime, removed: removed, isEnabled: enabled.contains(key: key, name: name), + isBrowser: raw["isBrowser"] == true, ); }).toList(); } diff --git a/macos/Runner/Handlers/AppStreamHandler.swift b/macos/Runner/Handlers/AppStreamHandler.swift index be6f8bdd49..e77095dc42 100644 --- a/macos/Runner/Handlers/AppStreamHandler.swift +++ b/macos/Runner/Handlers/AppStreamHandler.swift @@ -1,10 +1,68 @@ +import AppKit +import CoreServices import FlutterMacOS import Foundation import Liblantern +import UniformTypeIdentifiers final class AppStreamHandler: NSObject, FlutterStreamHandler { private var eventSink: FlutterEventSink? + /// Apps registered with LaunchServices as browsers — dynamic per device + /// (any installed browser, not a hardcoded list); drives the bypass-list + /// warning dialog on the Flutter side. + /// + /// A browser is an app that handles the http(s) URL scheme AND the HTML + /// content type. Scheme handling alone over-matches (mail clients, meeting + /// apps and download managers register for http links too); the HTML check + /// filters those out. If the HTML query yields nothing we fall back to + /// scheme-only detection — missing a real browser is worse for users in + /// censored regions than an extra warning. + private lazy var browserApps: (bundleIds: Set, paths: Set) = { + guard let url = URL(string: "https://example.com") else { return ([], []) } + var appURLs: [URL] + if #available(macOS 12.0, *) { + appURLs = NSWorkspace.shared.urlsForApplications(toOpen: url) + let htmlHandlers = Set(NSWorkspace.shared.urlsForApplications(toOpen: UTType.html)) + if !htmlHandlers.isEmpty { + appURLs = appURLs.filter { htmlHandlers.contains($0) } + } + } else { + appURLs = + (LSCopyApplicationURLsForURL(url as CFURL, .viewer)?.takeRetainedValue() as? [URL]) ?? [] + let htmlBundleIds = + (LSCopyAllRoleHandlersForContentType(kUTTypeHTML, .viewer)?.takeRetainedValue() + as? [String]).map(Set.init) ?? [] + if !htmlBundleIds.isEmpty { + appURLs = appURLs.filter { appURL in + Bundle(url: appURL)?.bundleIdentifier.map { htmlBundleIds.contains($0) } ?? false + } + } + } + let bundleIds = Set(appURLs.compactMap { Bundle(url: $0)?.bundleIdentifier }) + let paths = Set(appURLs.map { $0.standardizedFileURL.path }) + return (bundleIds, paths) + }() + + /// Adds "isBrowser" to each app item so Flutter can warn before adding a + /// browser to the split-tunnel bypass list. + private func markBrowsers(_ items: [[String: Any]]) -> [[String: Any]] { + let browsers = browserApps + return items.map { item in + var m = item + let bundleId = (item["bundleId"] as? String) ?? "" + let appPath = (item["appPath"] as? String) ?? "" + // browserApps.paths holds standardized paths; standardize the incoming + // path too so symlinks/relative components don't cause false negatives. + let standardizedPath = appPath.isEmpty + ? "" : URL(fileURLWithPath: appPath).standardizedFileURL.path + m["isBrowser"] = + (!bundleId.isEmpty && browsers.bundleIds.contains(bundleId)) + || (!standardizedPath.isEmpty && browsers.paths.contains(standardizedPath)) + return m + } + } + private func readCachedApps(dataDir: String) -> [[String: Any]] { let cachePath = (dataDir as NSString).appendingPathComponent("apps_cache.json") guard let data = try? Data(contentsOf: URL(fileURLWithPath: cachePath)), @@ -30,7 +88,7 @@ final class AppStreamHandler: NSObject, FlutterStreamHandler { guard let self else { return } let dataDir = FilePath.dataDirectory.path - let cached = self.readCachedApps(dataDir: dataDir) + let cached = self.markBrowsers(self.readCachedApps(dataDir: dataDir)) // Send cached snapshot only if stream is still active await MainActor.run { @@ -62,10 +120,11 @@ final class AppStreamHandler: NSObject, FlutterStreamHandler { if let data = jsonString.data(using: .utf8), let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + let marked = self.markBrowsers(arr) await MainActor.run { self.emit([ "type": "snapshot", - "items": arr, + "items": marked, "removed": [], "source": "scan", ])