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/HelperManager.swift b/Sources/HelperManager.swift index a0c7647..b4e1aa0 100644 --- a/Sources/HelperManager.swift +++ b/Sources/HelperManager.swift @@ -1,354 +1,515 @@ // 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) } - } else if isHelperInstalled { - // Check for version mismatch and auto-update if needed - checkAndUpdateHelperVersion() + helperState = .missing + print("🔐 Helper not found, attempting install...") + let installed = await installHelper() + if !installed { + helperState = .failed(installationError ?? "Installation failed") + return false + } + // 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: - 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 - helper.getVersion { version in - Task { @MainActor in - self?.helperVersion = version - } - } + + // 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 } - } else { - isHelperInstalled = false - helperVersion = nil } + + connection.resume() + xpcConnection = connection + return connection } - + + /// 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 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 + once.complete(version) + } + } + return result + } + // 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 hard XPC deadline) + 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 - helper.addRoute(destination: destination, gateway: gateway, isNetwork: isNetwork) { success, error in - continuation.resume(returning: (success, error)) - } + + let connection = getOrCreateConnection() + 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 + once.complete((success, error)) } } + + if result == fallback { dropXPCConnection() } + 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 - helper.removeRoute(destination: destination) { success, error in - continuation.resume(returning: (success, error)) - } + + let connection = getOrCreateConnection() + 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 + once.complete((success, error)) } } + + if result == fallback { dropXPCConnection() } + 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 - 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")) - return await withCheckedContinuation { continuation in - connectToHelper { helper in - helper.addRoutesBatch(routes: dictRoutes) { successCount, failureCount, failedDestinations, error in - continuation.resume(returning: (successCount, failureCount, failedDestinations, error)) - } + let connection = getOrCreateConnection() + 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 + once.complete((successCount, failureCount, failedDestinations, error)) } } + + if result.3 == "XPC timeout" { dropXPCConnection() } + 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 - helper.removeRoutesBatch(destinations: destinations) { successCount, failureCount, failedDestinations, error in - continuation.resume(returning: (successCount, failureCount, failedDestinations, error)) - } + let timeout = xpcTimeout + Double(destinations.count) * 0.1 + let fallback = (0, destinations.count, destinations, Optional("XPC timeout")) + + let connection = getOrCreateConnection() + 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 + once.complete((successCount, failureCount, failedDestinations, error)) } } + + if result.3 == "XPC timeout" { dropXPCConnection() } + 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 - helper.updateHostsFile(entries: dictEntries) { success, error in - if success { - helper.flushDNSCache { _ in - continuation.resume(returning: (true, nil)) - } - } else { - continuation.resume(returning: (false, error)) + + let connection = getOrCreateConnection() + 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 + once.complete((true, nil)) } + } else { + once.complete((false, error)) } } } + + if result == fallback { dropXPCConnection() } + return result } - + func clearHostsFile() async -> (success: Bool, error: String?) { return await updateHostsFile(entries: []) } } + +// MARK: - XPC Deadline (hard timeout via DispatchQueue timer) + +/// 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: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func complete(_ value: sending 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. +@MainActor 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/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/RouteManager.swift b/Sources/RouteManager.swift index 67faf72..56d1aa1 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)") @@ -734,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 { @@ -758,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 { @@ -1078,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) } @@ -1360,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 @@ -1391,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") } } @@ -1477,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))") } } @@ -1634,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 { @@ -1647,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 } } @@ -1671,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") } } @@ -1864,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 { @@ -2194,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 @@ -3355,40 +3310,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 { @@ -3498,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")") } } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 6e840e3..d5ee4f8 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 @@ -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")) @@ -1763,7 +1809,14 @@ 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 and start the DNS refresh lifecycle. + await routeManager.detectAndApplyRoutesAsync() + routeManager.startDNSRefreshTimer() + } } } } @@ -2417,85 +2470,80 @@ struct LogRow: View { // MARK: - Settings Window Controller @MainActor -final class SettingsWindowController { +final class SettingsWindowController: NSObject, NSWindowDelegate { 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 (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 panel was closed, remove it so we create a fresh one - if panel != nil && !panel!.isVisible { - panel = 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, .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.contentMinSize = NSSize(width: 580, height: 620) + window.contentMaxSize = NSSize(width: 580, height: 620) + window.delegate = self + 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 } - + + func windowWillClose(_ notification: Notification) { + window = nil + } + 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 +2554,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) diff --git a/Sources/VPNBypassApp.swift b/Sources/VPNBypassApp.swift index 812079b..5021f0f 100644 --- a/Sources/VPNBypassApp.swift +++ b/Sources/VPNBypassApp.swift @@ -57,20 +57,32 @@ 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). 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() - + // Start the auto DNS refresh timer RouteManager.shared.startDNSRefreshTimer() - + // Mark startup as complete hasCompletedInitialStartup = true lastSuccessfulVPNCheck = Date() 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