From 61b2eb2c40b4737dc66a0380a5f78447ff3995d0 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Wed, 12 Aug 2026 15:36:55 +0530 Subject: [PATCH 1/9] Check if the app uses browser in split tunneling. --- .../org/getlantern/lantern/apps/AppData.kt | 8 +- .../getlantern/lantern/apps/AppDataHandler.kt | 19 ++- assets/locales/en.po | 9 ++ lantern-core/apps/apps.go | 7 + lantern-core/apps/apps_data.go | 6 + lantern-core/apps/browsers_other.go | 8 ++ lantern-core/apps/browsers_windows.go | 127 ++++++++++++++++++ lib/core/common/app_dialog.dart | 25 +++- lib/core/models/app_data.dart | 10 ++ .../split_tunneling/apps_split_tunneling.dart | 16 ++- .../split_tunneling/split_tunneling.dart | 10 +- lib/lantern/lantern_ffi_service.dart | 1 + lib/lantern/lantern_platform_service.dart | 1 + macos/Runner/Handlers/AppStreamHandler.swift | 59 +++++++- 14 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 lantern-core/apps/browsers_other.go create mode 100644 lantern-core/apps/browsers_windows.go 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..65a631679b 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,22 @@ 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 { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("http://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 d645ddbf91..248485d973 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -1731,3 +1731,12 @@ 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" 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..9146c7fc60 100644 --- a/lantern-core/apps/apps_data.go +++ b/lantern-core/apps/apps_data.go @@ -6,5 +6,11 @@ type AppData struct { AppPath string `json:"appPath"` IconPath string `json:"iconPath"` + // IsBrowser is true when the app is a web browser — drives the + // split-tunnel bypass warning dialog on the Flutter side. Populated by + // markBrowsers on Windows; on Android/macOS the platform layers + // (AppDataHandler.kt / AppStreamHandler.swift) detect browsers instead. + 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..1c4f2ba14a --- /dev/null +++ b/lantern-core/apps/browsers_windows.go @@ -0,0 +1,127 @@ +//go:build windows + +package apps + +import ( + "log/slog" + "path/filepath" + "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 +} + +// loadBrowserIndexOnce reads Software\Clients\StartMenuInternet — the +// registry every installed browser joins to be eligible as the default +// browser (Chrome, Firefox, Edge, Brave, Opera, regional browsers, ...). +// Dynamic per device, so any properly installed browser is detected without +// a hardcoded list. Portable browsers that skip registration (e.g. Tor +// Browser) are not caught. +var loadBrowserIndexOnce = sync.OnceValue(func() browserExeIndex { + idx := browserExeIndex{ + paths: map[string]bool{}, + basenames: map[string]bool{}, + } + + const clientsKey = `Software\Clients\StartMenuInternet` + type rootSpec struct { + root registry.Key + flags uint32 + } + roots := []rootSpec{ + {registry.LOCAL_MACHINE, registry.READ | registry.WOW64_64KEY}, + {registry.LOCAL_MACHINE, registry.READ | registry.WOW64_32KEY}, + // Per-user browser installs (e.g. user-level Chrome) register here. + {registry.CURRENT_USER, registry.READ | registry.WOW64_64KEY}, + } + + for _, r := range roots { + k, err := registry.OpenKey(r.root, clientsKey, r.flags) + if err != nil { + continue + } + names, _ := k.ReadSubKeyNames(-1) + k.Close() + + for _, name := range names { + ck, err := registry.OpenKey( + r.root, clientsKey+`\`+name+`\shell\open\command`, r.flags) + if err != nil { + continue + } + cmd, _, _ := ck.GetStringValue("") + ck.Close() + + exe := browserCommandExe(cmd) + if exe == "" { + continue + } + idx.paths[normalizeKey(filepath.Clean(exe))] = true + idx.basenames[strings.ToLower(filepath.Base(exe))] = true + } + } + + slog.Info("browser registry scan complete", + "browsers", len(idx.paths), + "basenames", sortedKeys(idx.basenames), + ) + return idx +}) + +// browserCommandExe extracts the executable path from a StartMenuInternet +// 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() + if len(idx.paths) == 0 && len(idx.basenames) == 0 { + return + } + for _, app := range list { + if app == nil { + continue + } + 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) + } + return out +} diff --git a/lib/core/common/app_dialog.dart b/lib/core/common/app_dialog.dart index 518204cbe4..603d680789 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: 40)), + 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..854cd8537c 100644 --- a/lib/core/models/app_data.dart +++ b/lib/core/models/app_data.dart @@ -12,6 +12,10 @@ class AppData { final int lastUpdateTime; final bool removed; + /// True when the app registers itself as an http(s) handler (a browser). + /// Currently only populated on Android; defaults to false elsewhere. + final bool isBrowser; + const AppData({ required this.name, required this.bundleId, @@ -21,6 +25,7 @@ class AppData { this.isEnabled = false, this.lastUpdateTime = 0, this.removed = false, + this.isBrowser = false, }); AppData copyWith({ @@ -32,6 +37,7 @@ class AppData { bool? isEnabled, int? lastUpdateTime, bool? removed, + bool? isBrowser, }) { return AppData( name: name ?? this.name, @@ -42,6 +48,7 @@ class AppData { isEnabled: isEnabled ?? this.isEnabled, lastUpdateTime: lastUpdateTime ?? this.lastUpdateTime, removed: removed ?? this.removed, + isBrowser: isBrowser ?? this.isBrowser, ); } @@ -60,6 +67,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 +80,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 +92,6 @@ class AppData { 'iconBytes': iconBytes, // or base64 if you serialize across FFI 'lastUpdateTime': lastUpdateTime, 'removed': removed, + 'isBrowser': isBrowser, }; } diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index dd635512e5..b05f48129c 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -148,7 +148,21 @@ class AppsSplitTunneling extends ConsumerWidget { return AppRow( app: app, enabled: false, - onToggle: () => notifier.toggleApp(app), + onToggle: () { + // Adding a browser routes its traffic + // outside the VPN — warn before adding + // (getlantern/engineering#3755). + if (app.isBrowser) { + AppDialog.browserBypassWarningDialog( + context: ctx, + browserName: app.name, + onAddAnyway: () => + notifier.toggleApp(app), + ); + } else { + notifier.toggleApp(app); + } + }, ); }, ), 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/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 5477077c6a..5eb7ace0c3 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) { 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..0aebbc34a4 100644 --- a/macos/Runner/Handlers/AppStreamHandler.swift +++ b/macos/Runner/Handlers/AppStreamHandler.swift @@ -1,10 +1,64 @@ +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: .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) ?? "" + m["isBrowser"] = + (!bundleId.isEmpty && browsers.bundleIds.contains(bundleId)) + || (!appPath.isEmpty && browsers.paths.contains(appPath)) + 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 +84,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 +116,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", ]) From ad28316a8889000779d24740fa7aa556c7d1ac33 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Wed, 12 Aug 2026 15:56:27 +0530 Subject: [PATCH 2/9] design add all dialog --- assets/locales/en.po | 12 +++ .../split_tunneling/apps_split_tunneling.dart | 88 +++++++++++++++++-- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index 248485d973..72b5d5ea35 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -1740,3 +1740,15 @@ msgstr "%s will connect directly, without Lantern's protection. In countries tha 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" diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index b05f48129c..8deea67e46 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -136,11 +136,11 @@ class AppsSplitTunneling extends ConsumerWidget { trailing: AppTextButton( label: 'select_all'.i18n, fontSize: 14, - onPressed: () async { - await notifier.selectApps( - filteredDisabled, - ); - }, + onPressed: () => onTapSelectAll( + ctx, + notifier, + filteredDisabled, + ), ), ); } @@ -172,6 +172,84 @@ class AppsSplitTunneling extends ConsumerWidget { ), ); } + + /// 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 VoidCallback onAddAllExceptBrowsers, + required VoidCallback 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: 40)), + 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: () { + appRouter.pop(); + onAddAllExceptBrowsers(); + }, + ), + SecondaryButton( + label: 'add_all_anyway'.i18n, + onPressed: () { + appRouter.pop(); + onAddAllAnyway(); + }, + ), + Center( + child: AppTextButton( + label: 'cancel'.i18n, + textColor: context.textPrimary, + onPressed: () => appRouter.pop(), + ), + ), + ], + ); } class AppRow extends ConsumerWidget { From 486e4f063decbf32f82fc508abc8d9836cfed687 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Wed, 12 Aug 2026 16:30:27 +0530 Subject: [PATCH 3/9] added one time diaog --- assets/locales/en.po | 12 +++++ lib/core/services/local_storage_service.dart | 30 +++++++++++ .../split_tunneling/apps_split_tunneling.dart | 51 +++++++++++++------ .../split_tunneling/website_domain_input.dart | 47 ++++++++++++----- 4 files changed, 112 insertions(+), 28 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index 72b5d5ea35..6bfa63778d 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -1752,3 +1752,15 @@ 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/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 8deea67e46..b9c67fae77 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'; @@ -148,21 +151,7 @@ class AppsSplitTunneling extends ConsumerWidget { return AppRow( app: app, enabled: false, - onToggle: () { - // Adding a browser routes its traffic - // outside the VPN — warn before adding - // (getlantern/engineering#3755). - if (app.isBrowser) { - AppDialog.browserBypassWarningDialog( - context: ctx, - browserName: app.name, - onAddAnyway: () => - notifier.toggleApp(app), - ); - } else { - notifier.toggleApp(app); - } - }, + onToggle: () => onTapAddApp(ctx, notifier, app), ); }, ), @@ -173,6 +162,38 @@ 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) { + unawaited(storage.markBypassAppDialogSeen()); + 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: () => 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, diff --git a/lib/features/split_tunneling/website_domain_input.dart b/lib/features/split_tunneling/website_domain_input.dart index a8c19e9c5e..e2cc73d985 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,24 @@ class WebsiteDomainInput extends HookConsumerWidget { return website; } + Future addValidatedWebsites(List added) async { + 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 +84,30 @@ 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) { + unawaited(storage.markBypassWebsiteDialogSeen()); + 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.first.domain, + ]), + primaryLabel: 'add'.i18n, + onPrimaryPressed: () => addValidatedWebsites(added), + secondaryLabel: 'cancel'.i18n, + ); return; } - showSnackbar( - failures.map((failure) => failure.localizedErrorMessage).join('\n'), - ); + await addValidatedWebsites(added); } return Column( From daac2ebbc909c840a061fb0b1301cc9bd73b7a2d Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Wed, 12 Aug 2026 16:32:02 +0530 Subject: [PATCH 4/9] update size --- lib/core/common/app_dialog.dart | 2 +- lib/features/split_tunneling/apps_split_tunneling.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/common/app_dialog.dart b/lib/core/common/app_dialog.dart index 603d680789..c4c9ddbbd8 100644 --- a/lib/core/common/app_dialog.dart +++ b/lib/core/common/app_dialog.dart @@ -259,7 +259,7 @@ class AppDialog { }) { return show( context: context, - header: Center(child: AppImage(path: AppImagePaths.warning, height: 40)), + 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]), diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index b9c67fae77..17b86094d9 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -230,7 +230,7 @@ Future _showSelectAllBypassWarning({ crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox(height: size24), - Center(child: AppImage(path: AppImagePaths.warning, height: 40)), + Center(child: AppImage(path: AppImagePaths.warning, height: 45)), SizedBox(height: size24), Text( 'bypass_all_warning_title'.i18n, From a7f071315235dbde9fcfd6b803751de95b766056 Mon Sep 17 00:00:00 2001 From: jigar-f Date: Wed, 12 Aug 2026 19:02:10 +0530 Subject: [PATCH 5/9] Update browser checking code for Windows. --- lantern-core/apps/apps_data.go | 6 - lantern-core/apps/browsers_windows.go | 154 +++++++++++++++++++------- lib/lantern/lantern_ffi_service.dart | 4 +- 3 files changed, 115 insertions(+), 49 deletions(-) diff --git a/lantern-core/apps/apps_data.go b/lantern-core/apps/apps_data.go index 9146c7fc60..9b530bd9cc 100644 --- a/lantern-core/apps/apps_data.go +++ b/lantern-core/apps/apps_data.go @@ -5,12 +5,6 @@ type AppData struct { BundleID string `json:"bundleId"` AppPath string `json:"appPath"` IconPath string `json:"iconPath"` - - // IsBrowser is true when the app is a web browser — drives the - // split-tunnel bypass warning dialog on the Flutter side. Populated by - // markBrowsers on Windows; on Android/macOS the platform layers - // (AppDataHandler.kt / AppStreamHandler.swift) detect browsers instead. IsBrowser bool `json:"isBrowser"` - IconBytes []byte `json:"iconBytes,omitempty"` } diff --git a/lantern-core/apps/browsers_windows.go b/lantern-core/apps/browsers_windows.go index 1c4f2ba14a..7c186662fb 100644 --- a/lantern-core/apps/browsers_windows.go +++ b/lantern-core/apps/browsers_windows.go @@ -18,66 +18,136 @@ type browserExeIndex struct { basenames map[string]bool } -// loadBrowserIndexOnce reads Software\Clients\StartMenuInternet — the -// registry every installed browser joins to be eligible as the default -// browser (Chrome, Firefox, Edge, Brave, Opera, regional browsers, ...). -// Dynamic per device, so any properly installed browser is detected without -// a hardcoded list. Portable browsers that skip registration (e.g. Tor -// Browser) are not caught. +// 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{}, } - const clientsKey = `Software\Clients\StartMenuInternet` - type rootSpec struct { - root registry.Key - flags uint32 - } - roots := []rootSpec{ - {registry.LOCAL_MACHINE, registry.READ | registry.WOW64_64KEY}, - {registry.LOCAL_MACHINE, registry.READ | registry.WOW64_32KEY}, - // Per-user browser installs (e.g. user-level Chrome) register here. - {registry.CURRENT_USER, registry.READ | registry.WOW64_64KEY}, + 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 +} - for _, r := range roots { - k, err := registry.OpenKey(r.root, clientsKey, r.flags) +// 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 } - names, _ := k.ReadSubKeyNames(-1) + 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 _, name := range names { - ck, err := registry.OpenKey( - r.root, clientsKey+`\`+name+`\shell\open\command`, r.flags) - if err != nil { - continue - } - cmd, _, _ := ck.GetStringValue("") - ck.Close() + for _, capPath := range capPaths { + addBrowserFromCapabilities(idx, r, capPath) + } + } +} - exe := browserCommandExe(cmd) - if exe == "" { - continue - } - idx.paths[normalizeKey(filepath.Clean(exe))] = true - idx.basenames[strings.ToLower(filepath.Base(exe))] = true +// 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))) } +} - slog.Info("browser registry scan complete", - "browsers", len(idx.paths), - "basenames", sortedKeys(idx.basenames), - ) - return idx -}) +// 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 StartMenuInternet -// shell\open\command value (typically a quoted exe path, sometimes with -// arguments). +// 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 { diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 5eb7ace0c3..7ce42aac0f 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -500,8 +500,10 @@ class LanternFFIService implements LanternCoreService { if (result == nullptr) { return right(unit); } + // Don't free `result`: it's a Go-allocated C string on Go's heap, so + // Dart's malloc.free would hard-crash (0xC0000409). Only free the input + // pointers we allocated (tPtr/vPtr), below. final resultStr = result.cast().toDartString(); - malloc.free(result); // The Go FFI returns a non-null C string like "ok" on success; only // treat unexpected payloads as errors. if (_ffiOkResults.contains(resultStr)) { From 70033a0109d323598fec1799436c61fbf1e9ba49 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Wed, 12 Aug 2026 19:08:52 +0530 Subject: [PATCH 6/9] Format go code --- lantern-core/apps/apps_data.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lantern-core/apps/apps_data.go b/lantern-core/apps/apps_data.go index 9b530bd9cc..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"` - IsBrowser bool `json:"isBrowser"` + 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"` } From 02724a2b28534bcad614b742da20de877996fd89 Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Wed, 12 Aug 2026 19:24:28 +0530 Subject: [PATCH 7/9] code review updates --- lantern-core/apps/browsers_windows.go | 2 ++ lib/core/models/app_data.dart | 3 ++- lib/features/split_tunneling/apps_split_tunneling.dart | 8 ++++++-- lib/features/split_tunneling/website_domain_input.dart | 10 +++++++--- lib/lantern/lantern_ffi_service.dart | 7 ++++--- macos/Runner/Handlers/AppStreamHandler.swift | 8 ++++++-- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/lantern-core/apps/browsers_windows.go b/lantern-core/apps/browsers_windows.go index 7c186662fb..6ceef754e6 100644 --- a/lantern-core/apps/browsers_windows.go +++ b/lantern-core/apps/browsers_windows.go @@ -5,6 +5,7 @@ package apps import ( "log/slog" "path/filepath" + "sort" "strings" "sync" @@ -193,5 +194,6 @@ func sortedKeys(m map[string]bool) []string { for k := range m { out = append(out, k) } + sort.Strings(out) return out } diff --git a/lib/core/models/app_data.dart b/lib/core/models/app_data.dart index 854cd8537c..0d2f8b7c50 100644 --- a/lib/core/models/app_data.dart +++ b/lib/core/models/app_data.dart @@ -13,7 +13,8 @@ class AppData { final bool removed; /// True when the app registers itself as an http(s) handler (a browser). - /// Currently only populated on Android; defaults to false elsewhere. + /// Populated on Android (intent handlers), macOS (LaunchServices) and + /// Windows (registry default-app scan); defaults to false elsewhere. final bool isBrowser; const AppData({ diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index 17b86094d9..6763d05fec 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -178,7 +178,6 @@ class AppsSplitTunneling extends ConsumerWidget { } final storage = sl(); if (!storage.hasSeenBypassAppDialog) { - unawaited(storage.markBypassAppDialogSeen()); await AppDialog.show( context: context, header: Center(child: AppImage(path: AppImagePaths.info, height: 40)), @@ -186,7 +185,12 @@ class AppsSplitTunneling extends ConsumerWidget { title: 'bypass_app_first_time_title'.i18n, body: 'bypass_app_first_time_body'.i18n.fill([app.name]), primaryLabel: 'add'.i18n, - onPrimaryPressed: () => notifier.toggleApp(app), + onPrimaryPressed: () { + // Mark seen only on confirm; cancelling should show the + // explainer again next time. + unawaited(storage.markBypassAppDialogSeen()); + notifier.toggleApp(app); + }, secondaryLabel: 'cancel'.i18n, ); return; diff --git a/lib/features/split_tunneling/website_domain_input.dart b/lib/features/split_tunneling/website_domain_input.dart index e2cc73d985..7e200107c2 100644 --- a/lib/features/split_tunneling/website_domain_input.dart +++ b/lib/features/split_tunneling/website_domain_input.dart @@ -91,17 +91,21 @@ class WebsiteDomainInput extends HookConsumerWidget { final storage = sl(); if (!storage.hasSeenBypassWebsiteDialog) { - unawaited(storage.markBypassWebsiteDialogSeen()); 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.first.domain, + added.map((website) => website.domain).join(', '), ]), primaryLabel: 'add'.i18n, - onPrimaryPressed: () => addValidatedWebsites(added), + onPrimaryPressed: () { + // Mark seen only on confirm; cancelling should show the + // explainer again next time. + unawaited(storage.markBypassWebsiteDialogSeen()); + unawaited(addValidatedWebsites(added)); + }, secondaryLabel: 'cancel'.i18n, ); return; diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 7ce42aac0f..41de7e14d2 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -500,10 +500,11 @@ class LanternFFIService implements LanternCoreService { if (result == nullptr) { return right(unit); } - // Don't free `result`: it's a Go-allocated C string on Go's heap, so - // Dart's malloc.free would hard-crash (0xC0000409). Only free the input - // pointers we allocated (tPtr/vPtr), below. + // `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 resultStr = result.cast().toDartString(); + _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/macos/Runner/Handlers/AppStreamHandler.swift b/macos/Runner/Handlers/AppStreamHandler.swift index 0aebbc34a4..e77095dc42 100644 --- a/macos/Runner/Handlers/AppStreamHandler.swift +++ b/macos/Runner/Handlers/AppStreamHandler.swift @@ -23,7 +23,7 @@ final class AppStreamHandler: NSObject, FlutterStreamHandler { var appURLs: [URL] if #available(macOS 12.0, *) { appURLs = NSWorkspace.shared.urlsForApplications(toOpen: url) - let htmlHandlers = Set(NSWorkspace.shared.urlsForApplications(toOpen: .html)) + let htmlHandlers = Set(NSWorkspace.shared.urlsForApplications(toOpen: UTType.html)) if !htmlHandlers.isEmpty { appURLs = appURLs.filter { htmlHandlers.contains($0) } } @@ -52,9 +52,13 @@ final class AppStreamHandler: NSObject, FlutterStreamHandler { 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)) - || (!appPath.isEmpty && browsers.paths.contains(appPath)) + || (!standardizedPath.isEmpty && browsers.paths.contains(standardizedPath)) return m } } From 5761410df6b56f7534cd4b9c6e902a66b0442dac Mon Sep 17 00:00:00 2001 From: Jigar-f Date: Wed, 12 Aug 2026 19:30:03 +0530 Subject: [PATCH 8/9] more code review updates --- .../org/getlantern/lantern/apps/AppDataHandler.kt | 10 +++++++--- lantern-core/apps/browsers_windows.go | 3 +++ lib/lantern/lantern_ffi_service.dart | 8 ++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) 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 65a631679b..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 @@ -238,9 +238,13 @@ internal class AppDataHandler( * hardcoded list of the common ones. */ private fun browserPackages(pm: PackageManager): Set = runCatching { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("http://example.com")) - .addCategory(Intent.CATEGORY_BROWSABLE) - pm.queryIntentActivities(intent, PackageManager.MATCH_ALL) + // 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()) diff --git a/lantern-core/apps/browsers_windows.go b/lantern-core/apps/browsers_windows.go index 6ceef754e6..9ad2aad857 100644 --- a/lantern-core/apps/browsers_windows.go +++ b/lantern-core/apps/browsers_windows.go @@ -178,6 +178,9 @@ func markBrowsers(list []*AppData) { 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 diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 41de7e14d2..84d71a854a 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -503,8 +503,12 @@ class LanternFFIService implements LanternCoreService { // `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 resultStr = result.cast().toDartString(); - _ffiService.freeCString(result); + 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)) { From 18fdbca3e3d6a9e16e833c336cbb8576e4cfc04d Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 13 Aug 2026 03:07:43 -0700 Subject: [PATCH 9/9] code review updates (#8978) --- lantern-core/apps/browsers_windows.go | 3 --- .../split_tunneling/apps_split_tunneling.dart | 12 ++++++------ .../split_tunneling/website_domain_input.dart | 3 +++ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lantern-core/apps/browsers_windows.go b/lantern-core/apps/browsers_windows.go index 9ad2aad857..4d50313b97 100644 --- a/lantern-core/apps/browsers_windows.go +++ b/lantern-core/apps/browsers_windows.go @@ -171,9 +171,6 @@ func browserCommandExe(cmd string) string { // but not byte-identical path. func markBrowsers(list []*AppData) { idx := loadBrowserIndexOnce() - if len(idx.paths) == 0 && len(idx.basenames) == 0 { - return - } for _, app := range list { if app == nil { continue diff --git a/lib/features/split_tunneling/apps_split_tunneling.dart b/lib/features/split_tunneling/apps_split_tunneling.dart index 6763d05fec..7f5f026f76 100644 --- a/lib/features/split_tunneling/apps_split_tunneling.dart +++ b/lib/features/split_tunneling/apps_split_tunneling.dart @@ -223,8 +223,8 @@ class AppsSplitTunneling extends ConsumerWidget { Future _showSelectAllBypassWarning({ required BuildContext context, required String browserName, - required VoidCallback onAddAllExceptBrowsers, - required VoidCallback onAddAllAnyway, + required Future Function() onAddAllExceptBrowsers, + required Future Function() onAddAllAnyway, }) { final textTheme = Theme.of(context).textTheme; return AppDialog.customDialog( @@ -254,16 +254,16 @@ Future _showSelectAllBypassWarning({ action: [ PrimaryButton( label: 'add_all_except_browsers'.i18n, - onPressed: () { + onPressed: () async { appRouter.pop(); - onAddAllExceptBrowsers(); + await onAddAllExceptBrowsers(); }, ), SecondaryButton( label: 'add_all_anyway'.i18n, - onPressed: () { + onPressed: () async { appRouter.pop(); - onAddAllAnyway(); + await onAddAllAnyway(); }, ), Center( diff --git a/lib/features/split_tunneling/website_domain_input.dart b/lib/features/split_tunneling/website_domain_input.dart index 7e200107c2..96709c8cec 100644 --- a/lib/features/split_tunneling/website_domain_input.dart +++ b/lib/features/split_tunneling/website_domain_input.dart @@ -46,6 +46,9 @@ class WebsiteDomainInput extends HookConsumerWidget { } Future addValidatedWebsites(List added) async { + if (added.isEmpty) { + return; + } textController.clear(); final failures = await ref