diff --git a/mac/VibeTunnel/Core/Models/AppConstants.swift b/mac/VibeTunnel/Core/Models/AppConstants.swift index 8427d4a5..4f774505 100644 --- a/mac/VibeTunnel/Core/Models/AppConstants.swift +++ b/mac/VibeTunnel/Core/Models/AppConstants.swift @@ -42,6 +42,12 @@ enum AppConstants { static let newSessionTitleMode = "NewSession.titleMode" } + /// Raw string values for DashboardAccessMode + enum DashboardAccessModeRawValues { + static let localhost = "localhost" + static let network = "network" + } + /// Default values for UserDefaults enum Defaults { /// Sleep prevention is enabled by default for better user experience @@ -53,7 +59,7 @@ enum AppConstants { // Server Configuration static let serverPort = 4_020 - static let dashboardAccessMode = "localhost" + static let dashboardAccessMode = DashboardAccessModeRawValues.network static let cleanupOnStartup = true static let authenticationMode = "os" diff --git a/mac/VibeTunnel/Core/Models/DashboardAccessMode.swift b/mac/VibeTunnel/Core/Models/DashboardAccessMode.swift index 74711643..d79d048d 100644 --- a/mac/VibeTunnel/Core/Models/DashboardAccessMode.swift +++ b/mac/VibeTunnel/Core/Models/DashboardAccessMode.swift @@ -6,6 +6,8 @@ import Foundation /// Controls whether the web interface is accessible only locally or /// from other devices on the network. enum DashboardAccessMode: String, CaseIterable { + // Raw values are automatically inferred as "localhost" and "network" + // These must match AppConstants.DashboardAccessModeRawValues case localhost case network diff --git a/mac/VibeTunnel/Core/Services/BunServer.swift b/mac/VibeTunnel/Core/Services/BunServer.swift index 0302cc36..3e652581 100644 --- a/mac/VibeTunnel/Core/Services/BunServer.swift +++ b/mac/VibeTunnel/Core/Services/BunServer.swift @@ -268,7 +268,7 @@ final class BunServer { // Add Node.js memory settings as command line arguments instead of NODE_OPTIONS // NODE_OPTIONS can interfere with SEA binaries - + // Set BUILD_PUBLIC_PATH to help the server find static files in the app bundle environment["BUILD_PUBLIC_PATH"] = staticPath logger.info("Setting BUILD_PUBLIC_PATH=\(staticPath)") diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift index 2964891e..2d9e27f8 100644 --- a/mac/VibeTunnel/Core/Services/ServerManager.swift +++ b/mac/VibeTunnel/Core/Services/ServerManager.swift @@ -61,16 +61,24 @@ class ServerManager { var bindAddress: String { get { - let mode = DashboardAccessMode( - rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "" - ) ?? - .localhost + // Get the raw value from UserDefaults, defaulting to the app default + let rawValue = UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? AppConstants.Defaults + .dashboardAccessMode + let mode = DashboardAccessMode(rawValue: rawValue) ?? .network + + // Log for debugging + logger + .debug( + "bindAddress getter: rawValue='\(rawValue)', mode=\(mode.rawValue), bindAddress=\(mode.bindAddress)" + ) + return mode.bindAddress } set { // Find the mode that matches this bind address if let mode = DashboardAccessMode.allCases.first(where: { $0.bindAddress == newValue }) { UserDefaults.standard.set(mode.rawValue, forKey: "dashboardAccessMode") + logger.debug("bindAddress setter: set mode=\(mode.rawValue) for bindAddress=\(newValue)") } } } @@ -209,7 +217,9 @@ class ServerManager { do { let server = BunServer() server.port = port - server.bindAddress = bindAddress + let currentBindAddress = bindAddress + server.bindAddress = currentBindAddress + logger.info("Starting server with port=\(self.port), bindAddress=\(currentBindAddress)") // Set up crash handler server.onCrash = { [weak self] exitCode in diff --git a/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift index 0caac615..27cf5cf2 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift @@ -6,7 +6,7 @@ struct DashboardSettingsView: View { @AppStorage(AppConstants.UserDefaultsKeys.serverPort) private var serverPort = "4020" @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) - private var accessModeString = DashboardAccessMode.network.rawValue + private var accessModeString = AppConstants.Defaults.dashboardAccessMode @Environment(ServerManager.self) private var serverManager diff --git a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift index e115679c..27c721ee 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift @@ -19,7 +19,7 @@ struct GeneralSettingsView: View { @AppStorage(AppConstants.UserDefaultsKeys.serverPort) private var serverPort = "4020" @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) - private var accessModeString = DashboardAccessMode.network.rawValue + private var accessModeString = AppConstants.Defaults.dashboardAccessMode @State private var isCheckingForUpdates = false @State private var localIPAddress: String? diff --git a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift index a8fb8a23..63942fbc 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift @@ -11,7 +11,7 @@ struct RemoteAccessSettingsView: View { @AppStorage(AppConstants.UserDefaultsKeys.serverPort) private var serverPort = "4020" @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) - private var accessModeString = DashboardAccessMode.network.rawValue + private var accessModeString = AppConstants.Defaults.dashboardAccessMode @Environment(NgrokService.self) private var ngrokService diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/ProjectFolderPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/ProjectFolderPageView.swift index 8de36e66..24c6a684 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/ProjectFolderPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/ProjectFolderPageView.swift @@ -1,5 +1,5 @@ -import SwiftUI import OSLog +import SwiftUI /// Project folder configuration page in the welcome flow. /// @@ -22,8 +22,8 @@ struct ProjectFolderPageView: View { @State private var scanTask: Task? @Binding var currentPage: Int - - // Page index for ProjectFolderPageView in the welcome flow + + /// Page index for ProjectFolderPageView in the welcome flow private let pageIndex = 4 var body: some View { @@ -126,16 +126,16 @@ struct ProjectFolderPageView: View { } .onChange(of: selectedPath) { _, newValue in repositoryBasePath = newValue - + // Cancel any existing scan scanTask?.cancel() - + // Debounce path changes to prevent rapid successive scans scanTask = Task { // Add small delay to debounce rapid changes try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } - + // Only scan if we're the current page if currentPage == pageIndex && !newValue.isEmpty { await performScan() @@ -178,16 +178,16 @@ struct ProjectFolderPageView: View { private func scanForRepositories() { // Cancel any existing scan scanTask?.cancel() - + scanTask = Task { await performScan() } } - + private func performScan() async { isScanning = true discoveredRepos = [] - + let expandedPath = (selectedPath as NSString).expandingTildeInPath let repos = await findGitRepositories(in: expandedPath, maxDepth: 3) @@ -204,29 +204,29 @@ struct ProjectFolderPageView: View { private func findGitRepositories(in path: String, maxDepth: Int) async -> [String] { var repositories: [String] = [] - + // Use a recursive async function that properly checks for cancellation func scanDirectory(_ dirPath: String, depth: Int) async { // Check for cancellation at each level guard !Task.isCancelled else { return } guard depth <= maxDepth else { return } - + do { let contents = try FileManager.default.contentsOfDirectory(atPath: dirPath) - + for item in contents { // Check for cancellation in the loop try Task.checkCancellation() - + let fullPath = (dirPath as NSString).appendingPathComponent(item) var isDirectory: ObjCBool = false - + guard FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDirectory), isDirectory.boolValue else { continue } - + // Skip hidden directories except .git if item.hasPrefix(".") && item != ".git" { continue } - + // Check if this directory contains .git let gitPath = (fullPath as NSString).appendingPathComponent(".git") if FileManager.default.fileExists(atPath: gitPath) { @@ -239,7 +239,9 @@ struct ProjectFolderPageView: View { } catch is CancellationError { // Task was cancelled, stop scanning return - } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoPermissionError { + } catch let error as NSError + where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoPermissionError + { // Silently ignore permission errors - common for system directories } catch let error as NSError where error.domain == NSPOSIXErrorDomain && error.code == 1 { // Operation not permitted - another common permission error @@ -249,12 +251,12 @@ struct ProjectFolderPageView: View { .debug("Unexpected error scanning \(dirPath): \(error)") } } - + // Run the scanning on a lower priority await Task(priority: .background) { await scanDirectory(path, depth: 0) }.value - + return repositories } } diff --git a/mac/VibeTunnel/Utilities/CLIInstaller.swift b/mac/VibeTunnel/Utilities/CLIInstaller.swift index f13d3604..ff2a676f 100644 --- a/mac/VibeTunnel/Utilities/CLIInstaller.swift +++ b/mac/VibeTunnel/Utilities/CLIInstaller.swift @@ -68,8 +68,11 @@ final class CLIInstaller { // Verify it's our wrapper script with all expected components // Check for the exec command with flexible quoting and optional arguments // Allow for optional variables or arguments between $VIBETUNNEL_BIN and fwd - let hasValidExecCommand = content.range(of: #"exec\s+["']?\$VIBETUNNEL_BIN["']?\s+fwd"#, options: .regularExpression) != nil - + let hasValidExecCommand = content.range( + of: #"exec\s+["']?\$VIBETUNNEL_BIN["']?\s+fwd"#, + options: .regularExpression + ) != nil + if content.contains("VibeTunnel CLI wrapper") && content.contains("$TRY_PATH/Contents/Resources/vibetunnel") && hasValidExecCommand diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index f573476b..9c9790bc 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -126,8 +126,8 @@ struct VibeTunnelApp: App { /// coordinator for application-wide events and services. @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { - // Needed for some gross menu item highlight hack - weak static var shared: AppDelegate? + // Needed for menu item highlight hack + static weak var shared: AppDelegate? override init() { super.init() Self.shared = self @@ -180,7 +180,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // Register default values UserDefaults.standard.register(defaults: [ - "showInDock": true // Default to showing in dock + "showInDock": true, // Default to showing in dock + "dashboardAccessMode": AppConstants.Defaults.dashboardAccessMode ]) // Initialize Sparkle updater manager diff --git a/mac/VibeTunnelTests/ModelTests.swift b/mac/VibeTunnelTests/ModelTests.swift index c7cf0346..6f8efd32 100644 --- a/mac/VibeTunnelTests/ModelTests.swift +++ b/mac/VibeTunnelTests/ModelTests.swift @@ -138,8 +138,8 @@ struct ModelTests { @Test("DashboardAccessMode raw values") func rawValues() throws { - #expect(DashboardAccessMode.localhost.rawValue == "localhost") - #expect(DashboardAccessMode.network.rawValue == "network") + #expect(DashboardAccessMode.localhost.rawValue == AppConstants.DashboardAccessModeRawValues.localhost) + #expect(DashboardAccessMode.network.rawValue == AppConstants.DashboardAccessModeRawValues.network) } @Test("DashboardAccessMode descriptions") @@ -147,6 +147,26 @@ struct ModelTests { #expect(DashboardAccessMode.localhost.description.contains("this Mac")) #expect(DashboardAccessMode.network.description.contains("other devices")) } + + @Test("DashboardAccessMode default value") + func defaultValue() throws { + // Verify the default is network mode + #expect(AppConstants.Defaults.dashboardAccessMode == DashboardAccessMode.network.rawValue) + + // Verify we can create a mode from the default + let mode = DashboardAccessMode(rawValue: AppConstants.Defaults.dashboardAccessMode) + #expect(mode == .network) + #expect(mode?.bindAddress == "0.0.0.0") + } + + @Test("DashboardAccessMode from invalid raw value") + func invalidRawValue() throws { + let mode = DashboardAccessMode(rawValue: "invalid") + #expect(mode == nil) + + let emptyMode = DashboardAccessMode(rawValue: "") + #expect(emptyMode == nil) + } } // MARK: - UpdateChannel Tests @@ -246,6 +266,58 @@ struct ModelTests { @Test("UserDefaults keys") func userDefaultsKeys() throws { #expect(AppConstants.UserDefaultsKeys.welcomeVersion == "welcomeVersion") + #expect(AppConstants.UserDefaultsKeys.dashboardAccessMode == "dashboardAccessMode") + #expect(AppConstants.UserDefaultsKeys.serverPort == "serverPort") + } + + @Test("AppConstants default values") + func defaultValues() throws { + // Verify dashboard access mode default + #expect(AppConstants.Defaults.dashboardAccessMode == DashboardAccessMode.network.rawValue) + + // Verify server port default + #expect(AppConstants.Defaults.serverPort == 4_020) + + // Verify other defaults + #expect(AppConstants.Defaults.cleanupOnStartup == true) + #expect(AppConstants.Defaults.showInDock == false) + } + + @Test("AppConstants stringValue helper with dashboardAccessMode") + func stringValueHelper() throws { + // Store original value + let originalValue = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.dashboardAccessMode) + + defer { + // Restore original value + if let originalValue { + UserDefaults.standard.set(originalValue, forKey: AppConstants.UserDefaultsKeys.dashboardAccessMode) + } else { + UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.dashboardAccessMode) + } + } + + // When key doesn't exist, should return default + UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.dashboardAccessMode) + let defaultValue = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.dashboardAccessMode) + #expect(defaultValue == AppConstants.Defaults.dashboardAccessMode) + #expect(defaultValue == AppConstants.DashboardAccessModeRawValues.network) // Our default is network + + // When key exists with localhost, should return localhost + UserDefaults.standard.set( + AppConstants.DashboardAccessModeRawValues.localhost, + forKey: AppConstants.UserDefaultsKeys.dashboardAccessMode + ) + let localhostValue = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.dashboardAccessMode) + #expect(localhostValue == AppConstants.DashboardAccessModeRawValues.localhost) + + // When key exists with network, should return network + UserDefaults.standard.set( + AppConstants.DashboardAccessModeRawValues.network, + forKey: AppConstants.UserDefaultsKeys.dashboardAccessMode + ) + let networkValue = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.dashboardAccessMode) + #expect(networkValue == AppConstants.DashboardAccessModeRawValues.network) } } } diff --git a/mac/VibeTunnelTests/ServerManagerTests.swift b/mac/VibeTunnelTests/ServerManagerTests.swift index bc06676d..c28b5c74 100644 --- a/mac/VibeTunnelTests/ServerManagerTests.swift +++ b/mac/VibeTunnelTests/ServerManagerTests.swift @@ -136,6 +136,101 @@ final class ServerManagerTests { UserDefaults.standard.set(originalMode, forKey: "dashboardAccessMode") } + @Test("Bind address default value") + func bindAddressDefaultValue() async throws { + // Store original value + let originalMode = UserDefaults.standard.string(forKey: "dashboardAccessMode") + + // Remove the key to test default behavior + UserDefaults.standard.removeObject(forKey: "dashboardAccessMode") + UserDefaults.standard.synchronize() + + // Should default to network mode (0.0.0.0) + #expect(manager.bindAddress == "0.0.0.0") + + // Restore original value + if let originalMode { + UserDefaults.standard.set(originalMode, forKey: "dashboardAccessMode") + } + } + + @Test("Bind address setter") + func bindAddressSetter() async throws { + // Store original value + let originalMode = UserDefaults.standard.string(forKey: "dashboardAccessMode") + + // Test setting via bind address + manager.bindAddress = "127.0.0.1" + #expect(UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues + .localhost + ) + #expect(manager.bindAddress == "127.0.0.1") + + manager.bindAddress = "0.0.0.0" + #expect(UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues + .network + ) + #expect(manager.bindAddress == "0.0.0.0") + + // Test invalid bind address (should not change UserDefaults) + manager.bindAddress = "192.168.1.1" + #expect(manager.bindAddress == "0.0.0.0") // Should still be the last valid value + + // Restore original value + if let originalMode { + UserDefaults.standard.set(originalMode, forKey: "dashboardAccessMode") + } else { + UserDefaults.standard.removeObject(forKey: "dashboardAccessMode") + } + } + + @Test("Bind address persistence across server restarts") + func bindAddressPersistence() async throws { + // Store original values + let originalMode = UserDefaults.standard.string(forKey: "dashboardAccessMode") + let originalPort = manager.port + + // Set to localhost mode + UserDefaults.standard.set(AppConstants.DashboardAccessModeRawValues.localhost, forKey: "dashboardAccessMode") + manager.port = "4021" + + // Start server + await manager.start() + try await Task.sleep(for: .milliseconds(500)) + + // Verify bind address + #expect(manager.bindAddress == "127.0.0.1") + + // Restart server + await manager.restart() + try await Task.sleep(for: .milliseconds(500)) + + // Bind address should persist + #expect(manager.bindAddress == "127.0.0.1") + #expect(UserDefaults.standard.string(forKey: "dashboardAccessMode") == AppConstants.DashboardAccessModeRawValues + .localhost + ) + + // Change to network mode + UserDefaults.standard.set(AppConstants.DashboardAccessModeRawValues.network, forKey: "dashboardAccessMode") + + // Restart again + await manager.restart() + try await Task.sleep(for: .milliseconds(500)) + + // Should now be network mode + #expect(manager.bindAddress == "0.0.0.0") + + // Cleanup + await manager.stop() + manager.port = originalPort + if let originalMode { + UserDefaults.standard.set(originalMode, forKey: "dashboardAccessMode") + } else { + UserDefaults.standard.removeObject(forKey: "dashboardAccessMode") + } + } + // MARK: - Concurrent Operations Tests @Test("Concurrent server operations are serialized", .tags(.concurrency)) diff --git a/mac/VibeTunnelTests/SystemControlHandlerTests.swift b/mac/VibeTunnelTests/SystemControlHandlerTests.swift index e653a7ac..32390eeb 100644 --- a/mac/VibeTunnelTests/SystemControlHandlerTests.swift +++ b/mac/VibeTunnelTests/SystemControlHandlerTests.swift @@ -72,12 +72,12 @@ struct SystemControlHandlerTests { func ignoresNonWebPathUpdates() async throws { // Use a unique key for this test to avoid interference from other processes let testKey = "TestRepositoryBasePath_\(UUID().uuidString)" - + // Given - Set test value let initialPath = "~/Projects" UserDefaults.standard.set(initialPath, forKey: testKey) UserDefaults.standard.synchronize() - + defer { // Clean up test key UserDefaults.standard.removeObject(forKey: testKey) @@ -86,7 +86,7 @@ struct SystemControlHandlerTests { // Temporarily override the key used by SystemControlHandler let originalKey = AppConstants.UserDefaultsKeys.repositoryBasePath - + // Create a custom handler that uses our test key // Note: Since we can't easily mock UserDefaults key in SystemControlHandler, // we'll test the core logic by verifying the handler's response behavior @@ -108,16 +108,17 @@ struct SystemControlHandlerTests { // Then - Should respond with success but indicate source was not web #expect(response != nil) - + if let responseData = response, let responseJson = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any], - let payload = responseJson["payload"] as? [String: Any] { + let payload = responseJson["payload"] as? [String: Any] + { // The handler should return success but the actual UserDefaults update // should only happen for source="web" #expect(payload["success"] as? Bool == true) #expect(payload["path"] as? String == testPath) } - + // The real test is that the handler's logic correctly ignores non-web sources // We can't reliably test UserDefaults in CI due to potential interference }