From 77394504eec131a4980cd98e02c46903156efaba Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:40:59 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20UI=20overhaul=20=E2=80=94=20normal?= =?UTF-8?q?=20window,=20official=20logo,=20larger=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings: NSPanel → NSWindow with standard close/minimize/zoom buttons and resizable behavior. Removed screenSaver level and floating panel. - Menu bar: use official template icon (menubar-icon.png) instead of SF Symbol shield. Pulsing animation preserved for loading state. - Dropdown: use official 3D logo (VPNBypass.png) in title header. - Settings titlebar: use official logo instead of SF Symbol. - Tab bar: larger font (13pt), rounded rectangle shape, more top padding to clear titlebar traffic lights. - Makefile: copy menubar-icon PNGs to app bundle Resources. --- Makefile | 2 + Sources/MenuBarViews.swift | 46 +++++++++---- Sources/SettingsView.swift | 137 ++++++++++++++++++------------------- 3 files changed, 103 insertions(+), 82 deletions(-) diff --git a/Makefile b/Makefile index c0b4ff6..e5eeee7 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,8 @@ bundle: build build-helper @cp assets/AppIcon.icns "$(APP_BUNDLE)/Contents/Resources/" @cp assets/VPNBypass.png "$(APP_BUNDLE)/Contents/Resources/" @cp assets/author-avatar.png "$(APP_BUNDLE)/Contents/Resources/" + @cp assets/menubar-icon.png "$(APP_BUNDLE)/Contents/Resources/" + @cp assets/menubar-icon@2x.png "$(APP_BUNDLE)/Contents/Resources/" @echo "App bundle created: $(APP_BUNDLE)" clean: diff --git a/Sources/MenuBarViews.swift b/Sources/MenuBarViews.swift index cb53a22..1256eae 100644 --- a/Sources/MenuBarViews.swift +++ b/Sources/MenuBarViews.swift @@ -38,15 +38,28 @@ struct BrandColors { struct MenuBarLabel: View { @EnvironmentObject var routeManager: RouteManager @State private var isAnimating = false - + + private var menuBarIcon: some View { + Group { + if let iconPath = Bundle.main.path(forResource: "menubar-icon", ofType: "png"), + let nsImage = NSImage(contentsOfFile: iconPath) { + let _ = nsImage.isTemplate = true + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + } else { + Image(systemName: routeManager.isVPNConnected ? "shield.checkered" : "shield") + .font(.system(size: 15)) + } + } + } + var body: some View { HStack(spacing: 3) { - // Shield icon - simple and clear at menu bar size ZStack { if routeManager.isLoading || routeManager.isApplyingRoutes { - // Pulsing shield when loading - Image(systemName: "shield.fill") - .font(.system(size: 15)) + menuBarIcon .opacity(isAnimating ? 0.4 : 1.0) .animation( Animation.easeInOut(duration: 0.5) @@ -56,11 +69,10 @@ struct MenuBarLabel: View { .onAppear { isAnimating = true } .onDisappear { isAnimating = false } } else { - Image(systemName: routeManager.isVPNConnected ? "shield.checkered" : "shield") - .font(.system(size: 15)) + menuBarIcon } } - + // Active routes count when VPN connected and not loading if routeManager.isVPNConnected && !routeManager.activeRoutes.isEmpty && !routeManager.isLoading && !routeManager.isApplyingRoutes { Text("\(routeManager.uniqueRouteCount)") @@ -145,11 +157,19 @@ struct MenuContent: View { private var titleHeader: some View { HStack(spacing: 8) { - // Shield icon with brand color - Image(systemName: "shield.checkered") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(BrandColors.blueGradient) - + // App logo from bundle + if let logoPath = Bundle.main.path(forResource: "VPNBypass", ofType: "png"), + let nsImage = NSImage(contentsOfFile: logoPath) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 22, height: 22) + } else { + Image(systemName: "shield.checkered") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(BrandColors.blueGradient) + } + // App name with branded colors BrandedAppName(fontSize: 15) diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 6e840e3..9b9adaf 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -32,7 +32,7 @@ struct SettingsView: View { private var headerView: some View { VStack(spacing: 0) { // Tab bar with pill selector - HStack(spacing: 4) { + HStack(spacing: 6) { ForEach(0..<5) { index in TabItem( index: index, @@ -45,9 +45,9 @@ struct SettingsView: View { } } .padding(.horizontal, 16) - .padding(.top, 8) + .padding(.top, 36) // Space for titlebar traffic lights .padding(.bottom, 16) - + // Subtle separator Rectangle() .fill( @@ -100,19 +100,19 @@ struct TabItem: View { var body: some View { Button(action: action) { - HStack(spacing: 8) { + HStack(spacing: 6) { Image(systemName: icon) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 13, weight: .medium)) Text(title) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 13, weight: .medium)) } .foregroundColor(isSelected ? .white : Color(hex: "71717A")) - .padding(.horizontal, 16) - .padding(.vertical, 10) + .padding(.horizontal, 14) + .padding(.vertical, 8) .background( Group { if isSelected { - Capsule() + RoundedRectangle(cornerRadius: 8, style: .continuous) .fill( LinearGradient( colors: [Color(hex: "10B981"), Color(hex: "059669")], @@ -122,12 +122,12 @@ struct TabItem: View { ) .shadow(color: Color(hex: "10B981").opacity(0.4), radius: 8, y: 2) } else if isHovered { - Capsule() + RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.white.opacity(0.08)) } } ) - .contentShape(Capsule()) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } .buttonStyle(.plain) .onHover { hovering in @@ -2419,83 +2419,74 @@ struct LogRow: View { @MainActor final class SettingsWindowController { static let shared = SettingsWindowController() - - private var panel: NSPanel? - + + private var window: NSWindow? + func show() { - showPanel() + showWindow() } - + func showOnTop() { - showPanel() + showWindow() } - - private func showPanel() { - // If panel exists and is visible, just bring it to front - if let panel = panel, panel.isVisible { - panel.level = .screenSaver - panel.orderFrontRegardless() + + private func showWindow() { + // If window exists and is visible, just bring it to front + if let window = window, window.isVisible { + window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) return } - - // If panel was closed, remove it so we create a fresh one - if panel != nil && !panel!.isVisible { - panel = nil + + // If window was closed, remove it so we create a fresh one + if window != nil && !window!.isVisible { + window = nil } - + let settingsView = SettingsView() .environmentObject(RouteManager.shared) .environmentObject(NotificationManager.shared) .environmentObject(LaunchAtLoginManager.shared) let hostingView = NSHostingView(rootView: settingsView) - - // Use NSPanel which can float above other windows - let panel = NSPanel( + + let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 580, height: 620), - styleMask: [.titled, .closable, .fullSizeContentView, .utilityWindow], + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) - - panel.contentView = hostingView - panel.title = "" // Empty title, we use custom view - panel.titlebarAppearsTransparent = true - panel.titleVisibility = .hidden - panel.backgroundColor = NSColor(Color(hex: "0F0F14")) - panel.isReleasedWhenClosed = false - panel.center() - - // Add branded title to titlebar - addBrandedTitlebar(to: panel) - - // Make it float above EVERYTHING - use screenSaver level (highest) - panel.level = .screenSaver - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - panel.isFloatingPanel = true - panel.hidesOnDeactivate = false - - panel.orderFrontRegardless() + + window.contentView = hostingView + window.title = "VPN Bypass" + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.backgroundColor = NSColor(Color(hex: "0F0F14")) + window.isReleasedWhenClosed = false + window.minSize = NSSize(width: 500, height: 480) + window.center() + + // Add branded titlebar accessory + addBrandedTitlebar(to: window) + + // Bring to front (normal level — not floating/screenSaver) + window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - - self.panel = panel + + self.window = window } - + private func addBrandedTitlebar(to window: NSWindow) { - // Create a container view that spans the full titlebar width let containerView = NSView(frame: NSRect(x: 0, y: 0, width: window.frame.width, height: 28)) - - // Create the branded title view + let titleView = NSHostingView(rootView: BrandedTitlebarView()) titleView.frame = containerView.bounds titleView.autoresizingMask = [.width, .height] containerView.addSubview(titleView) - - // Create accessory view controller - use .right to stay in titlebar row + let accessory = NSTitlebarAccessoryViewController() accessory.view = containerView accessory.layoutAttribute = .right - + window.addTitlebarAccessoryViewController(accessory) } } @@ -2506,25 +2497,33 @@ struct BrandedTitlebarView: View { var body: some View { HStack { Spacer() - - HStack(spacing: 5) { - // Shield icon - Image(systemName: "shield.checkered") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(BrandColors.blueGradient) - + + HStack(spacing: 6) { + // App logo from bundle + if let logoPath = Bundle.main.path(forResource: "VPNBypass", ofType: "png"), + let nsImage = NSImage(contentsOfFile: logoPath) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + } else { + Image(systemName: "shield.checkered") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(BrandColors.blueGradient) + } + // Branded name HStack(spacing: 0) { Text("VPN") .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(BrandColors.blueGradient) - + Text("Bypass") .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(BrandColors.silverGradient) } } - + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) From c770915c085720b1fa2fb0944806fc71071e14c9 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:13:48 +0200 Subject: [PATCH 2/8] fix: reuse minimized window, remove broken resizable, add close delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - showWindow() now deminiaturizes and reuses existing window instead of discarding it when !isVisible (miniaturized windows aren't visible) - Added NSWindowDelegate with windowWillClose to nil the reference only when the user actually closes the window (red X) - Removed .resizable from styleMask since the SwiftUI layout is fixed at 580x620 — set contentMinSize/contentMaxSize to match --- Sources/SettingsView.swift | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 9b9adaf..36781a9 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -2417,7 +2417,7 @@ struct LogRow: View { // MARK: - Settings Window Controller @MainActor -final class SettingsWindowController { +final class SettingsWindowController: NSObject, NSWindowDelegate { static let shared = SettingsWindowController() private var window: NSWindow? @@ -2431,18 +2431,16 @@ final class SettingsWindowController { } private func showWindow() { - // If window exists and is visible, just bring it to front - if let window = window, window.isVisible { + // If window exists (visible, minimized, or offscreen), reuse it + if let window = window { + if window.isMiniaturized { + window.deminiaturize(nil) + } window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) return } - // If window was closed, remove it so we create a fresh one - if window != nil && !window!.isVisible { - window = nil - } - let settingsView = SettingsView() .environmentObject(RouteManager.shared) .environmentObject(NotificationManager.shared) @@ -2451,7 +2449,7 @@ final class SettingsWindowController { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 580, height: 620), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], backing: .buffered, defer: false ) @@ -2462,7 +2460,9 @@ final class SettingsWindowController { window.titleVisibility = .hidden window.backgroundColor = NSColor(Color(hex: "0F0F14")) window.isReleasedWhenClosed = false - window.minSize = NSSize(width: 500, height: 480) + window.contentMinSize = NSSize(width: 580, height: 620) + window.contentMaxSize = NSSize(width: 580, height: 620) + window.delegate = self window.center() // Add branded titlebar accessory @@ -2475,6 +2475,10 @@ final class SettingsWindowController { self.window = window } + func windowWillClose(_ notification: Notification) { + window = nil + } + private func addBrandedTitlebar(to window: NSWindow) { let containerView = NSView(frame: NSRect(x: 0, y: 0, width: window.frame.width, height: 28)) From 9d95a13efa9168b36c327b0bdaaa9d7b63233722 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:30:20 +0200 Subject: [PATCH 3/8] fix: helper startup race and XPC hang on version mismatch Root cause: after a Homebrew upgrade, the installed helper binary stays at the old version. Route application starts 0.5s after launch, but helper version check was scheduled 2s later. XPC calls to the old helper with new method signatures hang forever (no error handler, no timeout), leaving the app stuck at "Setting Up" indefinitely. Changes: - Add ensureHelperReady() preflight that MUST complete before any route application. Verifies helper files exist, connects with timeout to check version, auto-updates if mismatched, and retries. - Replace isHelperInstalled boolean with HelperState enum: missing, checking, installing, outdated, ready, failed. - All XPC RPCs now use remoteObjectProxyWithErrorHandler (not bare remoteObjectProxy) so protocol mismatches fire the error handler instead of silently hanging. - All XPC RPCs have a timeout (10s base + 0.1s per route for batches). On timeout, the XPC connection is dropped and an error is returned. - Startup ordering: VPNBypassApp awaits ensureHelperReady() before detectAndApplyRoutesAsync(), eliminating the race. - NSApp.activate before admin prompt so the authorization dialog appears on top. - Settings UI shows proper state: "Update Required", "Checking...", "Error: ..." with contextual action buttons (Install/Update/Retry). --- Sources/HelperManager.swift | 532 +++++++++++++++++++++++------------- Sources/SettingsView.swift | 88 ++++-- Sources/VPNBypassApp.swift | 18 +- 3 files changed, 423 insertions(+), 215 deletions(-) diff --git a/Sources/HelperManager.swift b/Sources/HelperManager.swift index a0c7647..44e4b64 100644 --- a/Sources/HelperManager.swift +++ b/Sources/HelperManager.swift @@ -1,296 +1,383 @@ // HelperManager.swift // Manages installation and communication with the privileged helper tool. +import AppKit import Foundation import ServiceManagement import Security +// MARK: - Helper State + +enum HelperState: Equatable { + case missing + case checking + case installing + case outdated(installed: String, expected: String) + case ready + case failed(String) + + var isReady: Bool { self == .ready } + + var statusText: String { + switch self { + case .missing: return "Not Installed" + case .checking: return "Checking..." + case .installing: return "Installing..." + case .outdated(let installed, let expected): return "Update Required (v\(installed) → v\(expected))" + case .ready: return "Helper Installed" + case .failed(let msg): return "Error: \(msg)" + } + } +} + @MainActor final class HelperManager: ObservableObject { static let shared = HelperManager() - - @Published var isHelperInstalled = false + + @Published var helperState: HelperState = .checking @Published var helperVersion: String? @Published var installationError: String? @Published var isInstalling = false - + + /// Backwards-compatible computed property used by RouteManager + var isHelperInstalled: Bool { helperState.isReady } + private var xpcConnection: NSXPCConnection? private let hasPromptedKey = "HasPromptedHelperInstall" - + + /// XPC timeout for all helper RPCs (seconds) + private let xpcTimeout: TimeInterval = 10 + private init() { - checkHelperStatus() - // Auto-install on first launch after a short delay to let UI load - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - Task { @MainActor in - self.autoInstallOnFirstLaunch() - } + // Only set initial state — do NOT start route application here. + // The app must call ensureHelperReady() before using helper RPCs. + let helperPath = "/Library/PrivilegedHelperTools/\(kHelperToolMachServiceName)" + let plistPath = "/Library/LaunchDaemons/\(kHelperToolMachServiceName).plist" + if FileManager.default.fileExists(atPath: helperPath) && + FileManager.default.fileExists(atPath: plistPath) { + helperState = .checking + } else { + helperState = .missing } } - - private func autoInstallOnFirstLaunch() { - let hasPrompted = UserDefaults.standard.bool(forKey: hasPromptedKey) - if !hasPrompted && !isHelperInstalled { - print("🔐 First launch - auto-installing privileged helper") - UserDefaults.standard.set(true, forKey: hasPromptedKey) - Task { - _ = await installHelper() + + // MARK: - Preflight (must be awaited before any route application) + + /// Verifies the helper is installed, running, and at the expected version. + /// If outdated, attempts an automatic update. Returns true only when helper + /// is verified ready. Route application MUST NOT start until this returns true. + func ensureHelperReady() async -> Bool { + // Fast path: already verified + if helperState.isReady { return true } + + let helperPath = "/Library/PrivilegedHelperTools/\(kHelperToolMachServiceName)" + let plistPath = "/Library/LaunchDaemons/\(kHelperToolMachServiceName).plist" + + // Check files exist + if !FileManager.default.fileExists(atPath: helperPath) || + !FileManager.default.fileExists(atPath: plistPath) { + // First launch or files removed — try to install + let hasPrompted = UserDefaults.standard.bool(forKey: hasPromptedKey) + if !hasPrompted { + UserDefaults.standard.set(true, forKey: hasPromptedKey) + } + helperState = .missing + print("🔐 Helper not found, attempting install...") + let installed = await installHelper() + if !installed { + helperState = .failed(installationError ?? "Installation failed") + return false } - } else if isHelperInstalled { - // Check for version mismatch and auto-update if needed - checkAndUpdateHelperVersion() + // Install succeeded — drop stale connection before version check + dropXPCConnection() } - } - - private func checkAndUpdateHelperVersion() { - connectToHelper { [weak self] helper in - helper.getVersion { installedVersion in - Task { @MainActor in - guard let self = self else { return } - self.helperVersion = installedVersion - - // Compare with expected version - let expectedVersion = HelperConstants.helperVersion - if installedVersion != expectedVersion { - print("🔐 Helper version mismatch: installed=\(installedVersion), expected=\(expectedVersion)") - print("🔐 Auto-updating privileged helper...") - Task { - _ = await self.installHelper() - } - } + + // Files exist — verify version via XPC with timeout + helperState = .checking + let version = await getVersionWithTimeout() + + guard let version = version else { + // XPC connection failed — helper may be corrupted or wrong arch + print("🔐 Helper XPC connection failed, attempting reinstall...") + helperState = .installing + let reinstalled = await installHelper() + if reinstalled { + // Retry version check after reinstall + dropXPCConnection() + let retryVersion = await getVersionWithTimeout() + if retryVersion == HelperConstants.helperVersion { + helperVersion = retryVersion + helperState = .ready + return true } } + helperState = .failed("Cannot connect to helper after reinstall") + return false + } + + helperVersion = version + let expected = HelperConstants.helperVersion + + if version == expected { + helperState = .ready + return true + } + + // Version mismatch — update + print("🔐 Helper version mismatch: installed=\(version), expected=\(expected)") + helperState = .outdated(installed: version, expected: expected) + + print("🔐 Auto-updating helper...") + let updated = await installHelper() + if !updated { + helperState = .failed("Helper update failed: \(installationError ?? "unknown")") + return false + } + + // Verify the update succeeded + dropXPCConnection() + let newVersion = await getVersionWithTimeout() + if newVersion == expected { + helperVersion = newVersion + helperState = .ready + return true + } + + helperState = .failed("Helper update did not take effect (got \(newVersion ?? "nil"), expected \(expected))") + return false + } + + // MARK: - XPC Connection + + private func dropXPCConnection() { + xpcConnection?.invalidate() + xpcConnection = nil + } + + private func getOrCreateConnection() -> NSXPCConnection { + if let connection = xpcConnection { + return connection + } + + let connection = NSXPCConnection(machServiceName: kHelperToolMachServiceName, options: .privileged) + connection.remoteObjectInterface = NSXPCInterface(with: HelperProtocol.self) + + connection.invalidationHandler = { [weak self] in + Task { @MainActor in + self?.xpcConnection = nil + } } + + connection.interruptionHandler = { [weak self] in + Task { @MainActor in + self?.xpcConnection = nil + } + } + + connection.resume() + xpcConnection = connection + return connection } - - // MARK: - Helper Status - - func checkHelperStatus() { - // Check if helper is installed (file exists means it's registered with launchd) - let helperPath = "/Library/PrivilegedHelperTools/\(kHelperToolMachServiceName)" - let plistPath = "/Library/LaunchDaemons/\(kHelperToolMachServiceName).plist" - - // If both files exist, the helper is installed and will be launched on-demand by launchd - if FileManager.default.fileExists(atPath: helperPath) && - FileManager.default.fileExists(atPath: plistPath) { - isHelperInstalled = true - - // Try to connect and get version (async, non-blocking) - connectToHelper { [weak self] helper in + + /// Get a proxy with error handler. On XPC error, the errorHandler fires + /// instead of the reply block, preventing silent hangs. + private nonisolated func getProxyWithErrorHandler( + connection: NSXPCConnection, + errorHandler: @escaping (Error) -> Void + ) -> HelperProtocol? { + return connection.remoteObjectProxyWithErrorHandler { error in + errorHandler(error) + } as? HelperProtocol + } + + // MARK: - Version Check with Timeout + + private func getVersionWithTimeout() async -> String? { + let connection = getOrCreateConnection() + let result: String? = await withTaskTimeout(seconds: xpcTimeout) { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + print("🔐 XPC error during getVersion: \(error.localizedDescription)") + continuation.resume(returning: "") + } as? HelperProtocol + + guard let helper = proxy else { + continuation.resume(returning: "") + return + } + helper.getVersion { version in - Task { @MainActor in - self?.helperVersion = version - } + continuation.resume(returning: version) } } - } else { - isHelperInstalled = false - helperVersion = nil } + guard let version = result, !version.isEmpty else { return nil } + return version } - + // MARK: - Helper Installation - + func installHelper() async -> Bool { print("🔐 Installing privileged helper...") isInstalling = true - defer { - Task { @MainActor in - self.isInstalling = false - } + helperState = .installing + defer { + isInstalling = false } - - // First, try the modern SMAppService API (macOS 13+) + + // Activate the app so the admin prompt appears on top + NSApp.activate(ignoringOtherApps: true) + if #available(macOS 13.0, *) { return await installHelperModern() } else { return installHelperLegacy() } } - + @available(macOS 13.0, *) private func installHelperModern() async -> Bool { do { - // The plist must be in Contents/Library/LaunchDaemons/ let service = SMAppService.daemon(plistName: "\(kHelperToolMachServiceName).plist") - - print("🔐 Attempting to register daemon service...") try await service.register() - - await MainActor.run { - self.isHelperInstalled = true - self.installationError = nil - } + + installationError = nil print("✅ Helper registered successfully via SMAppService") return true } catch { print("⚠️ SMAppService failed: \(error.localizedDescription)") print("🔐 Falling back to legacy SMJobBless...") - - // Fall back to legacy method - return await MainActor.run { - return self.installHelperLegacy() - } + return installHelperLegacy() } } - + private func installHelperLegacy() -> Bool { - // For unsigned development builds, use AppleScript to install helper manually print("🔐 Attempting manual helper installation via AppleScript...") - + guard let bundlePath = Bundle.main.bundlePath as String?, bundlePath.hasSuffix(".app") else { installationError = "Not running from app bundle" return false } - - // Path to helper binary in the app bundle + let helperSource = "\(bundlePath)/Contents/MacOS/\(kHelperToolMachServiceName)" let plistSource = "\(bundlePath)/Contents/Library/LaunchDaemons/\(kHelperToolMachServiceName).plist" - let helperDest = "/Library/PrivilegedHelperTools/\(kHelperToolMachServiceName)" let plistDest = "/Library/LaunchDaemons/\(kHelperToolMachServiceName).plist" - - // Check if source files exist + guard FileManager.default.fileExists(atPath: helperSource) else { installationError = "Helper binary not found in app bundle" print("❌ Helper not found at: \(helperSource)") return false } - + guard FileManager.default.fileExists(atPath: plistSource) else { installationError = "Helper plist not found in app bundle" print("❌ Plist not found at: \(plistSource)") return false } - - // Create install script + let script = """ do shell script " - # Create directory if needed mkdir -p /Library/PrivilegedHelperTools - - # Stop existing helper if running launchctl bootout system/\(kHelperToolMachServiceName) 2>/dev/null || true - - # Copy helper binary cp '\(helperSource)' '\(helperDest)' chmod 544 '\(helperDest)' chown root:wheel '\(helperDest)' - - # Copy launchd plist cp '\(plistSource)' '\(plistDest)' chmod 644 '\(plistDest)' chown root:wheel '\(plistDest)' - - # Load the helper launchctl bootstrap system '\(plistDest)' " with administrator privileges """ - + var error: NSDictionary? if let appleScript = NSAppleScript(source: script) { appleScript.executeAndReturnError(&error) - + if let error = error { let errorMessage = error[NSAppleScript.errorMessage] as? String ?? "Unknown error" installationError = errorMessage print("❌ AppleScript error: \(errorMessage)") return false } - + print("✅ Helper installed successfully via AppleScript") - isHelperInstalled = true installationError = nil - - // Verify installation - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.checkHelperStatus() - } - return true } - + installationError = "Failed to create AppleScript" return false } - - // MARK: - XPC Connection - - private func connectToHelper(completion: @escaping (HelperProtocol) -> Void) { - if let connection = xpcConnection { - if let helper = connection.remoteObjectProxy as? HelperProtocol { - completion(helper) - return - } - } - - // Create new connection - let connection = NSXPCConnection(machServiceName: kHelperToolMachServiceName, options: .privileged) - connection.remoteObjectInterface = NSXPCInterface(with: HelperProtocol.self) - - connection.invalidationHandler = { [weak self] in - Task { @MainActor in - self?.xpcConnection = nil - } - } - - connection.interruptionHandler = { [weak self] in - Task { @MainActor in - self?.xpcConnection = nil - } - } - - connection.resume() - xpcConnection = connection - - if let helper = connection.remoteObjectProxy as? HelperProtocol { - completion(helper) - } - } - - private func getHelper() async -> HelperProtocol? { - return await withCheckedContinuation { continuation in - connectToHelper { helper in - continuation.resume(returning: helper) - } - } - } - - // MARK: - Route Operations - + + // MARK: - Route Operations (all with timeout + error handling) + func addRoute(destination: String, gateway: String, isNetwork: Bool = false) async -> (success: Bool, error: String?) { - guard isHelperInstalled else { - return (false, "Helper not installed") + guard helperState.isReady else { + return (false, "Helper not ready (\(helperState.statusText))") } - - return await withCheckedContinuation { continuation in - connectToHelper { helper in + + let connection = getOrCreateConnection() + let result = await withTaskTimeout(seconds: xpcTimeout) { + await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + continuation.resume(returning: (false, "XPC error: \(error.localizedDescription)")) + } as? HelperProtocol + + guard let helper = proxy else { + continuation.resume(returning: (false, "Failed to create XPC proxy")) + return + } + helper.addRoute(destination: destination, gateway: gateway, isNetwork: isNetwork) { success, error in continuation.resume(returning: (success, error)) } } } + + if result == nil { + dropXPCConnection() + return (false, "XPC timeout after \(Int(xpcTimeout))s") + } + return result! } - + func removeRoute(destination: String) async -> (success: Bool, error: String?) { - guard isHelperInstalled else { - return (false, "Helper not installed") + guard helperState.isReady else { + return (false, "Helper not ready (\(helperState.statusText))") } - - return await withCheckedContinuation { continuation in - connectToHelper { helper in + + let connection = getOrCreateConnection() + let result = await withTaskTimeout(seconds: xpcTimeout) { + await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + continuation.resume(returning: (false, "XPC error: \(error.localizedDescription)")) + } as? HelperProtocol + + guard let helper = proxy else { + continuation.resume(returning: (false, "Failed to create XPC proxy")) + return + } + helper.removeRoute(destination: destination) { success, error in continuation.resume(returning: (success, error)) } } } + + if result == nil { + dropXPCConnection() + return (false, "XPC timeout after \(Int(xpcTimeout))s") + } + return result! } - - // MARK: - Batch Route Operations (for startup/stop performance) - + + // MARK: - Batch Route Operations + func addRoutesBatch(routes: [(destination: String, gateway: String, isNetwork: Bool)]) async -> (successCount: Int, failureCount: Int, failedDestinations: [String], error: String?) { - guard isHelperInstalled else { - return (0, routes.count, routes.map { $0.destination }, "Helper not installed") + guard helperState.isReady else { + return (0, routes.count, routes.map { $0.destination }, "Helper not ready (\(helperState.statusText))") } let dictRoutes = routes.map { route -> [String: Any] in @@ -301,40 +388,82 @@ final class HelperManager: ObservableObject { ] } - return await withCheckedContinuation { continuation in - connectToHelper { helper in + let connection = getOrCreateConnection() + let result = await withTaskTimeout(seconds: xpcTimeout + Double(routes.count) * 0.1) { + await withCheckedContinuation { (continuation: CheckedContinuation<(Int, Int, [String], String?), Never>) in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + continuation.resume(returning: (0, routes.count, routes.map { $0.destination }, "XPC error: \(error.localizedDescription)")) + } as? HelperProtocol + + guard let helper = proxy else { + continuation.resume(returning: (0, routes.count, routes.map { $0.destination }, "Failed to create XPC proxy")) + return + } + helper.addRoutesBatch(routes: dictRoutes) { successCount, failureCount, failedDestinations, error in continuation.resume(returning: (successCount, failureCount, failedDestinations, error)) } } } + + if result == nil { + dropXPCConnection() + return (0, routes.count, routes.map { $0.destination }, "XPC timeout") + } + return result! } func removeRoutesBatch(destinations: [String]) async -> (successCount: Int, failureCount: Int, failedDestinations: [String], error: String?) { - guard isHelperInstalled else { - return (0, destinations.count, destinations, "Helper not installed") + guard helperState.isReady else { + return (0, destinations.count, destinations, "Helper not ready (\(helperState.statusText))") } - return await withCheckedContinuation { continuation in - connectToHelper { helper in + let connection = getOrCreateConnection() + let result = await withTaskTimeout(seconds: xpcTimeout + Double(destinations.count) * 0.1) { + await withCheckedContinuation { (continuation: CheckedContinuation<(Int, Int, [String], String?), Never>) in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + continuation.resume(returning: (0, destinations.count, destinations, "XPC error: \(error.localizedDescription)")) + } as? HelperProtocol + + guard let helper = proxy else { + continuation.resume(returning: (0, destinations.count, destinations, "Failed to create XPC proxy")) + return + } + helper.removeRoutesBatch(destinations: destinations) { successCount, failureCount, failedDestinations, error in continuation.resume(returning: (successCount, failureCount, failedDestinations, error)) } } } + + if result == nil { + dropXPCConnection() + return (0, destinations.count, destinations, "XPC timeout") + } + return result! } - + // MARK: - Hosts File Operations - + func updateHostsFile(entries: [(domain: String, ip: String)]) async -> (success: Bool, error: String?) { - guard isHelperInstalled else { - return (false, "Helper not installed") + guard helperState.isReady else { + return (false, "Helper not ready (\(helperState.statusText))") } - + let dictEntries = entries.map { ["domain": $0.domain, "ip": $0.ip] } - - return await withCheckedContinuation { continuation in - connectToHelper { helper in + + let connection = getOrCreateConnection() + let result = await withTaskTimeout(seconds: xpcTimeout) { + await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + continuation.resume(returning: (false, "XPC error: \(error.localizedDescription)")) + } as? HelperProtocol + + guard let helper = proxy else { + continuation.resume(returning: (false, "Failed to create XPC proxy")) + return + } + helper.updateHostsFile(entries: dictEntries) { success, error in if success { helper.flushDNSCache { _ in @@ -346,9 +475,34 @@ final class HelperManager: ObservableObject { } } } + + if result == nil { + dropXPCConnection() + return (false, "XPC timeout after \(Int(xpcTimeout))s") + } + return result! } - + func clearHostsFile() async -> (success: Bool, error: String?) { return await updateHostsFile(entries: []) } } + +// MARK: - Task Timeout Helper + +/// Runs an async operation with a deadline. Returns nil if the timeout fires first. +private func withTaskTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async -> T) async -> T? { + await withTaskGroup(of: T?.self) { group in + group.addTask { + await operation() + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + // Whichever finishes first wins + let result = await group.next() ?? nil + group.cancelAll() + return result + } +} diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 36781a9..e81be35 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -993,7 +993,59 @@ struct GeneralTab: View { @State private var showingImportPicker = false @State private var showingImportError = false @State private var importErrorMessage = "" - + + private var helperStateIcon: String { + switch helperManager.helperState { + case .ready: return "checkmark.shield.fill" + case .checking, .installing: return "shield.fill" + case .outdated: return "exclamationmark.shield.fill" + case .missing, .failed: return "xmark.shield.fill" + } + } + + private var helperStateColor: Color { + switch helperManager.helperState { + case .ready: return Color(hex: "10B981") + case .checking, .installing: return Color(hex: "F59E0B") + case .outdated: return Color(hex: "F59E0B") + case .missing, .failed: return Color(hex: "EF4444") + } + } + + private var helperStateSubtitle: String { + switch helperManager.helperState { + case .ready: return "No more password prompts for route changes" + case .checking: return "Verifying helper version..." + case .installing: return "Admin authorization required..." + case .outdated: return "Helper needs updating for this version" + case .missing: return "Install to enable route management" + case .failed: return "Helper could not be started" + } + } + + private var helperNeedsAction: Bool { + switch helperManager.helperState { + case .missing, .outdated, .failed: return true + default: return false + } + } + + private var helperActionIcon: String { + switch helperManager.helperState { + case .outdated: return "arrow.up.circle.fill" + default: return "arrow.down.circle.fill" + } + } + + private var helperActionLabel: String { + if helperManager.isInstalling { return "Installing..." } + switch helperManager.helperState { + case .outdated: return "Update" + case .failed: return "Retry" + default: return "Install" + } + } + var body: some View { VStack(alignment: .leading, spacing: 20) { // Header @@ -1026,29 +1078,23 @@ struct GeneralTab: View { // Privileged Helper section SettingsCard(title: "Privileged Helper", icon: "lock.shield.fill", iconColor: Color(hex: "EF4444")) { HStack(spacing: 12) { - Image(systemName: helperManager.isHelperInstalled ? "checkmark.shield.fill" : "xmark.shield.fill") + Image(systemName: helperStateIcon) .font(.system(size: 14)) - .foregroundColor(helperManager.isHelperInstalled ? Color(hex: "10B981") : Color(hex: "EF4444")) + .foregroundColor(helperStateColor) .frame(width: 20) - + VStack(alignment: .leading, spacing: 2) { - Text(helperManager.isHelperInstalled ? "Helper Installed" : "Helper Not Installed") + Text(helperManager.helperState.statusText) .font(.system(size: 13, weight: .medium)) .foregroundColor(.white) - if helperManager.isHelperInstalled { - Text("No more password prompts for route changes") - .font(.system(size: 11)) - .foregroundColor(Color(hex: "6B7280")) - } else { - Text("Install to avoid repeated admin prompts") - .font(.system(size: 11)) - .foregroundColor(Color(hex: "6B7280")) - } + Text(helperStateSubtitle) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "6B7280")) } - + Spacer() - - if !helperManager.isHelperInstalled { + + if helperNeedsAction { Button { installHelper() } label: { @@ -1058,10 +1104,10 @@ struct GeneralTab: View { .scaleEffect(0.6) .frame(width: 12, height: 12) } else { - Image(systemName: "arrow.down.circle.fill") + Image(systemName: helperActionIcon) .font(.system(size: 10)) } - Text(helperManager.isInstalling ? "Installing..." : "Install") + Text(helperActionLabel) .font(.system(size: 11, weight: .medium)) } .foregroundColor(.white) @@ -1084,7 +1130,7 @@ struct GeneralTab: View { .foregroundColor(Color(hex: "6B7280")) } } - + if let error = helperManager.installationError { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") @@ -1096,7 +1142,7 @@ struct GeneralTab: View { .lineLimit(2) } } - + Text("The helper runs as root and handles route/hosts changes without prompting.") .font(.system(size: 11)) .foregroundColor(Color(hex: "6B7280")) diff --git a/Sources/VPNBypassApp.swift b/Sources/VPNBypassApp.swift index 812079b..4f8c01d 100644 --- a/Sources/VPNBypassApp.swift +++ b/Sources/VPNBypassApp.swift @@ -57,20 +57,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Pre-warm SettingsWindowController so first click is instant _ = SettingsWindowController.shared - + // Load config and apply routes on startup Task { @MainActor in RouteManager.shared.loadConfig() - + + // Ensure helper is installed, running, and at the correct version + // BEFORE any route application. This prevents the "Setting Up" hang + // when the helper is outdated after a Homebrew upgrade. + let helperReady = await HelperManager.shared.ensureHelperReady() + if !helperReady { + RouteManager.shared.log(.error, "Helper not ready: \(HelperManager.shared.helperState.statusText)") + } + // Small delay to let network interfaces settle try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds - + // Detect VPN and apply routes on startup await RouteManager.shared.detectAndApplyRoutesAsync() - + // Start the auto DNS refresh timer RouteManager.shared.startDNSRefreshTimer() - + // Mark startup as complete hasCompletedInitialStartup = true lastSuccessfulVPNCheck = Date() From 7a119554b885d6833fdf10f5600f8977f96fb5a7 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:48:22 +0200 Subject: [PATCH 4/8] fix: hard XPC deadline, authoritative helper gate, Settings auto-recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Replace broken withTaskTimeout (task group + CheckedContinuation — cancellation doesn't resume stuck continuations) with OnceGate + DispatchQueue.asyncAfter hard deadline. The gate guarantees exactly- once delivery: whichever fires first (XPC reply or timer) wins, the other is silently dropped. No cooperative cancellation needed. 2. Make helper preflight an authoritative gate: if ensureHelperReady() returns false, startup now returns early — detectAndApplyRoutesAsync() is never called. No more helperless fallback presenting false state. 3. Settings Install/Update/Retry button now calls ensureHelperReady() (full preflight with version verify) instead of bare installHelper(). On success, if VPN is connected but no routes exist (startup was gated), automatically triggers route application. --- Sources/HelperManager.swift | 261 ++++++++++++++++++------------------ Sources/SettingsView.swift | 8 +- Sources/VPNBypassApp.swift | 6 +- 3 files changed, 146 insertions(+), 129 deletions(-) diff --git a/Sources/HelperManager.swift b/Sources/HelperManager.swift index 44e4b64..d815a59 100644 --- a/Sources/HelperManager.swift +++ b/Sources/HelperManager.swift @@ -194,25 +194,23 @@ final class HelperManager: ObservableObject { private func getVersionWithTimeout() async -> String? { let connection = getOrCreateConnection() - let result: String? = await withTaskTimeout(seconds: xpcTimeout) { - await withCheckedContinuation { (continuation: CheckedContinuation) in - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - print("🔐 XPC error during getVersion: \(error.localizedDescription)") - continuation.resume(returning: "") - } as? HelperProtocol - - guard let helper = proxy else { - continuation.resume(returning: "") - return - } + let noVersion: String? = nil + let result: String? = await withXPCDeadline(seconds: xpcTimeout, fallback: noVersion) { once in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + print("🔐 XPC error during getVersion: \(error.localizedDescription)") + once.complete(noVersion) + } as? HelperProtocol + + guard let helper = proxy else { + once.complete(noVersion) + return + } - helper.getVersion { version in - continuation.resume(returning: version) - } + helper.getVersion { version in + once.complete(version) } } - guard let version = result, !version.isEmpty else { return nil } - return version + return result } // MARK: - Helper Installation @@ -311,7 +309,7 @@ final class HelperManager: ObservableObject { return false } - // MARK: - Route Operations (all with timeout + error handling) + // MARK: - Route Operations (all with hard XPC deadline) func addRoute(destination: String, gateway: String, isNetwork: Bool = false) async -> (success: Bool, error: String?) { guard helperState.isReady else { @@ -319,28 +317,24 @@ final class HelperManager: ObservableObject { } let connection = getOrCreateConnection() - let result = await withTaskTimeout(seconds: xpcTimeout) { - await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - continuation.resume(returning: (false, "XPC error: \(error.localizedDescription)")) - } as? HelperProtocol - - guard let helper = proxy else { - continuation.resume(returning: (false, "Failed to create XPC proxy")) - return - } + let fallback: (Bool, String?) = (false, "XPC timeout after \(Int(xpcTimeout))s") + let result = await withXPCDeadline(seconds: xpcTimeout, fallback: fallback) { once in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + once.complete((false, "XPC error: \(error.localizedDescription)")) + } as? HelperProtocol + + guard let helper = proxy else { + once.complete((false, "Failed to create XPC proxy")) + return + } - helper.addRoute(destination: destination, gateway: gateway, isNetwork: isNetwork) { success, error in - continuation.resume(returning: (success, error)) - } + helper.addRoute(destination: destination, gateway: gateway, isNetwork: isNetwork) { success, error in + once.complete((success, error)) } } - if result == nil { - dropXPCConnection() - return (false, "XPC timeout after \(Int(xpcTimeout))s") - } - return result! + if result == fallback { dropXPCConnection() } + return result } func removeRoute(destination: String) async -> (success: Bool, error: String?) { @@ -349,28 +343,24 @@ final class HelperManager: ObservableObject { } let connection = getOrCreateConnection() - let result = await withTaskTimeout(seconds: xpcTimeout) { - await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - continuation.resume(returning: (false, "XPC error: \(error.localizedDescription)")) - } as? HelperProtocol - - guard let helper = proxy else { - continuation.resume(returning: (false, "Failed to create XPC proxy")) - return - } + let fallback: (Bool, String?) = (false, "XPC timeout after \(Int(xpcTimeout))s") + let result = await withXPCDeadline(seconds: xpcTimeout, fallback: fallback) { once in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + once.complete((false, "XPC error: \(error.localizedDescription)")) + } as? HelperProtocol + + guard let helper = proxy else { + once.complete((false, "Failed to create XPC proxy")) + return + } - helper.removeRoute(destination: destination) { success, error in - continuation.resume(returning: (success, error)) - } + helper.removeRoute(destination: destination) { success, error in + once.complete((success, error)) } } - if result == nil { - dropXPCConnection() - return (false, "XPC timeout after \(Int(xpcTimeout))s") - } - return result! + if result == fallback { dropXPCConnection() } + return result } // MARK: - Batch Route Operations @@ -381,36 +371,34 @@ final class HelperManager: ObservableObject { } let dictRoutes = routes.map { route -> [String: Any] in - return [ + [ "destination": route.destination, "gateway": route.gateway, "isNetwork": route.isNetwork ] } + let allDests = routes.map { $0.destination } + let timeout = xpcTimeout + Double(routes.count) * 0.1 + let fallback = (0, routes.count, allDests, Optional("XPC timeout")) let connection = getOrCreateConnection() - let result = await withTaskTimeout(seconds: xpcTimeout + Double(routes.count) * 0.1) { - await withCheckedContinuation { (continuation: CheckedContinuation<(Int, Int, [String], String?), Never>) in - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - continuation.resume(returning: (0, routes.count, routes.map { $0.destination }, "XPC error: \(error.localizedDescription)")) - } as? HelperProtocol - - guard let helper = proxy else { - continuation.resume(returning: (0, routes.count, routes.map { $0.destination }, "Failed to create XPC proxy")) - return - } + let result = await withXPCDeadline(seconds: timeout, fallback: fallback) { once in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + once.complete((0, routes.count, allDests, Optional("XPC error: \(error.localizedDescription)"))) + } as? HelperProtocol + + guard let helper = proxy else { + once.complete((0, routes.count, allDests, Optional("Failed to create XPC proxy"))) + return + } - helper.addRoutesBatch(routes: dictRoutes) { successCount, failureCount, failedDestinations, error in - continuation.resume(returning: (successCount, failureCount, failedDestinations, error)) - } + helper.addRoutesBatch(routes: dictRoutes) { successCount, failureCount, failedDestinations, error in + once.complete((successCount, failureCount, failedDestinations, error)) } } - if result == nil { - dropXPCConnection() - return (0, routes.count, routes.map { $0.destination }, "XPC timeout") - } - return result! + if result.3 == "XPC timeout" { dropXPCConnection() } + return result } func removeRoutesBatch(destinations: [String]) async -> (successCount: Int, failureCount: Int, failedDestinations: [String], error: String?) { @@ -418,29 +406,27 @@ final class HelperManager: ObservableObject { return (0, destinations.count, destinations, "Helper not ready (\(helperState.statusText))") } + let timeout = xpcTimeout + Double(destinations.count) * 0.1 + let fallback = (0, destinations.count, destinations, Optional("XPC timeout")) + let connection = getOrCreateConnection() - let result = await withTaskTimeout(seconds: xpcTimeout + Double(destinations.count) * 0.1) { - await withCheckedContinuation { (continuation: CheckedContinuation<(Int, Int, [String], String?), Never>) in - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - continuation.resume(returning: (0, destinations.count, destinations, "XPC error: \(error.localizedDescription)")) - } as? HelperProtocol - - guard let helper = proxy else { - continuation.resume(returning: (0, destinations.count, destinations, "Failed to create XPC proxy")) - return - } + let result = await withXPCDeadline(seconds: timeout, fallback: fallback) { once in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + once.complete((0, destinations.count, destinations, Optional("XPC error: \(error.localizedDescription)"))) + } as? HelperProtocol + + guard let helper = proxy else { + once.complete((0, destinations.count, destinations, Optional("Failed to create XPC proxy"))) + return + } - helper.removeRoutesBatch(destinations: destinations) { successCount, failureCount, failedDestinations, error in - continuation.resume(returning: (successCount, failureCount, failedDestinations, error)) - } + helper.removeRoutesBatch(destinations: destinations) { successCount, failureCount, failedDestinations, error in + once.complete((successCount, failureCount, failedDestinations, error)) } } - if result == nil { - dropXPCConnection() - return (0, destinations.count, destinations, "XPC timeout") - } - return result! + if result.3 == "XPC timeout" { dropXPCConnection() } + return result } // MARK: - Hosts File Operations @@ -453,34 +439,30 @@ final class HelperManager: ObservableObject { let dictEntries = entries.map { ["domain": $0.domain, "ip": $0.ip] } let connection = getOrCreateConnection() - let result = await withTaskTimeout(seconds: xpcTimeout) { - await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - continuation.resume(returning: (false, "XPC error: \(error.localizedDescription)")) - } as? HelperProtocol - - guard let helper = proxy else { - continuation.resume(returning: (false, "Failed to create XPC proxy")) - return - } + let fallback: (Bool, String?) = (false, "XPC timeout after \(Int(xpcTimeout))s") + let result = await withXPCDeadline(seconds: xpcTimeout, fallback: fallback) { once in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + once.complete((false, "XPC error: \(error.localizedDescription)")) + } as? HelperProtocol + + guard let helper = proxy else { + once.complete((false, "Failed to create XPC proxy")) + return + } - helper.updateHostsFile(entries: dictEntries) { success, error in - if success { - helper.flushDNSCache { _ in - continuation.resume(returning: (true, nil)) - } - } else { - continuation.resume(returning: (false, error)) + helper.updateHostsFile(entries: dictEntries) { success, error in + if success { + helper.flushDNSCache { _ in + once.complete((true, nil)) } + } else { + once.complete((false, error)) } } } - if result == nil { - dropXPCConnection() - return (false, "XPC timeout after \(Int(xpcTimeout))s") - } - return result! + if result == fallback { dropXPCConnection() } + return result } func clearHostsFile() async -> (success: Bool, error: String?) { @@ -488,21 +470,46 @@ final class HelperManager: ObservableObject { } } -// MARK: - Task Timeout Helper +// MARK: - XPC Deadline (hard timeout via DispatchQueue timer) -/// Runs an async operation with a deadline. Returns nil if the timeout fires first. -private func withTaskTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async -> T) async -> T? { - await withTaskGroup(of: T?.self) { group in - group.addTask { - await operation() - } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil - } - // Whichever finishes first wins - let result = await group.next() ?? nil - group.cancelAll() - return result +/// Ensures exactly-once delivery of a result to a CheckedContinuation. +/// Either the XPC reply or the DispatchQueue deadline fires — whichever +/// comes first wins, the other is silently dropped. +final class OnceGate { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func complete(_ value: T) { + lock.lock() + let cont = continuation + continuation = nil + lock.unlock() + cont?.resume(returning: value) + } +} + +/// Runs a synchronous XPC call block with a hard deadline. The block receives +/// a `OnceGate` that it must call `complete()` on when the XPC reply arrives. +/// If the deadline fires first, the gate delivers `fallback` and subsequent +/// `complete()` calls from the XPC reply are silently dropped. +private func withXPCDeadline( + seconds: TimeInterval, + fallback: T, + operation: @escaping (OnceGate) -> Void +) async -> T { + await withCheckedContinuation { continuation in + let gate = OnceGate(continuation: continuation) + + // Hard deadline — fires on a background queue, does not depend on + // cooperative task cancellation or the XPC reply ever arriving. + DispatchQueue.global().asyncAfter(deadline: .now() + seconds) { + gate.complete(fallback) + } + + operation(gate) } } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index e81be35..aa182d8 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -1809,7 +1809,13 @@ struct GeneralTab: View { private func installHelper() { Task { - _ = await helperManager.installHelper() + let ready = await helperManager.ensureHelperReady() + if ready && routeManager.isVPNConnected && routeManager.activeRoutes.isEmpty { + // Helper just became ready and VPN is connected but no routes — + // the initial startup was skipped because helper wasn't ready. + // Automatically apply routes now. + await routeManager.detectAndApplyRoutesAsync() + } } } } diff --git a/Sources/VPNBypassApp.swift b/Sources/VPNBypassApp.swift index 4f8c01d..5021f0f 100644 --- a/Sources/VPNBypassApp.swift +++ b/Sources/VPNBypassApp.swift @@ -67,12 +67,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { // when the helper is outdated after a Homebrew upgrade. let helperReady = await HelperManager.shared.ensureHelperReady() if !helperReady { - RouteManager.shared.log(.error, "Helper not ready: \(HelperManager.shared.helperState.statusText)") + RouteManager.shared.log(.error, "Helper not ready: \(HelperManager.shared.helperState.statusText). Route application skipped.") } // Small delay to let network interfaces settle try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + // Only attempt route application if helper is verified ready. + // Without the helper, route operations will fail silently or hang. + guard helperReady else { return } + // Detect VPN and apply routes on startup await RouteManager.shared.detectAndApplyRoutesAsync() From 966797e530e8be0b1246db77b6b03ef8071fb16d Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:57:58 +0200 Subject: [PATCH 5/8] fix: close helperless fallback paths and restore DNS timer on recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove helperless fallback from addRoute/removeRoute — when helper isn't ready, fail immediately instead of silently falling back to direct /sbin/route commands that require sudo and bypass the state model. This closes the back door that let periodic refresh and network monitor re-enter the unsupported helperless path. 2. Gate auto-apply on VPN connect: checkVPNStatus() now also checks HelperManager.shared.isHelperInstalled before triggering applyAllRoutes(), preventing the 30-second periodic refresh from bypassing the startup gate. 3. Settings recovery now starts startDNSRefreshTimer() after detectAndApplyRoutesAsync(), restoring the full lifecycle that was skipped when startup returned early due to helper failure. --- Sources/RouteManager.swift | 45 +++++++++++++------------------------- Sources/SettingsView.swift | 3 ++- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/Sources/RouteManager.swift b/Sources/RouteManager.swift index 67faf72..9b541df 100644 --- a/Sources/RouteManager.swift +++ b/Sources/RouteManager.swift @@ -718,7 +718,8 @@ final class RouteManager: ObservableObject { } // Auto-apply routes when VPN connects (skip if already applying or recently applied) - if isVPNConnected && !wasVPNConnected && config.autoApplyOnVPN && !isLoading && !isApplyingRoutes { + // Also skip if helper is not ready — no point attempting routes that will all fail + if isVPNConnected && !wasVPNConnected && config.autoApplyOnVPN && !isLoading && !isApplyingRoutes && HelperManager.shared.isHelperInstalled { // Skip if routes were applied very recently (within 5 seconds) - prevents double-triggering if let lastUpdate = lastUpdate, Date().timeIntervalSince(lastUpdate) < 5 { log(.info, "Skipping duplicate route application (applied \(Int(Date().timeIntervalSince(lastUpdate)))s ago)") @@ -3355,40 +3356,24 @@ final class RouteManager: ObservableObject { } private func addRoute(_ destination: String, gateway: String, isNetwork: Bool = false) async -> Bool { - // Use privileged helper if installed - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.addRoute(destination: destination, gateway: gateway, isNetwork: isNetwork) - if !result.success { - log(.warning, "Helper route add failed: \(result.error ?? "unknown")") - } - return result.success - } - - // Fallback: direct command (may require sudo) - // First try to delete existing route - _ = await removeRoute(destination) - - let args = isNetwork - ? ["-n", "add", "-net", destination, gateway] - : ["-n", "add", "-host", destination, gateway] - - guard let result = await runProcessAsync("/sbin/route", arguments: args, timeout: 5.0) else { + guard HelperManager.shared.isHelperInstalled else { + log(.error, "Cannot add route: helper not ready") return false } - - return result.exitCode == 0 + let result = await HelperManager.shared.addRoute(destination: destination, gateway: gateway, isNetwork: isNetwork) + if !result.success { + log(.warning, "Helper route add failed: \(result.error ?? "unknown")") + } + return result.success } - + private func removeRoute(_ destination: String) async -> Bool { - // Use privileged helper if installed - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.removeRoute(destination: destination) - return result.success + guard HelperManager.shared.isHelperInstalled else { + log(.error, "Cannot remove route: helper not ready") + return false } - - // Fallback: direct command with timeout - _ = await runProcessAsync("/sbin/route", arguments: ["-n", "delete", destination], timeout: 3.0) - return true // Route delete can fail if route doesn't exist, that's ok + let result = await HelperManager.shared.removeRoute(destination: destination) + return result.success } private func updateHostsFile() async { diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index aa182d8..d5ee4f8 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -1813,8 +1813,9 @@ struct GeneralTab: View { if ready && routeManager.isVPNConnected && routeManager.activeRoutes.isEmpty { // Helper just became ready and VPN is connected but no routes — // the initial startup was skipped because helper wasn't ready. - // Automatically apply routes now. + // Automatically apply routes and start the DNS refresh lifecycle. await routeManager.detectAndApplyRoutesAsync() + routeManager.startDNSRefreshTimer() } } } From adec9b0f7fbfc6766113ea5e214efe24ac2cbbec Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:12:06 +0200 Subject: [PATCH 6/8] fix: remove all helperless fallback paths from RouteManager - Add helper-ready guards to VPN interface reroute and Tailscale profile reroute code paths - Replace all `else { for ... addRoute/removeRoute }` fallback loops with early-return or log-and-skip in: applyAllRoutesInternal, removeAllRoutes, applyRoutesFromCache, backgroundDNSRefresh, performDNSRefresh stale cleanup - Remove entire AppleScript hosts file fallback from modifyHostsFile - Flatten orphan/add-failed cleanup branches (no conditional needed when helper is the only path) - Helper readiness is now authoritative at every route-mutating entry point, not just at startup --- Sources/RouteManager.swift | 248 +++++++++++-------------------------- 1 file changed, 71 insertions(+), 177 deletions(-) diff --git a/Sources/RouteManager.swift b/Sources/RouteManager.swift index 9b541df..56d1aa1 100644 --- a/Sources/RouteManager.swift +++ b/Sources/RouteManager.swift @@ -735,7 +735,7 @@ final class RouteManager: ObservableObject { } // VPN interface switched while still connected — re-route through new gateway - if isVPNConnected && wasVPNConnected && interface != oldInterface && oldInterface != nil && interface != nil && !isLoading && !isApplyingRoutes { + if isVPNConnected && wasVPNConnected && interface != oldInterface && oldInterface != nil && interface != nil && !isLoading && !isApplyingRoutes && HelperManager.shared.isHelperInstalled { if let last = lastInterfaceReroute, Date().timeIntervalSince(last) < 10 { log(.info, "Skipping interface re-route (cooldown, last was \(Int(Date().timeIntervalSince(last)))s ago)") } else { @@ -759,7 +759,7 @@ final class RouteManager: ObservableObject { interface == oldInterface && oldTailscaleFingerprint != nil && newTailscaleFingerprint != nil && oldTailscaleFingerprint != newTailscaleFingerprint && - !isLoading && !isApplyingRoutes { + !isLoading && !isApplyingRoutes && HelperManager.shared.isHelperInstalled { if let last = lastInterfaceReroute, Date().timeIntervalSince(last) < 10 { log(.info, "Skipping Tailscale profile re-route (cooldown, last was \(Int(Date().timeIntervalSince(last)))s ago)") } else { @@ -1079,6 +1079,10 @@ final class RouteManager: ObservableObject { /// Called from Refresh button - sends notification func refreshRoutes() { + guard HelperManager.shared.isHelperInstalled else { + log(.error, "Cannot refresh routes: helper not ready (\(HelperManager.shared.helperState.statusText))") + return + } Task { await detectAndApplyRoutesAsync(sendNotification: true) } @@ -1361,10 +1365,8 @@ final class RouteManager: ObservableObject { log(.warning, "Batch route add: \(result.successCount) succeeded, \(result.failureCount) failed (\(result.failedDestinations.prefix(3).joined(separator: ", "))...)") } } else { - // Fallback: add routes one by one (slower but works without helper) - for route in routesToAdd { - _ = await addRoute(route.destination, gateway: route.gateway, isNetwork: route.isNetwork) - } + log(.error, "Cannot add routes: helper not ready (\(HelperManager.shared.helperState.statusText))") + return } // Build activeRoutes from allSourceEntries, excluding destinations that failed kernel add @@ -1392,38 +1394,24 @@ final class RouteManager: ObservableObject { // Truly orphaned: re-attach on failure (route is genuinely still in kernel) if !trulyOrphanedDests.isEmpty { - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.removeRoutesBatch(destinations: trulyOrphanedDests) - if result.failureCount > 0 { - log(.warning, "Orphan cleanup: \(result.successCount) removed, \(result.failureCount) failed — retaining") - let failedSet = Set(result.failedDestinations) - for route in activeRoutes where failedSet.contains(route.destination) && !newDestinations.contains(route.destination) { - newRoutes.append(route) - } - } else if result.successCount > 0 { - log(.info, "Orphan cleanup: \(result.successCount) stale kernel routes removed") - } - } else { - for dest in trulyOrphanedDests { - _ = await removeRoute(dest) + let result = await HelperManager.shared.removeRoutesBatch(destinations: trulyOrphanedDests) + if result.failureCount > 0 { + log(.warning, "Orphan cleanup: \(result.successCount) removed, \(result.failureCount) failed — retaining") + let failedSet = Set(result.failedDestinations) + for route in activeRoutes where failedSet.contains(route.destination) && !newDestinations.contains(route.destination) { + newRoutes.append(route) } + } else if result.successCount > 0 { + log(.info, "Orphan cleanup: \(result.successCount) stale kernel routes removed") } } // Add-failed: helper's addRoute does delete-before-add, so the old route is // gone after a failed re-add. Don't re-attach — the kernel route doesn't exist. - // (The only way delete-before-add's delete could fail is if the route was already - // absent, since the helper runs as root and permission errors don't apply.) if !addFailedStaleDests.isEmpty { - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.removeRoutesBatch(destinations: addFailedStaleDests) - if result.failureCount > 0 { - log(.info, "Add-failed cleanup: \(result.failureCount) route(s) already removed by delete-before-add") - } - } else { - for dest in addFailedStaleDests { - _ = await removeRoute(dest) - } + let result = await HelperManager.shared.removeRoutesBatch(destinations: addFailedStaleDests) + if result.failureCount > 0 { + log(.info, "Add-failed cleanup: \(result.failureCount) route(s) already removed by delete-before-add") } } @@ -1478,17 +1466,17 @@ final class RouteManager: ObservableObject { let destinations = Array(Set(activeRoutes.map { $0.destination })) var failedDests: Set = [] - if HelperManager.shared.isHelperInstalled && !destinations.isEmpty { - let result = await HelperManager.shared.removeRoutesBatch(destinations: destinations) - failedDests = Set(result.failedDestinations) - if result.failureCount > 0 { - log(.warning, "Batch route removal: \(result.successCount) succeeded, \(result.failureCount) failed — retaining failed entries in model") + if !destinations.isEmpty { + if HelperManager.shared.isHelperInstalled { + let result = await HelperManager.shared.removeRoutesBatch(destinations: destinations) + failedDests = Set(result.failedDestinations) + if result.failureCount > 0 { + log(.warning, "Batch route removal: \(result.successCount) succeeded, \(result.failureCount) failed — retaining failed entries in model") + } else { + log(.info, "Batch route removal: \(result.successCount) routes removed") + } } else { - log(.info, "Batch route removal: \(result.successCount) routes removed") - } - } else { - for route in activeRoutes { - _ = await removeRoute(route.destination) + log(.error, "Cannot remove routes: helper not ready (\(HelperManager.shared.helperState.statusText))") } } @@ -1635,7 +1623,7 @@ final class RouteManager: ObservableObject { log(.info, "Applying \(routesToAdd.count) routes from cache (\(isInverse ? "VPN Only" : "Bypass") mode)...") - // Apply routes in batch (with fallback for helperless mode) + // Apply routes in batch via helper var batchFailureCount = 0 var batchFailedDests: Set = [] if !routesToAdd.isEmpty { @@ -1648,9 +1636,8 @@ final class RouteManager: ObservableObject { log(.warning, "Cache route batch: \(result.successCount) succeeded, \(result.failureCount) failed — will reconcile on DNS refresh") } } else { - for route in routesToAdd { - _ = await addRoute(route.destination, gateway: route.gateway, isNetwork: route.isNetwork) - } + log(.error, "Cannot apply cached routes: helper not ready (\(HelperManager.shared.helperState.statusText))") + return false } } @@ -1672,35 +1659,23 @@ final class RouteManager: ObservableObject { let addFailedStaleDests = Array(allStaleDests.intersection(batchAttemptedDests)) if !trulyOrphanedDests.isEmpty { - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.removeRoutesBatch(destinations: trulyOrphanedDests) - if result.failureCount > 0 { - log(.warning, "Cache orphan cleanup: \(result.successCount) removed, \(result.failureCount) failed — retaining") - let failedSet = Set(result.failedDestinations) - for route in activeRoutes where failedSet.contains(route.destination) && !newDestinations.contains(route.destination) { - newRoutes.append(route) - } - } else if result.successCount > 0 { - log(.info, "Cache orphan cleanup: \(result.successCount) stale kernel routes removed") - } - } else { - for dest in trulyOrphanedDests { - _ = await removeRoute(dest) + let result = await HelperManager.shared.removeRoutesBatch(destinations: trulyOrphanedDests) + if result.failureCount > 0 { + log(.warning, "Cache orphan cleanup: \(result.successCount) removed, \(result.failureCount) failed — retaining") + let failedSet = Set(result.failedDestinations) + for route in activeRoutes where failedSet.contains(route.destination) && !newDestinations.contains(route.destination) { + newRoutes.append(route) } + } else if result.successCount > 0 { + log(.info, "Cache orphan cleanup: \(result.successCount) stale kernel routes removed") } } // Add-failed: delete-before-add already removed the old route (see full apply comment) if !addFailedStaleDests.isEmpty { - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.removeRoutesBatch(destinations: addFailedStaleDests) - if result.failureCount > 0 { - log(.info, "Cache add-failed cleanup: \(result.failureCount) route(s) already removed by delete-before-add") - } - } else { - for dest in addFailedStaleDests { - _ = await removeRoute(dest) - } + let result = await HelperManager.shared.removeRoutesBatch(destinations: addFailedStaleDests) + if result.failureCount > 0 { + log(.info, "Cache add-failed cleanup: \(result.failureCount) route(s) already removed by delete-before-add") } } @@ -1865,51 +1840,36 @@ final class RouteManager: ObservableObject { } if !kernelRemovalDests.isEmpty { - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.removeRoutesBatch(destinations: kernelRemovalDests) - if result.failureCount > 0 { - // Re-add activeRoute entries for destinations that failed kernel removal - let failedSet = Set(result.failedDestinations) - await MainActor.run { - for removal in removals where failedSet.contains(removal.destination) { - activeRoutes.append(ActiveRoute( - destination: removal.destination, - gateway: removal.gateway, - source: removal.source, - timestamp: Date() - )) - } + let result = await HelperManager.shared.removeRoutesBatch(destinations: kernelRemovalDests) + if result.failureCount > 0 { + // Re-add activeRoute entries for destinations that failed kernel removal + let failedSet = Set(result.failedDestinations) + await MainActor.run { + for removal in removals where failedSet.contains(removal.destination) { + activeRoutes.append(ActiveRoute( + destination: removal.destination, + gateway: removal.gateway, + source: removal.source, + timestamp: Date() + )) } } - } else { - for dest in kernelRemovalDests { - _ = await removeRoute(dest) - } } } } if !additions.isEmpty { var addFailedDests: Set = [] - if HelperManager.shared.isHelperInstalled { - // Deduplicate by destination for kernel operations — same IP from - // different sources must only be sent once to avoid delete-before-add - // destroying a just-added route on the second pass - var seenAddDests: Set = [] - let routes = additions.compactMap { add -> (destination: String, gateway: String, isNetwork: Bool)? in - guard seenAddDests.insert(add.destination).inserted else { return nil } - return (destination: add.destination, gateway: add.gateway, isNetwork: add.isNetwork) - } - let result = await HelperManager.shared.addRoutesBatch(routes: routes) - addFailedDests = Set(result.failedDestinations) - } else { - var seenAddDests: Set = [] - for add in additions { - if seenAddDests.insert(add.destination).inserted { - _ = await addRoute(add.destination, gateway: add.gateway, isNetwork: add.isNetwork) - } - } + // Deduplicate by destination for kernel operations — same IP from + // different sources must only be sent once to avoid delete-before-add + // destroying a just-added route on the second pass + var seenAddDests: Set = [] + let routes = additions.compactMap { add -> (destination: String, gateway: String, isNetwork: Bool)? in + guard seenAddDests.insert(add.destination).inserted else { return nil } + return (destination: add.destination, gateway: add.gateway, isNetwork: add.isNetwork) } + let result = await HelperManager.shared.addRoutesBatch(routes: routes) + addFailedDests = Set(result.failedDestinations) // Record ownership for ALL sources whose destinations succeeded await MainActor.run { @@ -2195,14 +2155,8 @@ final class RouteManager: ObservableObject { // Attempt kernel removal first var failedKernelRemovals: Set = [] if !kernelRemovals.isEmpty { - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.removeRoutesBatch(destinations: kernelRemovals) - failedKernelRemovals = Set(result.failedDestinations) - } else { - for ip in kernelRemovals { - _ = await removeRoute(ip) - } - } + let result = await HelperManager.shared.removeRoutesBatch(destinations: kernelRemovals) + failedKernelRemovals = Set(result.failedDestinations) } // Now remove stale entries, but retain those whose kernel removal failed @@ -3483,73 +3437,13 @@ final class RouteManager: ObservableObject { } private func modifyHostsFile(entries: [(domain: String, ip: String)]) async { - // Use privileged helper if installed - if HelperManager.shared.isHelperInstalled { - let result = await HelperManager.shared.updateHostsFile(entries: entries) - if !result.success { - log(.error, "Helper hosts update failed: \(result.error ?? "unknown")") - } - return - } - - // Fallback: AppleScript with admin privileges (prompts each time) - let marker = "# VPN-BYPASS-MANAGED" - let hostsPath = "/etc/hosts" - - // Read current hosts file - guard let currentContent = try? String(contentsOfFile: hostsPath, encoding: .utf8) else { - log(.error, "Could not read /etc/hosts") + guard HelperManager.shared.isHelperInstalled else { + log(.error, "Cannot modify hosts file: helper not ready (\(HelperManager.shared.helperState.statusText))") return } - - // Remove existing VPN-BYPASS section - var lines = currentContent.components(separatedBy: "\n") - var inSection = false - lines = lines.filter { line in - if line.contains("\(marker) - START") { - inSection = true - return false - } - if line.contains("\(marker) - END") { - inSection = false - return false - } - return !inSection - } - - // Add new section if we have entries - if !entries.isEmpty { - lines.append("") - lines.append("\(marker) - START") - for entry in entries { - lines.append("\(entry.ip) \(entry.domain)") - } - lines.append("\(marker) - END") - } - - // Write back (this will fail without sudo - user needs to grant permission) - let newContent = lines.joined(separator: "\n") - - // Use a randomized heredoc delimiter to prevent injection via crafted content - let delimiter = "VPNBYPASS_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" - - // Use AppleScript to write with admin privileges - let script = """ - do shell script "cat > /etc/hosts << '\(delimiter)' - \(newContent) - \(delimiter)" with administrator privileges - """ - - var error: NSDictionary? - if let appleScript = NSAppleScript(source: script) { - appleScript.executeAndReturnError(&error) - if error == nil { - // Flush DNS cache - let flush = Process() - flush.executableURL = URL(fileURLWithPath: "/usr/bin/dscacheutil") - flush.arguments = ["-flushcache"] - try? flush.run() - } + let result = await HelperManager.shared.updateHostsFile(entries: entries) + if !result.success { + log(.error, "Helper hosts update failed: \(result.error ?? "unknown")") } } From c2847c38a0a942e2215d0eb247bd9d740a490735 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:20:23 +0200 Subject: [PATCH 7/8] fix: resolve strict-concurrency warnings in OnceGate/withXPCDeadline - Mark OnceGate as @unchecked Sendable (thread-safe via NSLock) - Constrain T: Sendable so fallback values can cross concurrency boundaries - Use `sending T` parameter in complete() for safe value transfer - Make withXPCDeadline @MainActor to match all callers and avoid closure isolation boundary crossings Only remaining warning is Apple SDK: SMAppService.register() declared async but compiler sees no async ops (unfixable without SDK change). --- Sources/HelperManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/HelperManager.swift b/Sources/HelperManager.swift index d815a59..b4e1aa0 100644 --- a/Sources/HelperManager.swift +++ b/Sources/HelperManager.swift @@ -475,7 +475,7 @@ final class HelperManager: ObservableObject { /// Ensures exactly-once delivery of a result to a CheckedContinuation. /// Either the XPC reply or the DispatchQueue deadline fires — whichever /// comes first wins, the other is silently dropped. -final class OnceGate { +final class OnceGate: @unchecked Sendable { private let lock = NSLock() private var continuation: CheckedContinuation? @@ -483,7 +483,7 @@ final class OnceGate { self.continuation = continuation } - func complete(_ value: T) { + func complete(_ value: sending T) { lock.lock() let cont = continuation continuation = nil @@ -496,7 +496,7 @@ final class OnceGate { /// a `OnceGate` that it must call `complete()` on when the XPC reply arrives. /// If the deadline fires first, the gate delivers `fallback` and subsequent /// `complete()` calls from the XPC reply are silently dropped. -private func withXPCDeadline( +@MainActor private func withXPCDeadline( seconds: TimeInterval, fallback: T, operation: @escaping (OnceGate) -> Void From f67eaed67afc3c69822ae0590d2683714f0287c3 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:23:39 +0200 Subject: [PATCH 8/8] docs: add v2.1.0 changelog --- docs/CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d6fed2a..d2bf4cb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,24 @@ 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). +## [2.1.0] - 2026-03-31 + +### Changed +- **Native Settings Window** - Replaced persistent `NSPanel` with a standard `NSWindow` featuring minimize, close, and full traffic light controls +- **Official Logo Everywhere** - Menu bar uses a template icon (`menubar-icon.png`) for proper dark/light mode, dropdown header uses the official 3D logo instead of SF Symbols +- **Larger Tab Buttons** - Settings tab items enlarged to 13pt with rounded-rectangle styling for better usability +- **Git-Derived Version** - App version is now stamped from the latest git tag at build time via `PlistBuddy`, eliminating hardcoded version strings + +### Fixed +- **Helper Startup Race** - App no longer hangs at "Setting Up" when the privileged helper is outdated. A new `ensureHelperReady()` preflight verifies the helper is installed, running, and at the expected version before any route application begins +- **XPC Timeout Protection** - All XPC calls now use a hard wall-clock deadline (`OnceGate` + `DispatchQueue.asyncAfter`) instead of cooperative task cancellation, preventing indefinite hangs when the helper is unresponsive +- **Helper State Machine** - New `HelperState` enum (`missing`, `checking`, `installing`, `outdated`, `ready`, `failed`) with reactive UI throughout the app +- **Auto-Update on Version Mismatch** - Helper is automatically reinstalled when version mismatch is detected, with XPC connection reset and post-update verification +- **Helperless Fallback Removal** - All direct `/sbin/route` and AppleScript fallback paths removed; every route-mutating operation now requires the privileged helper, eliminating silent failures and false state +- **Settings Recovery** - Install/Update/Retry button in Settings runs full `ensureHelperReady()` preflight and automatically applies routes + restarts DNS timer if VPN is connected +- **Window Minimize/Reopen** - Minimized settings window is properly restored instead of creating a new instance +- **Strict Concurrency** - `OnceGate` marked `@unchecked Sendable` with `T: Sendable` constraint, eliminating all strict-concurrency warnings from the XPC deadline infrastructure + ## [2.0.0] - 2026-03-30 ### Added