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" static let newSessionTitleMode = "NewSession.titleMode"
} }
/// Raw string values for DashboardAccessMode
enum DashboardAccessModeRawValues {
static let localhost = "localhost"
static let network = "network"
}
/// Default values for UserDefaults /// Default values for UserDefaults
enum Defaults { enum Defaults {
/// Sleep prevention is enabled by default for better user experience /// Sleep prevention is enabled by default for better user experience
@ -53,7 +59,7 @@ enum AppConstants {
// Server Configuration // Server Configuration
static let serverPort = 4_020 static let serverPort = 4_020
static let dashboardAccessMode = "localhost" static let dashboardAccessMode = DashboardAccessModeRawValues.network
static let cleanupOnStartup = true static let cleanupOnStartup = true
static let authenticationMode = "os" static let authenticationMode = "os"

View file

@ -6,6 +6,8 @@ import Foundation
/// Controls whether the web interface is accessible only locally or /// Controls whether the web interface is accessible only locally or
/// from other devices on the network. /// from other devices on the network.
enum DashboardAccessMode: String, CaseIterable { enum DashboardAccessMode: String, CaseIterable {
// Raw values are automatically inferred as "localhost" and "network"
// These must match AppConstants.DashboardAccessModeRawValues
case localhost case localhost
case network case network

View file

@ -61,16 +61,24 @@ class ServerManager {
var bindAddress: String { var bindAddress: String {
get { get {
let mode = DashboardAccessMode( // Get the raw value from UserDefaults, defaulting to the app default
rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "" let rawValue = UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? AppConstants.Defaults
) ?? .dashboardAccessMode
.localhost let mode = DashboardAccessMode(rawValue: rawValue) ?? .network
// Log for debugging
logger
.debug(
"bindAddress getter: rawValue='\(rawValue)', mode=\(mode.rawValue), bindAddress=\(mode.bindAddress)"
)
return mode.bindAddress return mode.bindAddress
} }
set { set {
// Find the mode that matches this bind address // Find the mode that matches this bind address
if let mode = DashboardAccessMode.allCases.first(where: { $0.bindAddress == newValue }) { if let mode = DashboardAccessMode.allCases.first(where: { $0.bindAddress == newValue }) {
UserDefaults.standard.set(mode.rawValue, forKey: "dashboardAccessMode") 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 { do {
let server = BunServer() let server = BunServer()
server.port = port 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 // Set up crash handler
server.onCrash = { [weak self] exitCode in server.onCrash = { [weak self] exitCode in

View file

@ -6,7 +6,7 @@ struct DashboardSettingsView: View {
@AppStorage(AppConstants.UserDefaultsKeys.serverPort) @AppStorage(AppConstants.UserDefaultsKeys.serverPort)
private var serverPort = "4020" private var serverPort = "4020"
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
private var accessModeString = DashboardAccessMode.network.rawValue private var accessModeString = AppConstants.Defaults.dashboardAccessMode
@Environment(ServerManager.self) @Environment(ServerManager.self)
private var serverManager private var serverManager

View file

@ -19,7 +19,7 @@ struct GeneralSettingsView: View {
@AppStorage(AppConstants.UserDefaultsKeys.serverPort) @AppStorage(AppConstants.UserDefaultsKeys.serverPort)
private var serverPort = "4020" private var serverPort = "4020"
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
private var accessModeString = DashboardAccessMode.network.rawValue private var accessModeString = AppConstants.Defaults.dashboardAccessMode
@State private var isCheckingForUpdates = false @State private var isCheckingForUpdates = false
@State private var localIPAddress: String? @State private var localIPAddress: String?

View file

@ -11,7 +11,7 @@ struct RemoteAccessSettingsView: View {
@AppStorage(AppConstants.UserDefaultsKeys.serverPort) @AppStorage(AppConstants.UserDefaultsKeys.serverPort)
private var serverPort = "4020" private var serverPort = "4020"
@AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode) @AppStorage(AppConstants.UserDefaultsKeys.dashboardAccessMode)
private var accessModeString = DashboardAccessMode.network.rawValue private var accessModeString = AppConstants.Defaults.dashboardAccessMode
@Environment(NgrokService.self) @Environment(NgrokService.self)
private var ngrokService private var ngrokService

View file

@ -1,5 +1,5 @@
import SwiftUI
import OSLog import OSLog
import SwiftUI
/// Project folder configuration page in the welcome flow. /// Project folder configuration page in the welcome flow.
/// ///
@ -23,7 +23,7 @@ struct ProjectFolderPageView: View {
@State private var scanTask: Task<Void, Never>? @State private var scanTask: Task<Void, Never>?
@Binding var currentPage: Int @Binding var currentPage: Int
// Page index for ProjectFolderPageView in the welcome flow /// Page index for ProjectFolderPageView in the welcome flow
private let pageIndex = 4 private let pageIndex = 4
var body: some View { var body: some View {
@ -239,7 +239,9 @@ struct ProjectFolderPageView: View {
} catch is CancellationError { } catch is CancellationError {
// Task was cancelled, stop scanning // Task was cancelled, stop scanning
return 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 // Silently ignore permission errors - common for system directories
} catch let error as NSError where error.domain == NSPOSIXErrorDomain && error.code == 1 { } catch let error as NSError where error.domain == NSPOSIXErrorDomain && error.code == 1 {
// Operation not permitted - another common permission error // Operation not permitted - another common permission error

View file

@ -68,7 +68,10 @@ final class CLIInstaller {
// Verify it's our wrapper script with all expected components // Verify it's our wrapper script with all expected components
// Check for the exec command with flexible quoting and optional arguments // Check for the exec command with flexible quoting and optional arguments
// Allow for optional variables or arguments between $VIBETUNNEL_BIN and fwd // 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") && if content.contains("VibeTunnel CLI wrapper") &&
content.contains("$TRY_PATH/Contents/Resources/vibetunnel") && content.contains("$TRY_PATH/Contents/Resources/vibetunnel") &&

View file

@ -126,8 +126,8 @@ struct VibeTunnelApp: App {
/// coordinator for application-wide events and services. /// coordinator for application-wide events and services.
@MainActor @MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
// Needed for some gross menu item highlight hack // Needed for menu item highlight hack
weak static var shared: AppDelegate? static weak var shared: AppDelegate?
override init() { override init() {
super.init() super.init()
Self.shared = self Self.shared = self
@ -180,7 +180,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
// Register default values // Register default values
UserDefaults.standard.register(defaults: [ 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 // Initialize Sparkle updater manager

View file

@ -138,8 +138,8 @@ struct ModelTests {
@Test("DashboardAccessMode raw values") @Test("DashboardAccessMode raw values")
func rawValues() throws { func rawValues() throws {
#expect(DashboardAccessMode.localhost.rawValue == "localhost") #expect(DashboardAccessMode.localhost.rawValue == AppConstants.DashboardAccessModeRawValues.localhost)
#expect(DashboardAccessMode.network.rawValue == "network") #expect(DashboardAccessMode.network.rawValue == AppConstants.DashboardAccessModeRawValues.network)
} }
@Test("DashboardAccessMode descriptions") @Test("DashboardAccessMode descriptions")
@ -147,6 +147,26 @@ struct ModelTests {
#expect(DashboardAccessMode.localhost.description.contains("this Mac")) #expect(DashboardAccessMode.localhost.description.contains("this Mac"))
#expect(DashboardAccessMode.network.description.contains("other devices")) #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 // MARK: - UpdateChannel Tests
@ -246,6 +266,58 @@ struct ModelTests {
@Test("UserDefaults keys") @Test("UserDefaults keys")
func userDefaultsKeys() throws { func userDefaultsKeys() throws {
#expect(AppConstants.UserDefaultsKeys.welcomeVersion == "welcomeVersion") #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") 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 // MARK: - Concurrent Operations Tests
@Test("Concurrent server operations are serialized", .tags(.concurrency)) @Test("Concurrent server operations are serialized", .tags(.concurrency))

View file

@ -111,7 +111,8 @@ struct SystemControlHandlerTests {
if let responseData = response, if let responseData = response,
let responseJson = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any], 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 // The handler should return success but the actual UserDefaults update
// should only happen for source="web" // should only happen for source="web"
#expect(payload["success"] as? Bool == true) #expect(payload["success"] as? Bool == true)