From 7ea21bc4d70ab24df3b1fa99d1a9b0a3ff688131 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:26:08 +0100 Subject: [PATCH 1/2] fix: re-detect gateway on user actions and VPN interface changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway was stale when VPN switched interfaces (e.g. utun4 → utun5), causing addDomain/toggleDomain to silently skip route application. Now all user-triggered actions re-detect the gateway if nil, VPN interface changes trigger automatic re-routing, and NWPathMonitor tracks interface names (not just types) to catch utun transitions. --- Casks/vpn-bypass.rb | 2 +- Info.plist | 2 +- README.md | 2 +- Sources/RouteManager.swift | 93 ++++++++++++++++++++++++++++++-------- Sources/SettingsView.swift | 4 +- Sources/VPNBypassApp.swift | 13 +++--- docs/CHANGELOG.md | 11 +++++ 7 files changed, 98 insertions(+), 29 deletions(-) diff --git a/Casks/vpn-bypass.rb b/Casks/vpn-bypass.rb index 10bd75d..737ab88 100644 --- a/Casks/vpn-bypass.rb +++ b/Casks/vpn-bypass.rb @@ -3,7 +3,7 @@ # Or if using local tap: brew install --cask --no-quarantine ./Casks/vpn-bypass.rb cask "vpn-bypass" do - version "1.8.0" + version "1.8.1" sha256 "37b127a55aec0bdb80e824e59e840ce5b529c09086aac7fc24dc4616abb817bd" url "https://github.com/GeiserX/VPN-Bypass/releases/download/v#{version}/VPN-Bypass-#{version}.dmg" diff --git a/Info.plist b/Info.plist index 6709a02..107eb8a 100644 --- a/Info.plist +++ b/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.8.0 + 1.8.1 CFBundleVersion 19 LSMinimumSystemVersion diff --git a/README.md b/README.md index 3c1aff5..ccc4101 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

macOS 13+ Swift 5.9 - Version + Version

