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