Skip to content

Commit c29a3ef

Browse files
committed
fix: suppress spurious VPN disconnect/reconnect notifications
NWPathMonitor and ifconfig can momentarily miss the VPN interface during network transitions, causing a false disconnect→reconnect cycle with duplicate notifications. Now rechecks after 1.5s before committing to a disconnect state.
1 parent c1ce9ef commit c29a3ef

5 files changed

Lines changed: 27 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ After CI completes: `brew update && brew upgrade --cask vpn-bypass` to install l
9292
- **CI handles releases end-to-end** — pushing a `v*` tag triggers `.github/workflows/release.yml` which builds the DMG, creates the GitHub release, AND updates the Homebrew cask. Do NOT manually create releases or update the cask — CI will overwrite them. Just commit, tag, push.
9393
- **Test the stale-helper upgrade path after release** — especially with VPN already connected and an older helper still installed. Expected flow: helper preflight on startup, admin prompt if needed, helper update, route apply, and DNS refresh timer start automatically.
9494
- **Some VPNs route via interface link, not IP gateway** — Cisco Secure Client sets the default route to `link#N` (an interface reference) instead of an IP address. `route -n get default` shows `interface: utunX` with no `gateway:` line. VPN Only mode handles this via `iface:<interface>` convention: the helper uses `route add -host <dest> -interface utunX` instead of an IP gateway. See #26.
95+
- **VPN interface flaps cause spurious notifications.** `NWPathMonitor` and `ifconfig` can momentarily miss the VPN interface during network transitions. `checkVPNStatus()` must recheck after a short delay (1.5s) before committing to a disconnect state, otherwise the app fires false disconnect→reconnect notifications.
9596
- **Wildcard domains (`*.example.com`) are impossible at the macOS routing level.** macOS routing tables are IP-based — you can only route specific IPs or CIDR ranges, not domain patterns. `/etc/hosts` also does not support wildcards. Any wildcard implementation can only resolve the base domain's IPs, not actual subdomains with different IPs. Don't reintroduce this feature.
9697
- **Helper launchd plist MUST have `RunAtLoad: true`** — without it, the daemon relies on on-demand XPC activation, which macOS blocks when the Login Items toggle is disabled. Homebrew cask upgrades re-sign the app, causing macOS to reset the toggle, which re-breaks the helper on every boot. `RunAtLoad: true` makes the daemon start unconditionally — no Login Items dependency. NEVER set `RunAtLoad` back to `false`. See #25.
9798

Casks/vpn-bypass.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Or if using local tap: brew install --cask --no-quarantine ./Casks/vpn-bypass.rb
44

55
cask "vpn-bypass" do
6-
version "2.6.1"
6+
version "2.6.2"
77
sha256 "8da2ba2e2073f8dbcd9d413658e995d244b52adc94559aecc31690448a9acf42"
88

99
url "https://github.com/GeiserX/VPN-Bypass/releases/download/v#{version}/VPN-Bypass-#{version}.dmg"

Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<key>CFBundlePackageType</key>
2424
<string>APPL</string>
2525
<key>CFBundleShortVersionString</key>
26-
<string>2.6.1</string><!-- VERSION -->
26+
<string>2.6.2</string><!-- VERSION -->
2727
<key>CFBundleVersion</key>
2828
<string>22</string>
2929
<key>LSMinimumSystemVersion</key>

Sources/RouteManager.swift

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -706,13 +706,28 @@ final class RouteManager: ObservableObject {
706706
let wasVPNConnected = isVPNConnected
707707
let oldInterface = vpnInterface
708708
let oldTailscaleFingerprint = lastTailscaleSelfFingerprint
709-
709+
710710
// Detect current network first
711711
await detectCurrentNetwork()
712-
712+
713713
// Use scutil to detect VPN interfaces with IPv4 addresses
714-
let (connected, interface, detectedType) = await detectVPNInterface()
715-
714+
var (connected, interface, detectedType) = await detectVPNInterface()
715+
716+
// Guard against transient VPN interface flaps: if VPN was connected but
717+
// now appears disconnected, recheck after a short delay before committing.
718+
// NWPathMonitor and ifconfig can momentarily miss the interface during
719+
// network transitions, causing spurious disconnect→reconnect notifications.
720+
if wasVPNConnected && !connected {
721+
try? await Task.sleep(nanoseconds: 1_500_000_000)
722+
let (recheckConnected, recheckInterface, recheckType) = await detectVPNInterface()
723+
if recheckConnected {
724+
log(.info, "VPN flap suppressed — interface reappeared after recheck")
725+
connected = recheckConnected
726+
interface = recheckInterface
727+
detectedType = recheckType
728+
}
729+
}
730+
716731
isVPNConnected = connected
717732
vpnInterface = connected ? interface : nil
718733
vpnType = connected ? detectedType : nil

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to VPN Bypass will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.6.2] - 2026-05-03
9+
10+
### Fixed
11+
- **Spurious VPN notifications** — Suppress transient VPN interface flaps that caused repeated "VPN Connected" notifications while VPN was still active. Now rechecks after 1.5s before committing to a disconnect state.
12+
813
## [2.6.1] - 2026-05-03
914

1015
### Removed

0 commit comments

Comments
 (0)