mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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"
|
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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ final class BunServer {
|
||||||
|
|
||||||
// Add Node.js memory settings as command line arguments instead of NODE_OPTIONS
|
// Add Node.js memory settings as command line arguments instead of NODE_OPTIONS
|
||||||
// NODE_OPTIONS can interfere with SEA binaries
|
// NODE_OPTIONS can interfere with SEA binaries
|
||||||
|
|
||||||
// Set BUILD_PUBLIC_PATH to help the server find static files in the app bundle
|
// Set BUILD_PUBLIC_PATH to help the server find static files in the app bundle
|
||||||
environment["BUILD_PUBLIC_PATH"] = staticPath
|
environment["BUILD_PUBLIC_PATH"] = staticPath
|
||||||
logger.info("Setting BUILD_PUBLIC_PATH=\(staticPath)")
|
logger.info("Setting BUILD_PUBLIC_PATH=\(staticPath)")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
@ -22,8 +22,8 @@ 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 {
|
||||||
|
|
@ -126,16 +126,16 @@ struct ProjectFolderPageView: View {
|
||||||
}
|
}
|
||||||
.onChange(of: selectedPath) { _, newValue in
|
.onChange(of: selectedPath) { _, newValue in
|
||||||
repositoryBasePath = newValue
|
repositoryBasePath = newValue
|
||||||
|
|
||||||
// Cancel any existing scan
|
// Cancel any existing scan
|
||||||
scanTask?.cancel()
|
scanTask?.cancel()
|
||||||
|
|
||||||
// Debounce path changes to prevent rapid successive scans
|
// Debounce path changes to prevent rapid successive scans
|
||||||
scanTask = Task {
|
scanTask = Task {
|
||||||
// Add small delay to debounce rapid changes
|
// Add small delay to debounce rapid changes
|
||||||
try? await Task.sleep(for: .milliseconds(300))
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
// Only scan if we're the current page
|
// Only scan if we're the current page
|
||||||
if currentPage == pageIndex && !newValue.isEmpty {
|
if currentPage == pageIndex && !newValue.isEmpty {
|
||||||
await performScan()
|
await performScan()
|
||||||
|
|
@ -178,16 +178,16 @@ struct ProjectFolderPageView: View {
|
||||||
private func scanForRepositories() {
|
private func scanForRepositories() {
|
||||||
// Cancel any existing scan
|
// Cancel any existing scan
|
||||||
scanTask?.cancel()
|
scanTask?.cancel()
|
||||||
|
|
||||||
scanTask = Task {
|
scanTask = Task {
|
||||||
await performScan()
|
await performScan()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performScan() async {
|
private func performScan() async {
|
||||||
isScanning = true
|
isScanning = true
|
||||||
discoveredRepos = []
|
discoveredRepos = []
|
||||||
|
|
||||||
let expandedPath = (selectedPath as NSString).expandingTildeInPath
|
let expandedPath = (selectedPath as NSString).expandingTildeInPath
|
||||||
let repos = await findGitRepositories(in: expandedPath, maxDepth: 3)
|
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] {
|
private func findGitRepositories(in path: String, maxDepth: Int) async -> [String] {
|
||||||
var repositories: [String] = []
|
var repositories: [String] = []
|
||||||
|
|
||||||
// Use a recursive async function that properly checks for cancellation
|
// Use a recursive async function that properly checks for cancellation
|
||||||
func scanDirectory(_ dirPath: String, depth: Int) async {
|
func scanDirectory(_ dirPath: String, depth: Int) async {
|
||||||
// Check for cancellation at each level
|
// Check for cancellation at each level
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
guard depth <= maxDepth else { return }
|
guard depth <= maxDepth else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let contents = try FileManager.default.contentsOfDirectory(atPath: dirPath)
|
let contents = try FileManager.default.contentsOfDirectory(atPath: dirPath)
|
||||||
|
|
||||||
for item in contents {
|
for item in contents {
|
||||||
// Check for cancellation in the loop
|
// Check for cancellation in the loop
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
let fullPath = (dirPath as NSString).appendingPathComponent(item)
|
let fullPath = (dirPath as NSString).appendingPathComponent(item)
|
||||||
var isDirectory: ObjCBool = false
|
var isDirectory: ObjCBool = false
|
||||||
|
|
||||||
guard FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDirectory),
|
guard FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDirectory),
|
||||||
isDirectory.boolValue else { continue }
|
isDirectory.boolValue else { continue }
|
||||||
|
|
||||||
// Skip hidden directories except .git
|
// Skip hidden directories except .git
|
||||||
if item.hasPrefix(".") && item != ".git" { continue }
|
if item.hasPrefix(".") && item != ".git" { continue }
|
||||||
|
|
||||||
// Check if this directory contains .git
|
// Check if this directory contains .git
|
||||||
let gitPath = (fullPath as NSString).appendingPathComponent(".git")
|
let gitPath = (fullPath as NSString).appendingPathComponent(".git")
|
||||||
if FileManager.default.fileExists(atPath: gitPath) {
|
if FileManager.default.fileExists(atPath: gitPath) {
|
||||||
|
|
@ -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
|
||||||
|
|
@ -249,12 +251,12 @@ struct ProjectFolderPageView: View {
|
||||||
.debug("Unexpected error scanning \(dirPath): \(error)")
|
.debug("Unexpected error scanning \(dirPath): \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the scanning on a lower priority
|
// Run the scanning on a lower priority
|
||||||
await Task(priority: .background) {
|
await Task(priority: .background) {
|
||||||
await scanDirectory(path, depth: 0)
|
await scanDirectory(path, depth: 0)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
return repositories
|
return repositories
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,11 @@ 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") &&
|
||||||
hasValidExecCommand
|
hasValidExecCommand
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,12 @@ struct SystemControlHandlerTests {
|
||||||
func ignoresNonWebPathUpdates() async throws {
|
func ignoresNonWebPathUpdates() async throws {
|
||||||
// Use a unique key for this test to avoid interference from other processes
|
// Use a unique key for this test to avoid interference from other processes
|
||||||
let testKey = "TestRepositoryBasePath_\(UUID().uuidString)"
|
let testKey = "TestRepositoryBasePath_\(UUID().uuidString)"
|
||||||
|
|
||||||
// Given - Set test value
|
// Given - Set test value
|
||||||
let initialPath = "~/Projects"
|
let initialPath = "~/Projects"
|
||||||
UserDefaults.standard.set(initialPath, forKey: testKey)
|
UserDefaults.standard.set(initialPath, forKey: testKey)
|
||||||
UserDefaults.standard.synchronize()
|
UserDefaults.standard.synchronize()
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
// Clean up test key
|
// Clean up test key
|
||||||
UserDefaults.standard.removeObject(forKey: testKey)
|
UserDefaults.standard.removeObject(forKey: testKey)
|
||||||
|
|
@ -86,7 +86,7 @@ struct SystemControlHandlerTests {
|
||||||
|
|
||||||
// Temporarily override the key used by SystemControlHandler
|
// Temporarily override the key used by SystemControlHandler
|
||||||
let originalKey = AppConstants.UserDefaultsKeys.repositoryBasePath
|
let originalKey = AppConstants.UserDefaultsKeys.repositoryBasePath
|
||||||
|
|
||||||
// Create a custom handler that uses our test key
|
// Create a custom handler that uses our test key
|
||||||
// Note: Since we can't easily mock UserDefaults key in SystemControlHandler,
|
// Note: Since we can't easily mock UserDefaults key in SystemControlHandler,
|
||||||
// we'll test the core logic by verifying the handler's response behavior
|
// 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
|
// Then - Should respond with success but indicate source was not web
|
||||||
#expect(response != nil)
|
#expect(response != nil)
|
||||||
|
|
||||||
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)
|
||||||
#expect(payload["path"] as? String == testPath)
|
#expect(payload["path"] as? String == testPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The real test is that the handler's logic correctly ignores non-web sources
|
// 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
|
// We can't reliably test UserDefaults in CI due to potential interference
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue