Fix bind address reverting to localhost after server restart (#404)

This commit is contained in:
Peter Steinberger 2025-07-18 08:02:41 +02:00 committed by GitHub
parent 453d888731
commit 44946f1006
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 234 additions and 42 deletions

View file

@ -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"

View file

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

View file

@ -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)")

View file

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

View file

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

View file

@ -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?

View file

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

View file

@ -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<Void, Never>?
@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
}
}

View file

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

View file

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

View file

@ -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)
}
}
}

View file

@ -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))

View file

@ -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
}