## Why? diff --git a/Sources/RouteManager.swift b/Sources/RouteManager.swift index 603ad6c..12c3dc7 100644 --- a/Sources/RouteManager.swift +++ b/Sources/RouteManager.swift @@ -599,7 +599,20 @@ final class RouteManager: ObservableObject { } } - if !isVPNConnected && wasVPNConnected && vpnInterface != oldInterface { + // VPN interface switched while still connected — re-route through new gateway + if isVPNConnected && wasVPNConnected && interface != oldInterface && oldInterface != nil && !isLoading && !isApplyingRoutes { + log(.warning, "VPN interface changed: \(oldInterface ?? "?") → \(interface ?? "?")") + if localGateway != nil { + isLoading = true + await removeAllRoutes() + await applyAllRoutes() + isLoading = false + } else { + log(.error, "VPN interface changed but no gateway detected") + } + } + + if !isVPNConnected && wasVPNConnected { log(.warning, "VPN disconnected (was: \(oldInterface ?? "unknown"))") NotificationManager.shared.notifyVPNDisconnected(wasInterface: oldInterface) cancelAllRetries() @@ -1116,7 +1129,10 @@ final class RouteManager: ObservableObject { /// Apply routes using cached IPs only (no DNS resolution) - used for instant startup private func applyRoutesFromCache() async { - guard let gateway = localGateway else { return } + guard let gateway = localGateway else { + log(.error, "Cannot apply cached routes: no local gateway") + return + } var newRoutes: [ActiveRoute] = [] var routesToAdd: [(destination: String, gateway: String, isNetwork: Bool, source: String)] = [] @@ -1185,7 +1201,10 @@ final class RouteManager: ObservableObject { /// Background DNS refresh - re-resolves all domains and updates routes if IPs changed private func backgroundDNSRefresh(sendNotification: Bool) async { - guard let gateway = localGateway else { return } + guard let gateway = localGateway else { + log(.warning, "Background DNS refresh skipped: no local gateway") + return + } // Collect domains to resolve var domainsToResolve: [(domain: String, source: String)] = [] @@ -1334,7 +1353,7 @@ final class RouteManager: ObservableObject { /// Perform DNS refresh - re-resolve all domains and update routes private func performDNSRefresh() async { guard isVPNConnected, let gateway = localGateway else { - log(.info, "DNS refresh skipped: VPN not connected") + log(.info, "DNS refresh skipped: \(!isVPNConnected ? "VPN not connected" : "no local gateway")") nextDNSRefresh = config.autoDNSRefresh ? Date().addingTimeInterval(config.dnsRefreshInterval) : nil return } @@ -1489,9 +1508,14 @@ final class RouteManager: ObservableObject { saveConfig() log(.success, "Added domain: \(cleaned)") - if isVPNConnected, let gateway = localGateway { + if isVPNConnected { isApplyingRoutes = true Task { + guard let gateway = await ensureGateway() else { + log(.error, "Cannot route \(cleaned): no local gateway detected. Try Refresh Routes.") + isApplyingRoutes = false + return + } if let routes = await applyRoutesForDomain(cleaned, gateway: gateway) { activeRoutes.append(contentsOf: routes) if config.manageHostsFile { @@ -1522,7 +1546,11 @@ final class RouteManager: ObservableObject { private func retryFailedDomain(_ domain: String) async { guard config.domains.contains(where: { $0.domain == domain && $0.enabled }) else { return } - guard isVPNConnected, let gateway = localGateway else { return } + guard isVPNConnected else { return } + guard let gateway = await ensureGateway() else { + log(.error, "Retry skipped for \(domain): no local gateway detected") + return + } guard !activeRoutes.contains(where: { $0.source == domain }) else { log(.info, "Skipping retry for \(domain) — routes already exist") return @@ -1573,10 +1601,15 @@ final class RouteManager: ObservableObject { let domain = config.domains[index] log(.info, "\(domain.domain) \(domain.enabled ? "enabled" : "disabled")") - if isVPNConnected, let gateway = localGateway { + if isVPNConnected { isApplyingRoutes = true Task { if domain.enabled { + guard let gateway = await ensureGateway() else { + log(.error, "Cannot route \(domain.domain): no local gateway detected") + isApplyingRoutes = false + return + } if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway) { activeRoutes.append(contentsOf: routes) if config.manageHostsFile { @@ -1606,14 +1639,20 @@ final class RouteManager: ObservableObject { log(.info, enabled ? "Enabled all domains" : "Disabled all domains") - if isVPNConnected, let gateway = localGateway { + if isVPNConnected { Task { + let gateway: String? = enabled ? await ensureGateway() : nil + if enabled && gateway == nil { + log(.error, "Cannot enable domains: no local gateway detected") + isApplyingRoutes = false + return + } for domain in domainsToChange { - if enabled { - if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway, persistCache: false) { + if enabled, let gw = gateway { + if let routes = await applyRoutesForDomain(domain.domain, gateway: gw, persistCache: false) { activeRoutes.append(contentsOf: routes) } - } else { + } else if !enabled { await removeRoutesForSource(domain.domain) } } @@ -1654,14 +1693,17 @@ final class RouteManager: ObservableObject { log(.info, "\(service.name) \(service.enabled ? "enabled" : "disabled")") // Incremental route apply/remove - if isVPNConnected, let gateway = localGateway { + if isVPNConnected { isApplyingRoutes = true Task { if service.enabled { - // Service was just enabled - add its routes + guard let gateway = await ensureGateway() else { + log(.error, "Cannot route \(service.name): no local gateway detected") + isApplyingRoutes = false + return + } await applyRoutesForService(service, gateway: gateway) } else { - // Service was just disabled - remove its routes await removeRoutesForSource(service.name) } await MainActor.run { @@ -1756,12 +1798,18 @@ final class RouteManager: ObservableObject { log(.info, enabled ? "Enabled all services" : "Disabled all services") // Incrementally apply/remove routes for changed services only - if isVPNConnected, let gateway = localGateway { + if isVPNConnected { Task { + let gateway: String? = enabled ? await ensureGateway() : nil + if enabled && gateway == nil { + log(.error, "Cannot enable services: no local gateway detected") + isApplyingRoutes = false + return + } for service in servicesToChange { - if enabled { - await applyRoutesForService(service, gateway: gateway) - } else { + if enabled, let gw = gateway { + await applyRoutesForService(service, gateway: gw) + } else if !enabled { await removeRoutesForSource(service.name) } } @@ -1851,6 +1899,15 @@ final class RouteManager: ObservableObject { // MARK: - Private Methods + private func ensureGateway() async -> String? { + if let gw = localGateway { return gw } + localGateway = await detectLocalGateway() + if localGateway != nil { + log(.info, "Gateway re-detected: \(localGateway!)") + } + return localGateway + } + private func detectLocalGateway() async -> String? { // Try common network services let services = ["Wi-Fi", "Ethernet", "USB 10/100/1000 LAN", "Thunderbolt Ethernet", "USB-C LAN"] diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index e383101..daf5b12 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -1263,7 +1263,7 @@ struct GeneralTab: View { HStack { VStack(alignment: .leading, spacing: 2) { BrandedAppName(fontSize: 13) - Text("Version 1.8.0") + Text("Version 1.8.1") .font(.system(size: 11)) .foregroundColor(Color(hex: "6B7280")) } @@ -1721,7 +1721,7 @@ struct InfoTab: View { // App name with branded colors BrandedAppName(fontSize: 24) - Text("v1.8.0") + Text("v1.8.1") .font(.system(size: 12, design: .monospaced)) .foregroundColor(Color(hex: "6B7280")) diff --git a/Sources/VPNBypassApp.swift b/Sources/VPNBypassApp.swift index e47e039..beea4f5 100644 --- a/Sources/VPNBypassApp.swift +++ b/Sources/VPNBypassApp.swift @@ -42,6 +42,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var watchdogTimer: Timer? private var lastPathStatus: NWPath.Status? private var lastInterfaceTypes: Set = [] + private var lastInterfaceNames: Set = [] private var networkDebounceWorkItem: DispatchWorkItem? private var hasCompletedInitialStartup = false private var appStartTime = Date() @@ -129,22 +130,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func handleNetworkChange(_ path: NWPath) { let statusChanged = path.status != lastPathStatus let interfaceTypes = Set(path.availableInterfaces.map { $0.type }) - let interfacesChanged = interfaceTypes != lastInterfaceTypes + let interfaceNames = Set(path.availableInterfaces.map { $0.name }) + let typesChanged = interfaceTypes != lastInterfaceTypes + let namesChanged = interfaceNames != lastInterfaceNames - // Detect significant network changes - let isSignificantChange = statusChanged || interfacesChanged + let isSignificantChange = statusChanged || typesChanged || namesChanged if isSignificantChange { lastPathStatus = path.status lastInterfaceTypes = interfaceTypes + lastInterfaceNames = interfaceNames Task { @MainActor in - // Log the network change let statusStr = path.status == .satisfied ? "connected" : "disconnected" - let interfaceStr = interfaceTypes.map { interfaceTypeName($0) }.joined(separator: ", ") + let interfaceStr = interfaceNames.sorted().joined(separator: ", ") RouteManager.shared.log(.info, "Network change detected: \(statusStr) via \(interfaceStr)") - // Refresh VPN status RouteManager.shared.refreshStatus() } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 769fe2a..12e04ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to VPN Bypass will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.1] - 2026-02-25 + +### Fixed +- **Stale Gateway on Domain Addition** - Adding/toggling domains now re-detects the local gateway if stale, instead of silently failing when VPN switches interfaces +- **VPN Interface Switch Not Handled** - Routes are now automatically re-applied when VPN hops interfaces (e.g., utun4 → utun5) while staying connected +- **Network Monitor Missing VPN Changes** - NWPathMonitor now tracks individual interface names, catching VPN interface switches that type-only comparison missed + +### Improved +- **No More Silent Failures** - All gateway-dependent actions now log explicit errors when no gateway is available, instead of silently skipping route application +- **Fresh Gateway in All User Actions** - `addDomain`, `toggleDomain`, `toggleService`, `setAllDomainsEnabled`, `setAllServicesEnabled`, and DNS retry all use fresh gateway detection + ## [1.8.0] - 2026-02-25 ### Added From d02aaf6e50341b7e09f7cd6cc1f2a6815578cdc9 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:54:21 +0100 Subject: [PATCH 2/2] fix: harden gateway detection, concurrency, and network monitoring - Reset lastInterfaceNames in restartNetworkMonitor() (was missing after PR addition) - Fix data race on networkDebounceWorkItem by moving access to main thread - Guard interface != nil in VPN switch handler to prevent spurious re-routes - Make ensureGateway() time-aware (re-detect if >10s stale, not just nil) - Add 10s cooldown for VPN interface flapping to prevent rapid re-route storms - Validate gateway as IPv4 in getGatewayForService and parseDefaultGateway - Replace isApplyingRoutes Bool with ref-counted begin/endApplyingRoutes() - Replace force-unwrap in ensureGateway() with safe if-let binding - Bump CFBundleVersion to 20 - Revert Cask version to 1.8.0 (let make full-release handle version+SHA atomically) --- Casks/vpn-bypass.rb | 2 +- Info.plist | 2 +- Sources/RouteManager.swift | 105 ++++++++++++++++++++++--------------- Sources/VPNBypassApp.swift | 25 ++++----- 4 files changed, 79 insertions(+), 55 deletions(-) diff --git a/Casks/vpn-bypass.rb b/Casks/vpn-bypass.rb index 737ab88..10bd75d 100644 --- a/Casks/vpn-bypass.rb +++ b/Casks/vpn-bypass.rb @@ -3,7 +3,7 @@ # Or if using local tap: brew install --cask --no-quarantine ./Casks/vpn-bypass.rb cask "vpn-bypass" do - version "1.8.1" + version "1.8.0" sha256 "37b127a55aec0bdb80e824e59e840ce5b529c09086aac7fc24dc4616abb817bd" url "https://github.com/GeiserX/VPN-Bypass/releases/download/v#{version}/VPN-Bypass-#{version}.dmg" diff --git a/Info.plist b/Info.plist index 107eb8a..9eb4702 100644 --- a/Info.plist +++ b/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 1.8.1 CFBundleVersion - 19 + 20 LSMinimumSystemVersion 13.0 LSUIElement diff --git a/Sources/RouteManager.swift b/Sources/RouteManager.swift index 12c3dc7..c7c8533 100644 --- a/Sources/RouteManager.swift +++ b/Sources/RouteManager.swift @@ -27,7 +27,8 @@ final class RouteManager: ObservableObject { @Published var currentNetworkSSID: String? @Published var routeVerificationResults: [String: RouteVerificationResult] = [:] @Published var isLoading = true - @Published var isApplyingRoutes = false // True during incremental route changes (blocks UI) + @Published private(set) var isApplyingRoutes = false + private var applyingRoutesCount = 0 @Published var lastDNSRefresh: Date? @Published var nextDNSRefresh: Date? @Published var isTestingProxy = false @@ -44,6 +45,8 @@ final class RouteManager: ObservableObject { private var detectedDNSServer: String? // User's real DNS (pre-VPN), detected at startup private var dnsCache: [String: String] = [:] // Cache: domain -> first resolved IP (for hosts file) private var dnsDiskCache: [String: [String]] = [:] // Persistent cache: domain -> all resolved IPs + private var gatewayDetectedAt: Date? + private var lastInterfaceReroute: Date? private var dnsCacheURL: URL { @@ -582,6 +585,7 @@ final class RouteManager: ObservableObject { // Detect local gateway localGateway = await detectLocalGateway() + gatewayDetectedAt = localGateway != nil ? Date() : nil // Auto-apply routes when VPN connects (skip if already applying or recently applied) if isVPNConnected && !wasVPNConnected && config.autoApplyOnVPN && !isLoading && !isApplyingRoutes { @@ -600,15 +604,20 @@ final class RouteManager: ObservableObject { } // VPN interface switched while still connected — re-route through new gateway - if isVPNConnected && wasVPNConnected && interface != oldInterface && oldInterface != nil && !isLoading && !isApplyingRoutes { - log(.warning, "VPN interface changed: \(oldInterface ?? "?") → \(interface ?? "?")") - if localGateway != nil { - isLoading = true - await removeAllRoutes() - await applyAllRoutes() - isLoading = false + if isVPNConnected && wasVPNConnected && interface != oldInterface && oldInterface != nil && interface != nil && !isLoading && !isApplyingRoutes { + if let last = lastInterfaceReroute, Date().timeIntervalSince(last) < 10 { + log(.info, "Skipping interface re-route (cooldown, last was \(Int(Date().timeIntervalSince(last)))s ago)") } else { - log(.error, "VPN interface changed but no gateway detected") + log(.warning, "VPN interface changed: \(oldInterface ?? "?") → \(interface ?? "?")") + lastInterfaceReroute = Date() + if localGateway != nil { + isLoading = true + await removeAllRoutes() + await applyAllRoutes() + isLoading = false + } else { + log(.error, "VPN interface changed but no gateway detected") + } } } @@ -1359,7 +1368,7 @@ final class RouteManager: ObservableObject { } log(.info, "Auto DNS refresh: re-resolving domains...") - isApplyingRoutes = true + beginApplyingRoutes() var updatedCount = 0 var newIPs: Set = [] @@ -1434,7 +1443,7 @@ final class RouteManager: ObservableObject { lastDNSRefresh = Date() nextDNSRefresh = Date().addingTimeInterval(config.dnsRefreshInterval) - isApplyingRoutes = false + endApplyingRoutes() if updatedCount > 0 || removedCount > 0 { log(.success, "DNS refresh complete: \(updatedCount) added, \(removedCount) removed") @@ -1509,11 +1518,11 @@ final class RouteManager: ObservableObject { log(.success, "Added domain: \(cleaned)") if isVPNConnected { - isApplyingRoutes = true + beginApplyingRoutes() Task { guard let gateway = await ensureGateway() else { log(.error, "Cannot route \(cleaned): no local gateway detected. Try Refresh Routes.") - isApplyingRoutes = false + endApplyingRoutes() return } if let routes = await applyRoutesForDomain(cleaned, gateway: gateway) { @@ -1525,7 +1534,7 @@ final class RouteManager: ObservableObject { log(.warning, "DNS resolution failed for \(cleaned), retrying in 15s...") scheduleRetry(for: cleaned) } - isApplyingRoutes = false + endApplyingRoutes() } } } @@ -1556,8 +1565,8 @@ final class RouteManager: ObservableObject { return } - isApplyingRoutes = true - defer { isApplyingRoutes = false } + beginApplyingRoutes() + defer { endApplyingRoutes() } log(.info, "Retrying DNS for \(domain)...") if let routes = await applyRoutesForDomain(domain, gateway: gateway) { @@ -1580,7 +1589,7 @@ final class RouteManager: ObservableObject { pendingRetryTasks[domain.domain]?.cancel() pendingRetryTasks.removeValue(forKey: domain.domain) - isApplyingRoutes = true + beginApplyingRoutes() Task { await removeRoutesForSource(domain.domain) @@ -1588,7 +1597,7 @@ final class RouteManager: ObservableObject { config.domains.removeAll { $0.id == domain.id } saveConfig() log(.info, "Removed domain: \(domain.domain)") - isApplyingRoutes = false + endApplyingRoutes() } } @@ -1602,12 +1611,12 @@ final class RouteManager: ObservableObject { log(.info, "\(domain.domain) \(domain.enabled ? "enabled" : "disabled")") if isVPNConnected { - isApplyingRoutes = true + beginApplyingRoutes() Task { if domain.enabled { guard let gateway = await ensureGateway() else { log(.error, "Cannot route \(domain.domain): no local gateway detected") - isApplyingRoutes = false + endApplyingRoutes() return } if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway) { @@ -1619,14 +1628,14 @@ final class RouteManager: ObservableObject { } else { await removeRoutesForSource(domain.domain) } - isApplyingRoutes = false + endApplyingRoutes() } } } /// Bulk enable/disable all domains with loading state (incremental) func setAllDomainsEnabled(_ enabled: Bool) { - isApplyingRoutes = true + beginApplyingRoutes() // Get domains that need to change let domainsToChange = config.domains.filter { $0.enabled != enabled } @@ -1644,7 +1653,7 @@ final class RouteManager: ObservableObject { let gateway: String? = enabled ? await ensureGateway() : nil if enabled && gateway == nil { log(.error, "Cannot enable domains: no local gateway detected") - isApplyingRoutes = false + endApplyingRoutes() return } for domain in domainsToChange { @@ -1660,10 +1669,10 @@ final class RouteManager: ObservableObject { if enabled && config.manageHostsFile { await updateHostsFile() } - isApplyingRoutes = false + endApplyingRoutes() } } else { - isApplyingRoutes = false + endApplyingRoutes() } } @@ -1694,21 +1703,19 @@ final class RouteManager: ObservableObject { // Incremental route apply/remove if isVPNConnected { - isApplyingRoutes = true + beginApplyingRoutes() Task { if service.enabled { guard let gateway = await ensureGateway() else { log(.error, "Cannot route \(service.name): no local gateway detected") - isApplyingRoutes = false + endApplyingRoutes() return } await applyRoutesForService(service, gateway: gateway) } else { await removeRoutesForSource(service.name) } - await MainActor.run { - isApplyingRoutes = false - } + endApplyingRoutes() } } } @@ -1784,7 +1791,7 @@ final class RouteManager: ObservableObject { /// Bulk enable/disable all services with loading state (incremental) func setAllServicesEnabled(_ enabled: Bool) { - isApplyingRoutes = true + beginApplyingRoutes() // Get services that need to change let servicesToChange = config.services.filter { $0.enabled != enabled } @@ -1803,7 +1810,7 @@ final class RouteManager: ObservableObject { let gateway: String? = enabled ? await ensureGateway() : nil if enabled && gateway == nil { log(.error, "Cannot enable services: no local gateway detected") - isApplyingRoutes = false + endApplyingRoutes() return } for service in servicesToChange { @@ -1813,12 +1820,10 @@ final class RouteManager: ObservableObject { await removeRoutesForSource(service.name) } } - await MainActor.run { - isApplyingRoutes = false - } + endApplyingRoutes() } } else { - isApplyingRoutes = false + endApplyingRoutes() } } @@ -1899,11 +1904,26 @@ final class RouteManager: ObservableObject { // MARK: - Private Methods + private func beginApplyingRoutes() { + applyingRoutesCount += 1 + if !isApplyingRoutes { isApplyingRoutes = true } + } + + private func endApplyingRoutes() { + applyingRoutesCount = max(0, applyingRoutesCount - 1) + if applyingRoutesCount == 0 { isApplyingRoutes = false } + } + private func ensureGateway() async -> String? { - if let gw = localGateway { return gw } + if let gw = localGateway, + let detected = gatewayDetectedAt, + Date().timeIntervalSince(detected) < 10 { + return gw + } localGateway = await detectLocalGateway() - if localGateway != nil { - log(.info, "Gateway re-detected: \(localGateway!)") + gatewayDetectedAt = localGateway != nil ? Date() : nil + if let gw = localGateway { + log(.info, "Gateway re-detected: \(gw)") } return localGateway } @@ -1930,7 +1950,7 @@ final class RouteManager: ObservableObject { for line in result.output.components(separatedBy: "\n") { if line.hasPrefix("Router:") { let gateway = line.replacingOccurrences(of: "Router:", with: "").trimmingCharacters(in: .whitespaces) - if gateway != "none" && !gateway.isEmpty { + if gateway != "none" && !gateway.isEmpty && isValidIP(gateway) { return gateway } } @@ -2009,7 +2029,10 @@ final class RouteManager: ObservableObject { if line.contains("gateway:") { let parts = line.components(separatedBy: ":") if parts.count >= 2 { - return parts[1].trimmingCharacters(in: .whitespaces) + let gateway = parts[1].trimmingCharacters(in: .whitespaces) + if isValidIP(gateway) { + return gateway + } } } } diff --git a/Sources/VPNBypassApp.swift b/Sources/VPNBypassApp.swift index beea4f5..fa7da3e 100644 --- a/Sources/VPNBypassApp.swift +++ b/Sources/VPNBypassApp.swift @@ -109,19 +109,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { networkMonitor = NWPathMonitor() networkMonitor?.pathUpdateHandler = { [weak self] path in - guard let self = self else { return } - - // Debounce rapid network changes - self.networkDebounceWorkItem?.cancel() - - let workItem = DispatchWorkItem { [weak self] in - self?.handleNetworkChange(path) + // All access to networkDebounceWorkItem must happen on the main thread + DispatchQueue.main.async { + guard let self = self else { return } + + self.networkDebounceWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + self?.handleNetworkChange(path) + } + + self.networkDebounceWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) } - - self.networkDebounceWorkItem = workItem - - // Wait 1 second before processing to avoid rapid fire during network transitions - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) } networkMonitor?.start(queue: DispatchQueue(label: "NetworkMonitor")) @@ -214,6 +214,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Reset state lastPathStatus = nil lastInterfaceTypes = [] + lastInterfaceNames = [] networkDebounceWorkItem?.cancel() networkDebounceWorkItem = nil