mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-09 11:55:53 +00:00
Fix bind address reverting to localhost after server restart (#404)
This commit is contained in:
parent
453d888731
commit
44946f1006
13 changed files with 234 additions and 42 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue