diff --git a/Info.plist b/Info.plist index 6709a02..9eb4702 100644 --- a/Info.plist +++ b/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.8.0 + 1.8.1 CFBundleVersion - 19 + 20 LSMinimumSystemVersion 13.0 LSUIElement 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..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 { @@ -599,7 +603,25 @@ 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 && 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(.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") + } + } + } + + if !isVPNConnected && wasVPNConnected { log(.warning, "VPN disconnected (was: \(oldInterface ?? "unknown"))") NotificationManager.shared.notifyVPNDisconnected(wasInterface: oldInterface) cancelAllRetries() @@ -1116,7 +1138,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 +1210,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,13 +1362,13 @@ 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 } log(.info, "Auto DNS refresh: re-resolving domains...") - isApplyingRoutes = true + beginApplyingRoutes() var updatedCount = 0 var newIPs: Set = [] @@ -1415,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") @@ -1489,9 +1517,14 @@ final class RouteManager: ObservableObject { saveConfig() log(.success, "Added domain: \(cleaned)") - if isVPNConnected, let gateway = localGateway { - isApplyingRoutes = true + if isVPNConnected { + beginApplyingRoutes() Task { + guard let gateway = await ensureGateway() else { + log(.error, "Cannot route \(cleaned): no local gateway detected. Try Refresh Routes.") + endApplyingRoutes() + return + } if let routes = await applyRoutesForDomain(cleaned, gateway: gateway) { activeRoutes.append(contentsOf: routes) if config.manageHostsFile { @@ -1501,7 +1534,7 @@ final class RouteManager: ObservableObject { log(.warning, "DNS resolution failed for \(cleaned), retrying in 15s...") scheduleRetry(for: cleaned) } - isApplyingRoutes = false + endApplyingRoutes() } } } @@ -1522,14 +1555,18 @@ 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 } - isApplyingRoutes = true - defer { isApplyingRoutes = false } + beginApplyingRoutes() + defer { endApplyingRoutes() } log(.info, "Retrying DNS for \(domain)...") if let routes = await applyRoutesForDomain(domain, gateway: gateway) { @@ -1552,7 +1589,7 @@ final class RouteManager: ObservableObject { pendingRetryTasks[domain.domain]?.cancel() pendingRetryTasks.removeValue(forKey: domain.domain) - isApplyingRoutes = true + beginApplyingRoutes() Task { await removeRoutesForSource(domain.domain) @@ -1560,7 +1597,7 @@ final class RouteManager: ObservableObject { config.domains.removeAll { $0.id == domain.id } saveConfig() log(.info, "Removed domain: \(domain.domain)") - isApplyingRoutes = false + endApplyingRoutes() } } @@ -1573,10 +1610,15 @@ final class RouteManager: ObservableObject { let domain = config.domains[index] log(.info, "\(domain.domain) \(domain.enabled ? "enabled" : "disabled")") - if isVPNConnected, let gateway = localGateway { - isApplyingRoutes = true + if isVPNConnected { + beginApplyingRoutes() Task { if domain.enabled { + guard let gateway = await ensureGateway() else { + log(.error, "Cannot route \(domain.domain): no local gateway detected") + endApplyingRoutes() + return + } if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway) { activeRoutes.append(contentsOf: routes) if config.manageHostsFile { @@ -1586,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 } @@ -1606,14 +1648,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") + endApplyingRoutes() + 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) } } @@ -1621,10 +1669,10 @@ final class RouteManager: ObservableObject { if enabled && config.manageHostsFile { await updateHostsFile() } - isApplyingRoutes = false + endApplyingRoutes() } } else { - isApplyingRoutes = false + endApplyingRoutes() } } @@ -1654,19 +1702,20 @@ final class RouteManager: ObservableObject { log(.info, "\(service.name) \(service.enabled ? "enabled" : "disabled")") // Incremental route apply/remove - if isVPNConnected, let gateway = localGateway { - isApplyingRoutes = true + if isVPNConnected { + beginApplyingRoutes() 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") + endApplyingRoutes() + return + } await applyRoutesForService(service, gateway: gateway) } else { - // Service was just disabled - remove its routes await removeRoutesForSource(service.name) } - await MainActor.run { - isApplyingRoutes = false - } + endApplyingRoutes() } } } @@ -1742,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 } @@ -1756,21 +1805,25 @@ 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") + endApplyingRoutes() + 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) } } - await MainActor.run { - isApplyingRoutes = false - } + endApplyingRoutes() } } else { - isApplyingRoutes = false + endApplyingRoutes() } } @@ -1851,6 +1904,30 @@ 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, + let detected = gatewayDetectedAt, + Date().timeIntervalSince(detected) < 10 { + return gw + } + localGateway = await detectLocalGateway() + gatewayDetectedAt = localGateway != nil ? Date() : nil + if let gw = localGateway { + log(.info, "Gateway re-detected: \(gw)") + } + 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"] @@ -1873,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 } } @@ -1952,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/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..fa7da3e 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() @@ -108,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")) @@ -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() } } @@ -213,6 +214,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Reset state lastPathStatus = nil lastInterfaceTypes = [] + lastInterfaceNames = [] networkDebounceWorkItem?.cancel() networkDebounceWorkItem = nil 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