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 @@
-
+
## 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