refactor: extract all magic strings to centralized constants (#444)

This commit is contained in:
Peter Steinberger 2025-07-21 14:22:45 +02:00 committed by GitHub
parent f8a7cf9537
commit 958973c7b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 759 additions and 640 deletions

View file

@ -0,0 +1,32 @@
import Foundation
/// Centralized API endpoints for the VibeTunnel server
enum APIEndpoints {
// MARK: - Session Management
static let sessions = "/api/sessions"
static func sessionDetail(id: String) -> String {
"/api/sessions/\(id)"
}
static func sessionInput(id: String) -> String {
"/api/sessions/\(id)/input"
}
static func sessionStream(id: String) -> String {
"/api/sessions/\(id)/stream"
}
static func sessionResize(id: String) -> String {
"/api/sessions/\(id)/resize"
}
// MARK: - Cleanup
static let cleanupExited = "/api/cleanup-exited"
// MARK: - WebSocket
static let buffers = "/buffers"
}

View file

@ -0,0 +1,57 @@
import Foundation
/// Centralized bundle identifiers for external applications
enum BundleIdentifiers {
// MARK: - VibeTunnel
static let main = "sh.vibetunnel.vibetunnel"
static let vibeTunnel = "sh.vibetunnel.vibetunnel"
// MARK: - Terminal Applications
static let terminal = "com.apple.Terminal"
static let iTerm2 = "com.googlecode.iterm2"
static let ghostty = "com.mitchellh.ghostty"
static let wezterm = "com.github.wez.wezterm"
static let warp = "dev.warp.Warp-Stable"
static let alacritty = "org.alacritty"
static let hyper = "co.zeit.hyper"
static let kitty = "net.kovidgoyal.kitty"
enum Terminal {
static let apple = "com.apple.Terminal"
static let iTerm2 = "com.googlecode.iterm2"
static let ghostty = "com.mitchellh.ghostty"
static let wezTerm = "com.github.wez.wezterm"
}
// MARK: - Git Applications
static let cursor = "com.todesktop.230313mzl4w4u92"
static let fork = "com.DanPristupov.Fork"
static let githubDesktop = "com.github.GitHubClient"
static let gitup = "co.gitup.mac"
static let juxtaCode = "com.naiveapps.juxtacode"
static let sourcetree = "com.torusknot.SourceTreeNotMAS"
static let sublimeMerge = "com.sublimemerge"
static let tower = "com.fournova.Tower3"
static let vscode = "com.microsoft.VSCode"
static let windsurf = "com.codeiumapp.windsurf"
enum Git {
static let githubDesktop = "com.todesktop.230313mzl4w4u92"
static let fork = "com.DanPristupov.Fork"
static let githubClient = "com.github.GitHubClient"
static let juxtaCode = "com.naiveapps.juxtacode"
static let sourceTree = "com.torusknot.SourceTreeNotMAS"
static let sublimeMerge = "com.sublimemerge"
static let tower = "com.fournova.Tower3"
}
// MARK: - Code Editors
enum Editor {
static let vsCode = "com.microsoft.VSCode"
static let windsurf = "com.codeiumapp.windsurf"
}
}

View file

@ -0,0 +1,21 @@
import Foundation
/// Centralized environment variable names
enum EnvironmentKeys {
// MARK: - System Environment
static let path = "PATH"
static let lang = "LANG"
// MARK: - Test Environment
static let xcTestConfigurationFilePath = "XCTestConfigurationFilePath"
static let ci = "CI"
// MARK: - VibeTunnel Environment
static let parentPID = "PARENT_PID"
static let useDevelopmentServer = "USE_DEVELOPMENT_SERVER"
static let authenticationMode = "AUTHENTICATION_MODE"
static let authToken = "AUTH_TOKEN"
}

View file

@ -0,0 +1,102 @@
import Foundation
/// Centralized error messages and user-facing strings
enum ErrorMessages {
// MARK: - Notification Errors
static func notificationPermissionError(_ error: Error) -> String {
"Failed to request notification permissions: \(error.localizedDescription)"
}
// MARK: - Session Errors
static let sessionNotFound = "Session not found"
static let operationTimeout = "Operation timed out"
static let invalidRequest = "Invalid request"
static let sessionNameEmpty = "Session name cannot be empty"
static let terminalWindowNotFound = "Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel."
static func windowNotFoundForSession(_ sessionID: String) -> String {
"Could not find window for session \(sessionID)"
}
static func windowCloseFailedForSession(_ sessionID: String) -> String {
"Failed to close window for session \(sessionID)"
}
// MARK: - Launch/Login Errors
static func launchAtLoginError(_ enabled: Bool, _ error: Error) -> String {
"Failed to \(enabled ? "register" : "unregister") for launch at login: \(error.localizedDescription)"
}
// MARK: - Process Errors
static func errorOutputReadError(_ error: Error) -> String {
"Could not read error output: \(error.localizedDescription)"
}
static func modificationDateError(_ path: String) -> String {
"Could not get modification date for \(path)"
}
static func processTerminationError(_ pid: Int) -> String {
"Failed to terminate process with PID \(pid)"
}
static func processLaunchError(_ error: Error) -> String {
"Failed to launch process: \(error.localizedDescription)"
}
// MARK: - Keychain Errors
static let keychainSaveError = "Failed to save the auth token to the keychain. Please check your keychain permissions and try again."
static let keychainRetrieveError = "Failed to retrieve token from keychain"
static let keychainAccessError = "Failed to access auth token. Please try again."
static let keychainSaveTokenError = "Failed to save token to keychain"
// MARK: - Server Errors
static let serverRestartError = "Failed to Restart Server"
static let socketCreationError = "Failed to create socket for port check"
static let serverNotRunning = "Server is not running"
static let invalidServerURL = "Invalid server URL"
static let invalidServerResponse = "Invalid server response"
// MARK: - URL Scheme Errors
static let urlSchemeOpenError = "Failed to open URL scheme"
static func terminalLaunchError(_ terminalName: String) -> String {
"Failed to launch terminal: \(terminalName)"
}
// MARK: - Tunnel Errors
static func tunnelCreationError(_ error: Error) -> String {
"Failed to create tunnel: \(error.localizedDescription)"
}
static let ngrokPublicURLNotFound = "Could not find public URL in ngrok output"
static func ngrokStartError(_ error: Error) -> String {
"Failed to start ngrok: \(error.localizedDescription)"
}
static let ngrokNotInstalled = "ngrok is not installed. Please install it using 'brew install ngrok' or download from ngrok.com"
static let ngrokAuthTokenMissing = "ngrok auth token is missing. Please add it in Settings"
static let invalidNgrokConfiguration = "Invalid ngrok configuration"
// MARK: - Permission Errors
static let permissionDenied = "Permission Denied"
static let accessibilityPermissionRequired = "Accessibility Permission Required"
// MARK: - Terminal Errors
static let terminalNotFound = "Terminal Not Found"
static let terminalNotAvailable = "Terminal Not Available"
static let terminalCommunicationError = "Terminal Communication Error"
static let terminalLaunchFailed = "Terminal Launch Failed"
// MARK: - CLI Tool Errors
static let cliToolInstallationFailed = "CLI Tool Installation Failed"
}

View file

@ -0,0 +1,64 @@
import Foundation
/// Centralized file path constants
enum FilePathConstants {
// MARK: - System Paths
static let usrBin = "/usr/bin"
static let bin = "/bin"
static let usrLocalBin = "/usr/local/bin"
static let optHomebrewBin = "/opt/homebrew/bin"
// MARK: - Command Paths
static let which = "/usr/bin/which"
static let git = "/usr/bin/git"
static let homebrewGit = "/opt/homebrew/bin/git"
static let localGit = "/usr/local/bin/git"
// MARK: - Shell Paths
static let defaultShell = "/bin/zsh"
static let bash = "/bin/bash"
static let zsh = "/bin/zsh"
static let sh = "/bin/sh"
// MARK: - Application Paths
static let applicationsVibeTunnel = "/Applications/VibeTunnel.app"
static let userApplicationsVibeTunnel = "$HOME/Applications/VibeTunnel.app"
// MARK: - Temporary Directory
static let tmpDirectory = "/tmp/"
// MARK: - Common Repository Base Paths
static let projectsPath = "~/Projects"
static let documentsCodePath = "~/Documents/Code"
static let developmentPath = "~/Development"
static let sourcePath = "~/Source"
static let workPath = "~/Work"
static let codePath = "~/Code"
static let sitesPath = "~/Sites"
static let desktopPath = "~/Desktop"
static let documentsPath = "~/Documents"
static let downloadsPath = "~/Downloads"
static let homePath = "~/"
// MARK: - Resource Names
static let vibetunnelBinary = "vibetunnel"
static let vtCLI = "vt"
// MARK: - Configuration Files
static let infoPlist = "Info.plist"
static let entitlements = "VibeTunnel.entitlements"
// MARK: - Helper Functions
static func expandTilde(_ path: String) -> String {
path.replacingOccurrences(of: "~", with: NSHomeDirectory())
}
}

View file

@ -0,0 +1,14 @@
import Foundation
/// Centralized keychain service and account names
enum KeychainConstants {
// MARK: - Service Names
static let vibeTunnelService = "sh.vibetunnel.vibetunnel"
// MARK: - Account Names
static let ngrokAuthToken = "ngrokAuthToken"
static let authToken = "authToken"
static let dashboardPassword = "dashboard-password"
}

View file

@ -0,0 +1,39 @@
import Foundation
/// Centralized network-related constants
enum NetworkConstants {
// MARK: - Default Port
static let defaultPort = 4_020
// MARK: - Headers
static let localAuthHeader = "X-VibeTunnel-Local"
static let authorizationHeader = "Authorization"
static let contentTypeHeader = "Content-Type"
static let hostHeader = "Host"
// MARK: - Content Types
static let contentTypeJSON = "application/json"
static let contentTypeHTML = "text/html"
static let contentTypeText = "text/plain"
// MARK: - Common Values
static let localhost = "localhost"
// MARK: - Timeout Values
static let defaultTimeout: TimeInterval = 30.0
static let uploadTimeout: TimeInterval = 300.0
static let downloadTimeout: TimeInterval = 300.0
// MARK: - HTTP Methods
static let httpMethodGET = "GET"
static let httpMethodPOST = "POST"
static let httpMethodPUT = "PUT"
static let httpMethodDELETE = "DELETE"
static let httpMethodPATCH = "PATCH"
}

View file

@ -0,0 +1,21 @@
import Foundation
/// Centralized notification names
extension Notification.Name {
// MARK: - Settings
static let showSettings = Notification.Name("sh.vibetunnel.vibetunnel.showSettings")
// MARK: - Updates
static let checkForUpdates = Notification.Name("checkForUpdates")
// MARK: - Welcome
static let showWelcomeScreen = Notification.Name("showWelcomeScreen")
}
/// Notification categories
enum NotificationCategories {
static let updateReminder = "UPDATE_REMINDER"
}

View file

@ -0,0 +1,88 @@
import Foundation
/// Centralized UI strings for consistency
enum UIStrings {
// MARK: - Common UI
static let error = "Error"
static let ok = "OK"
static let cancel = "Cancel"
static let done = "Done"
static let save = "Save"
static let delete = "Delete"
static let edit = "Edit"
static let add = "Add"
static let remove = "Remove"
static let close = "Close"
static let open = "Open"
static let yes = "Yes"
static let no = "No"
// MARK: - Search
static let searchPlaceholder = "Search sessions..."
// MARK: - Actions
static let copy = "Copy"
static let paste = "Paste"
static let cut = "Cut"
static let selectAll = "Select All"
// MARK: - Status
static let loading = "Loading..."
static let saving = "Saving..."
static let updating = "Updating..."
static let connecting = "Connecting..."
static let disconnected = "Disconnected"
static let connected = "Connected"
// MARK: - Validation
static let required = "Required"
static let optional = "Optional"
static let invalid = "Invalid"
static let valid = "Valid"
// MARK: - App Names
static let appName = "VibeTunnel"
static let appNameDebug = "VibeTunnel Debug"
// MARK: - Session UI
static let sessionDetails = "Session Details"
static let windowInformation = "Window Information"
static let noWindowInformation = "No window information available"
// MARK: - Terminal UI
static let terminalApp = "Terminal App"
static let terminalAutomation = "Terminal Automation"
static let accessibility = "Accessibility"
// MARK: - Terminal Names
static let terminal = "Terminal"
static let iTerm2 = "iTerm2"
static let ghostty = "Ghostty"
static let wezTerm = "WezTerm"
// MARK: - CLI Tool UI
static let installCLITool = "Install VT Command Line Tool"
static let uninstallCLITool = "Uninstall VT Command Line Tool"
static let cliToolsInstalledSuccess = "CLI Tools Installed Successfully"
static let cliToolsUninstalledSuccess = "CLI Tools Uninstalled Successfully"
// MARK: - Dashboard UI
static let accessingDashboard = "Accessing Your Dashboard"
static let openDashboard = "Open Dashboard"
// MARK: - Welcome Screen
static let welcomeTitle = "Welcome to VibeTunnel"
static let welcomeSubtitle = "Turn any browser into your terminal. Command your agents on the go."
}

View file

@ -0,0 +1,53 @@
import Foundation
/// Centralized URL constants for the VibeTunnel application
enum URLConstants {
// MARK: - Local Server URLs
static let localhost = "http://localhost:"
static let localhostIP = "http://127.0.0.1:"
// MARK: - Main Website & Social
static let website = "https://vibetunnel.sh"
static let githubRepo = "https://github.com/amantus-ai/vibetunnel"
static let githubIssues = "https://github.com/amantus-ai/vibetunnel/issues"
static let twitter = "https://x.com/VibeTunnel"
// MARK: - Contributors
static let contributorMario = "https://mariozechner.at/"
static let contributorArmin = "https://lucumr.pocoo.org/"
static let contributorPeter = "https://steipete.me"
// MARK: - Tailscale
static let tailscaleWebsite = "https://tailscale.com/"
static let tailscaleAppStore = "https://apps.apple.com/us/app/tailscale/id1475387142"
static let tailscaleDownloadMac = "https://tailscale.com/download/macos"
static let tailscaleInstallGuide = "https://tailscale.com/kb/1017/install/"
static let tailscaleAPI = "http://100.100.100.100/api/data"
// MARK: - Cloudflare
static let cloudflareFormula = "https://formulae.brew.sh/formula/cloudflared"
static let cloudflareReleases = "https://github.com/cloudflare/cloudflared/releases/latest"
static let cloudflareDocs = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
// MARK: - Update Feed
static let updateFeedStable = "https://stats.store/api/v1/appcast/appcast.xml"
static let updateFeedPrerelease = "https://stats.store/api/v1/appcast/appcast-prerelease.xml"
// MARK: - Documentation
static let claudeCodeArmyPost = "https://steipete.me/posts/command-your-claude-code-army-reloaded"
// MARK: - Regular Expressions
static let cloudflareURLPattern = #"https://[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.trycloudflare\.com/?(?:\s|$)"#
// MARK: - Local Server Base
static let localServerBase = "http://127.0.0.1"
}

View file

@ -0,0 +1,42 @@
import Foundation
/// Centralized UserDefaults keys
enum UserDefaultsKeys {
// MARK: - Server Settings
static let serverPort = "serverPort"
static let dashboardAccessMode = "dashboardAccessMode"
static let cleanupOnStartup = "cleanupOnStartup"
static let preventSleepWhenRunning = "preventSleepWhenRunning"
static let useDevelopmentServer = "useDevelopmentServer"
// MARK: - App Preferences
static let debugMode = "debugMode"
static let showDockIcon = "showDockIcon"
static let launchAtLogin = "launchAtLogin"
static let preferredTerminal = "preferredTerminal"
static let preferredGitApp = "preferredGitApp"
// MARK: - UI Preferences
static let showIconInDock = "showIconInDock"
static let hideOnStartup = "hideOnStartup"
static let menuBarIconStyle = "menuBarIconStyle"
// MARK: - First Run
static let hasShownWelcome = "hasShownWelcome"
static let hasCompletedOnboarding = "hasCompletedOnboarding"
// MARK: - Update Settings
static let automaticallyCheckForUpdates = "automaticallyCheckForUpdates"
static let automaticallyDownloadUpdates = "automaticallyDownloadUpdates"
static let updateChannelPreference = "updateChannelPreference"
// MARK: - Path Sync
static let pathSyncShouldRun = "pathSyncShouldRun"
static let pathSyncTerminalPath = "pathSyncTerminalPath"
}

View file

@ -2,26 +2,32 @@ import SwiftUI
// MARK: - Environment Keys
/// Environment key for ServerManager dependency injection
private struct ServerManagerKey: EnvironmentKey {
static let defaultValue: ServerManager? = nil
}
/// Environment key for NgrokService dependency injection
private struct NgrokServiceKey: EnvironmentKey {
static let defaultValue: NgrokService? = nil
}
/// Environment key for SystemPermissionManager dependency injection
private struct SystemPermissionManagerKey: EnvironmentKey {
static let defaultValue: SystemPermissionManager? = nil
}
/// Environment key for TerminalLauncher dependency injection
private struct TerminalLauncherKey: EnvironmentKey {
static let defaultValue: TerminalLauncher? = nil
}
/// Environment key for TailscaleService dependency injection
private struct TailscaleServiceKey: EnvironmentKey {
static let defaultValue: TailscaleService? = nil
}
/// Environment key for CloudflareService dependency injection
private struct CloudflareServiceKey: EnvironmentKey {
static let defaultValue: CloudflareService? = nil
}

View file

@ -249,13 +249,13 @@ extension AppConstants {
UserDefaults.standard.removeObject(forKey: UserDefaultsKeys.preferredTerminal)
}
}
/// Get current dashboard access mode
static func getDashboardAccessMode() -> DashboardAccessMode {
let rawValue = stringValue(for: UserDefaultsKeys.dashboardAccessMode)
return DashboardAccessMode(rawValue: rawValue) ?? .network
}
/// Set dashboard access mode
static func setDashboardAccessMode(_ mode: DashboardAccessMode) {
UserDefaults.standard.set(mode.rawValue, forKey: UserDefaultsKeys.dashboardAccessMode)

View file

@ -1,15 +1,16 @@
import Foundation
/// Shared configuration enums used across the application
// Shared configuration enums used across the application
// MARK: - Authentication Mode
/// Represents the available authentication modes for dashboard access
enum AuthenticationMode: String, CaseIterable {
case none = "none"
case osAuth = "os"
case sshKeys = "ssh"
case both = "both"
var displayName: String {
switch self {
case .none: "None"
@ -18,7 +19,7 @@ enum AuthenticationMode: String, CaseIterable {
case .both: "macOS + SSH Keys"
}
}
var description: String {
switch self {
case .none: "Anyone can access the dashboard (not recommended)"
@ -31,12 +32,13 @@ enum AuthenticationMode: String, CaseIterable {
// MARK: - Title Mode
/// Represents the terminal window title display modes
enum TitleMode: String, CaseIterable {
case none = "none"
case filter = "filter"
case `static` = "static"
case dynamic = "dynamic"
var displayName: String {
switch self {
case .none: "None"
@ -45,4 +47,4 @@ enum TitleMode: String, CaseIterable {
case .dynamic: "Dynamic"
}
}
}
}

View file

@ -36,8 +36,8 @@ final class BunServer {
/// Resource cleanup tracking
private var isCleaningUp = false
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "BunServer")
private let serverOutput = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ServerOutput")
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "BunServer")
private let serverOutput = Logger(subsystem: BundleIdentifiers.main, category: "ServerOutput")
var isRunning: Bool {
state == .running
@ -121,11 +121,11 @@ final class BunServer {
guard let binaryPath = Bundle.main.path(forResource: "vibetunnel", ofType: nil) else {
let error = BunServerError.binaryNotFound
logger.error("vibetunnel binary not found in bundle")
// Additional diagnostics for CI debugging
logger.error("Bundle path: \(Bundle.main.bundlePath)")
logger.error("Resources path: \(Bundle.main.resourcePath ?? "nil")")
// List contents of Resources directory
if let resourcesPath = Bundle.main.resourcePath {
do {
@ -135,7 +135,7 @@ final class BunServer {
logger.error("Failed to list Resources directory: \(error)")
}
}
throw error
}
@ -269,7 +269,16 @@ final class BunServer {
environment["NODE_OPTIONS"] = "--max-old-space-size=4096 --max-semi-space-size=128"
// Copy only essential environment variables
let essentialVars = ["PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "VIBETUNNEL_DEBUG"]
let essentialVars = [
EnvironmentKeys.path,
"HOME",
"USER",
"SHELL",
EnvironmentKeys.lang,
"LC_ALL",
"LC_CTYPE",
"VIBETUNNEL_DEBUG"
]
for key in essentialVars {
if let value = ProcessInfo.processInfo.environment[key] {
environment[key] = value
@ -486,10 +495,10 @@ final class BunServer {
// Add pnpm to PATH so that scripts can use it
// pnpmDir is already defined above
if let existingPath = environment["PATH"] {
environment["PATH"] = "\(pnpmDir):\(existingPath)"
if let existingPath = environment[EnvironmentKeys.path] {
environment[EnvironmentKeys.path] = "\(pnpmDir):\(existingPath)"
} else {
environment["PATH"] = pnpmDir
environment[EnvironmentKeys.path] = pnpmDir
}
logger.info("Added pnpm directory to PATH: \(pnpmDir)")

View file

@ -16,30 +16,30 @@ class ConfigManager: ObservableObject {
// Core configuration
@Published private(set) var quickStartCommands: [QuickStartCommand] = []
@Published var repositoryBasePath: String = "~/"
// Server settings
@Published var serverPort: Int = 4020
@Published var serverPort: Int = 4_020
@Published var dashboardAccessMode: DashboardAccessMode = .network
@Published var cleanupOnStartup: Bool = true
@Published var authenticationMode: AuthenticationMode = .osAuth
// Development settings
@Published var debugMode: Bool = false
@Published var useDevServer: Bool = false
@Published var devServerPath: String = ""
@Published var logLevel: String = "info"
// Application preferences
@Published var preferredGitApp: String?
@Published var preferredTerminal: String?
@Published var updateChannel: UpdateChannel = .stable
@Published var showInDock: Bool = false
@Published var preventSleepWhenRunning: Bool = true
// Remote access
@Published var ngrokEnabled: Bool = false
@Published var ngrokTokenPresent: Bool = false
// Session defaults
@Published var sessionCommand: String = "zsh"
@Published var sessionWorkingDirectory: String = "~/"
@ -83,7 +83,7 @@ class ConfigManager: ObservableObject {
let version: Int
var quickStartCommands: [QuickStartCommand]
var repositoryBasePath: String?
// Extended configuration sections
var server: ServerConfig?
var development: DevelopmentConfig?
@ -91,23 +91,23 @@ class ConfigManager: ObservableObject {
var remoteAccess: RemoteAccessConfig?
var sessionDefaults: SessionDefaultsConfig?
}
// MARK: - Configuration Sub-structures
private struct ServerConfig: Codable {
var port: Int
var dashboardAccessMode: String
var cleanupOnStartup: Bool
var authenticationMode: String
}
private struct DevelopmentConfig: Codable {
var debugMode: Bool
var useDevServer: Bool
var devServerPath: String
var logLevel: String
}
private struct PreferencesConfig: Codable {
var preferredGitApp: String?
var preferredTerminal: String?
@ -115,12 +115,12 @@ class ConfigManager: ObservableObject {
var showInDock: Bool
var preventSleepWhenRunning: Bool
}
private struct RemoteAccessConfig: Codable {
var ngrokEnabled: Bool
var ngrokTokenPresent: Bool
}
private struct SessionDefaultsConfig: Codable {
var command: String
var workingDirectory: String
@ -160,11 +160,11 @@ class ConfigManager: ObservableObject {
do {
let data = try Data(contentsOf: configPath)
let config = try JSONDecoder().decode(VibeTunnelConfig.self, from: data)
// Load all configuration values
self.quickStartCommands = config.quickStartCommands
self.repositoryBasePath = config.repositoryBasePath ?? "~/"
// Server settings
if let server = config.server {
self.serverPort = server.port
@ -172,7 +172,7 @@ class ConfigManager: ObservableObject {
self.cleanupOnStartup = server.cleanupOnStartup
self.authenticationMode = AuthenticationMode(rawValue: server.authenticationMode) ?? .osAuth
}
// Development settings
if let dev = config.development {
self.debugMode = dev.debugMode
@ -180,7 +180,7 @@ class ConfigManager: ObservableObject {
self.devServerPath = dev.devServerPath
self.logLevel = dev.logLevel
}
// Preferences
if let prefs = config.preferences {
self.preferredGitApp = prefs.preferredGitApp
@ -189,13 +189,13 @@ class ConfigManager: ObservableObject {
self.showInDock = prefs.showInDock
self.preventSleepWhenRunning = prefs.preventSleepWhenRunning
}
// Remote access
if let remote = config.remoteAccess {
self.ngrokEnabled = remote.ngrokEnabled
self.ngrokTokenPresent = remote.ngrokTokenPresent
}
// Session defaults
if let session = config.sessionDefaults {
self.sessionCommand = session.command
@ -203,7 +203,7 @@ class ConfigManager: ObservableObject {
self.sessionSpawnWindow = session.spawnWindow
self.sessionTitleMode = TitleMode(rawValue: session.titleMode) ?? .dynamic
}
logger.info("Loaded configuration from disk")
} catch {
logger.error("Failed to load config: \(error.localizedDescription)")
@ -229,7 +229,7 @@ class ConfigManager: ObservableObject {
quickStartCommands: quickStartCommands,
repositoryBasePath: repositoryBasePath
)
// Server configuration
config.server = ServerConfig(
port: serverPort,
@ -237,7 +237,7 @@ class ConfigManager: ObservableObject {
cleanupOnStartup: cleanupOnStartup,
authenticationMode: authenticationMode.rawValue
)
// Development configuration
config.development = DevelopmentConfig(
debugMode: debugMode,
@ -245,7 +245,7 @@ class ConfigManager: ObservableObject {
devServerPath: devServerPath,
logLevel: logLevel
)
// Preferences
config.preferences = PreferencesConfig(
preferredGitApp: preferredGitApp,
@ -254,13 +254,13 @@ class ConfigManager: ObservableObject {
showInDock: showInDock,
preventSleepWhenRunning: preventSleepWhenRunning
)
// Remote access
config.remoteAccess = RemoteAccessConfig(
ngrokEnabled: ngrokEnabled,
ngrokTokenPresent: ngrokTokenPresent
)
// Session defaults
config.sessionDefaults = SessionDefaultsConfig(
command: sessionCommand,
@ -392,11 +392,11 @@ class ConfigManager: ObservableObject {
updateQuickStartCommands(commands)
logger.info("Reordered quick start commands")
}
/// Update repository base path
func updateRepositoryBasePath(_ path: String) {
guard path != repositoryBasePath else { return }
self.repositoryBasePath = path
saveConfiguration()
logger.info("Updated repository base path to: \(path)")

View file

@ -2,6 +2,7 @@ import Foundation
// MARK: - Terminal Control Payloads
/// Request payload for spawning a new terminal window
struct TerminalSpawnRequest: Codable {
let sessionId: String
let workingDirectory: String?
@ -9,6 +10,7 @@ struct TerminalSpawnRequest: Codable {
let terminalPreference: String?
}
/// Response payload for terminal spawn operations
struct TerminalSpawnResponse: Codable {
let success: Bool
let pid: Int?
@ -23,6 +25,7 @@ struct TerminalSpawnResponse: Codable {
// MARK: - System Control Payloads
/// Event payload indicating the system is ready
struct SystemReadyEvent: Codable {
let timestamp: Double
let version: String?
@ -33,6 +36,7 @@ struct SystemReadyEvent: Codable {
}
}
/// Request payload for system health check ping
struct SystemPingRequest: Codable {
let timestamp: Double
@ -41,6 +45,7 @@ struct SystemPingRequest: Codable {
}
}
/// Response payload for system health check ping
struct SystemPingResponse: Codable {
let status: String
let timestamp: Double
@ -51,28 +56,14 @@ struct SystemPingResponse: Codable {
}
}
struct RepositoryPathUpdateRequest: Codable {
let path: String
}
struct RepositoryPathUpdateResponse: Codable {
let success: Bool
let path: String?
let error: String?
init(success: Bool, path: String? = nil, error: String? = nil) {
self.success = success
self.path = path
self.error = error
}
}
// MARK: - Git Control Payloads (placeholder for future use)
/// Request payload for Git repository status
struct GitStatusRequest: Codable {
let repositoryPath: String
}
/// Response payload containing Git repository status information
struct GitStatusResponse: Codable {
let status: String
let branch: String?

View file

@ -64,8 +64,6 @@ enum ControlProtocol {
typealias SystemReadyMessage = ControlMessage<SystemReadyEvent>
typealias SystemPingRequestMessage = ControlMessage<SystemPingRequest>
typealias SystemPingResponseMessage = ControlMessage<SystemPingResponse>
typealias RepositoryPathUpdateRequestMessage = ControlMessage<RepositoryPathUpdateRequest>
typealias RepositoryPathUpdateResponseMessage = ControlMessage<RepositoryPathUpdateResponse>
// MARK: - Convenience builders for specific message types
@ -150,37 +148,6 @@ enum ControlProtocol {
)
}
static func repositoryPathUpdateRequest(path: String) -> RepositoryPathUpdateRequestMessage {
ControlMessage(
type: .request,
category: .system,
action: "repository-path-update",
payload: RepositoryPathUpdateRequest(path: path)
)
}
static func repositoryPathUpdateResponse(
to request: RepositoryPathUpdateRequestMessage,
success: Bool,
path: String? = nil,
error: String? = nil
)
-> RepositoryPathUpdateResponseMessage
{
ControlMessage(
id: request.id,
type: .response,
category: .system,
action: "repository-path-update",
payload: RepositoryPathUpdateResponse(
success: success,
path: path,
error: error
),
error: error
)
}
// MARK: - Message Serialization
static func encode(_ message: ControlMessage<some Codable>) throws -> Data {

View file

@ -11,9 +11,9 @@ import Security
final class DashboardKeychain {
static let shared = DashboardKeychain()
private let service = "sh.vibetunnel.vibetunnel"
private let account = "dashboard-password"
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DashboardKeychain")
private let service = KeychainConstants.vibeTunnelService
private let account = KeychainConstants.dashboardPassword
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "DashboardKeychain")
private init() {}

View file

@ -16,13 +16,13 @@ enum NgrokError: LocalizedError, Equatable {
var errorDescription: String? {
switch self {
case .notInstalled:
"ngrok is not installed. Please install it using 'brew install ngrok' or download from ngrok.com"
ErrorMessages.ngrokNotInstalled
case .authTokenMissing:
"ngrok auth token is missing. Please add it in Settings"
ErrorMessages.ngrokAuthTokenMissing
case .tunnelCreationFailed(let message):
"Failed to create tunnel: \(message)"
case .invalidConfiguration:
"Invalid ngrok configuration"
ErrorMessages.invalidNgrokConfiguration
case .networkError(let message):
"Network error: \(message)"
}
@ -107,7 +107,7 @@ final class NgrokService: NgrokTunnelProtocol {
/// Task for periodic status updates
private var statusTask: Task<Void, Never>?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "NgrokService")
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "NgrokService")
private init() {}
@ -160,14 +160,14 @@ final class NgrokService: NgrokTunnelProtocol {
private func startWithCLI(port: Int) async throws -> String {
// Check if ngrok is installed
let checkProcess = Process()
checkProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which")
checkProcess.executableURL = URL(fileURLWithPath: FilePathConstants.which)
checkProcess.arguments = ["ngrok"]
// Add common Homebrew paths to PATH for the check
var environment = ProcessInfo.processInfo.environment
let currentPath = environment["PATH"] ?? "/usr/bin:/bin"
let homebrewPaths = "/opt/homebrew/bin:/usr/local/bin"
environment["PATH"] = "\(homebrewPaths):\(currentPath)"
let currentPath = environment[EnvironmentKeys.path] ?? "\(FilePathConstants.usrBin):\(FilePathConstants.bin)"
let homebrewPaths = "\(FilePathConstants.optHomebrewBin):\(FilePathConstants.usrLocalBin)"
environment[EnvironmentKeys.path] = "\(homebrewPaths):\(currentPath)"
checkProcess.environment = environment
let checkPipe = Pipe()
@ -239,7 +239,7 @@ final class NgrokService: NgrokTunnelProtocol {
}
}
}
throw NgrokError.tunnelCreationFailed("Could not find public URL in ngrok output")
throw NgrokError.tunnelCreationFailed(ErrorMessages.ngrokPublicURLNotFound)
}
try process.run()

View file

@ -1,148 +0,0 @@
import Combine
import Foundation
import OSLog
/// Service that synchronizes repository base path changes to the server via Unix socket
@MainActor
final class RepositoryPathSyncService {
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "RepositoryPathSync")
// MARK: - Properties
private var cancellables = Set<AnyCancellable>()
private var lastSentPath: String?
private var syncEnabled = true
// MARK: - Initialization
init() {
logger.info("🚀 RepositoryPathSyncService initialized")
setupObserver()
setupNotifications()
}
// MARK: - Private Methods
private func setupObserver() {
// Monitor ConfigManager changes for repository base path
ConfigManager.shared.$repositoryBasePath
.removeDuplicates()
.dropFirst() // Skip initial value on startup
.sink { [weak self] newPath in
Task { @MainActor [weak self] in
await self?.handlePathChange(newPath)
}
}
.store(in: &cancellables)
logger.info("✅ Repository path observer configured")
}
private func setupNotifications() {
// Listen for notifications to disable/enable sync (for loop prevention)
NotificationCenter.default.addObserver(
self,
selector: #selector(disableSync),
name: .disablePathSync,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(enableSync),
name: .enablePathSync,
object: nil
)
logger.info("✅ Notification observers configured")
}
@objc
private func disableSync() {
syncEnabled = false
logger.debug("🔒 Path sync temporarily disabled")
}
@objc
private func enableSync() {
syncEnabled = true
logger.debug("🔓 Path sync re-enabled")
}
private func handlePathChange(_ newPath: String?) async {
// Check if sync is enabled (loop prevention)
guard syncEnabled else {
logger.debug("🔒 Skipping path change - sync is temporarily disabled")
return
}
let path = newPath ?? "~/"
// Skip if we've already sent this path
guard path != lastSentPath else {
logger.debug("Skipping duplicate path update: \(path)")
return
}
logger.info("📁 Repository base path changed to: \(path)")
// Get the shared Unix socket connection
let socketManager = SharedUnixSocketManager.shared
let connection = socketManager.getConnection()
// Ensure we're connected
guard connection.isConnected else {
logger.warning("⚠️ Unix socket not connected, cannot send path update")
return
}
// Create the repository path update message
let message = ControlProtocol.repositoryPathUpdateRequest(path: path)
do {
// Send the message
try await connection.send(message)
lastSentPath = path
logger.info("✅ Successfully sent repository path update to server")
} catch {
logger.error("❌ Failed to send repository path update: \(error)")
}
}
/// Manually trigger a path sync (useful after initial connection)
func syncCurrentPath() async {
let path = ConfigManager.shared.repositoryBasePath
logger.info("🔄 Manually syncing repository path: \(path)")
// Get the shared Unix socket connection
let socketManager = SharedUnixSocketManager.shared
let connection = socketManager.getConnection()
// Ensure we're connected
guard connection.isConnected else {
logger.warning("⚠️ Unix socket not connected, cannot sync path")
return
}
// Create the repository path update message
let message = ControlProtocol.repositoryPathUpdateRequest(path: path)
do {
// Send the message
try await connection.send(message)
lastSentPath = path
logger.info("✅ Successfully synced repository path to server")
} catch {
logger.error("❌ Failed to sync repository path: \(error)")
}
}
}
// MARK: - Notification Names
extension Notification.Name {
static let disablePathSync = Notification.Name("disablePathSync")
static let enablePathSync = Notification.Name("enablePathSync")
}

View file

@ -55,14 +55,16 @@ class ServerManager {
static let shared = ServerManager()
var port: String {
get { UserDefaults.standard.string(forKey: "serverPort") ?? "4020" }
set { UserDefaults.standard.set(newValue, forKey: "serverPort") }
get { UserDefaults.standard.string(forKey: UserDefaultsKeys.serverPort) ?? String(NetworkConstants.defaultPort)
}
set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.serverPort) }
}
var bindAddress: String {
get {
// Get the raw value from UserDefaults, defaulting to the app default
let rawValue = UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? AppConstants.Defaults
let rawValue = UserDefaults.standard.string(forKey: UserDefaultsKeys.dashboardAccessMode) ?? AppConstants
.Defaults
.dashboardAccessMode
let mode = DashboardAccessMode(rawValue: rawValue) ?? .network
@ -77,15 +79,15 @@ class ServerManager {
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")
UserDefaults.standard.set(mode.rawValue, forKey: UserDefaultsKeys.dashboardAccessMode)
logger.debug("bindAddress setter: set mode=\(mode.rawValue) for bindAddress=\(newValue)")
}
}
}
private var cleanupOnStartup: Bool {
get { UserDefaults.standard.bool(forKey: "cleanupOnStartup") }
set { UserDefaults.standard.set(newValue, forKey: "cleanupOnStartup") }
get { UserDefaults.standard.bool(forKey: UserDefaultsKeys.cleanupOnStartup) }
set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.cleanupOnStartup) }
}
private(set) var bunServer: BunServer?
@ -100,7 +102,7 @@ class ServerManager {
/// Last crash time for crash rate detection
private var lastCrashTime: Date?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "ServerManager")
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "ServerManager")
private let powerManager = PowerManagementService.shared
private init() {
@ -174,7 +176,7 @@ class ServerManager {
}
// First check if port is truly available by trying to bind to it
let portNumber = Int(self.port) ?? 4_020
let portNumber = Int(self.port) ?? NetworkConstants.defaultPort
let canBind = await PortConflictResolver.shared.canBindToPort(portNumber)
if !canBind {
@ -203,7 +205,7 @@ class ServerManager {
logger.error("Port \(self.port) is used by external app: \(appName)")
lastError = ServerManagerError.portInUseByApp(
appName: appName,
port: Int(self.port) ?? 4_020,
port: Int(self.port) ?? NetworkConstants.defaultPort,
alternatives: conflict.alternativePorts
)
return
@ -312,7 +314,7 @@ class ServerManager {
await stop()
// Wait with exponential backoff for port to be available
let portNumber = Int(self.port) ?? 4_020
let portNumber = Int(self.port) ?? NetworkConstants.defaultPort
var retries = 0
let maxRetries = 5
@ -353,7 +355,8 @@ class ServerManager {
do {
// Create URL for cleanup endpoint
guard let url = URL(string: "http://localhost:\(self.port)/api/cleanup-exited") else {
guard let url = URL(string: "\(URLConstants.localServerBase):\(self.port)\(APIEndpoints.cleanupExited)")
else {
logger.warning("Failed to create cleanup URL")
return
}
@ -363,7 +366,7 @@ class ServerManager {
// Add local auth token if available
if let server = bunServer {
request.setValue(server.localToken, forHTTPHeaderField: "X-VibeTunnel-Local")
request.setValue(server.localToken, forHTTPHeaderField: NetworkConstants.localAuthHeader)
}
// Make the cleanup request
@ -455,7 +458,9 @@ class ServerManager {
logger.info("Port \(self.port) is in use, checking for conflicts...")
// Check for port conflicts
if let conflict = await PortConflictResolver.shared.detectConflict(on: Int(self.port) ?? 4_020) {
if let conflict = await PortConflictResolver.shared
.detectConflict(on: Int(self.port) ?? NetworkConstants.defaultPort)
{
logger.warning("Found port conflict: \(conflict.process.name) (PID: \(conflict.process.pid))")
// Try to resolve the conflict
@ -478,7 +483,7 @@ class ServerManager {
// Port might still be in TIME_WAIT state, wait with backoff
logger.info("Port may be in TIME_WAIT state, checking availability...")
let portNumber = Int(self.port) ?? 4_020
let portNumber = Int(self.port) ?? NetworkConstants.defaultPort
var retries = 0
let maxRetries = 5
@ -563,9 +568,9 @@ class ServerManager {
/// Add authentication headers to a request
func authenticate(request: inout URLRequest) throws {
guard let server = bunServer else {
throw ServerError.startupFailed("Server not running")
throw ServerError.startupFailed(ErrorMessages.serverNotRunning)
}
request.setValue(server.localToken, forHTTPHeaderField: "X-VibeTunnel-Local")
request.setValue(server.localToken, forHTTPHeaderField: NetworkConstants.localAuthHeader)
}
}

View file

@ -24,14 +24,16 @@ final class SessionService {
throw SessionServiceError.invalidName
}
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)") else {
guard let url =
URL(string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)/\(sessionId)")
else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "PATCH"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("localhost", forHTTPHeaderField: "Host")
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
let body = ["name": trimmedName]
@ -66,13 +68,15 @@ final class SessionService {
/// - Note: The server implements graceful termination (SIGTERM SIGKILL)
/// with a 3-second timeout before force-killing processes.
func terminateSession(sessionId: String) async throws {
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)") else {
guard let url =
URL(string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)/\(sessionId)")
else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue("localhost", forHTTPHeaderField: "Host")
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
let (_, response) = try await URLSession.shared.data(for: request)
@ -106,14 +110,18 @@ final class SessionService {
throw SessionServiceError.serverNotRunning
}
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)/input") else {
guard let url =
URL(
string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)/\(sessionId)/input"
)
else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("localhost", forHTTPHeaderField: "Host")
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
let body = ["text": text]
@ -134,14 +142,18 @@ final class SessionService {
throw SessionServiceError.serverNotRunning
}
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions/\(sessionId)/input") else {
guard let url =
URL(
string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)/\(sessionId)/input"
)
else {
throw SessionServiceError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("localhost", forHTTPHeaderField: "Host")
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
let body = ["key": key]
@ -172,7 +184,8 @@ final class SessionService {
throw SessionServiceError.serverNotRunning
}
guard let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/sessions") else {
guard let url = URL(string: "\(URLConstants.localServerBase):\(serverManager.port)\(APIEndpoints.sessions)")
else {
throw SessionServiceError.invalidURL
}
@ -196,8 +209,8 @@ final class SessionService {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("localhost", forHTTPHeaderField: "Host")
request.setValue(NetworkConstants.contentTypeJSON, forHTTPHeaderField: NetworkConstants.contentTypeHeader)
request.setValue(NetworkConstants.localhost, forHTTPHeaderField: NetworkConstants.hostHeader)
try serverManager.authenticate(request: &request)
request.httpBody = try JSONSerialization.data(withJSONObject: body)
@ -244,17 +257,17 @@ enum SessionServiceError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidName:
"Session name cannot be empty"
ErrorMessages.sessionNameEmpty
case .invalidURL:
"Invalid server URL"
ErrorMessages.invalidServerURL
case .serverNotRunning:
"Server is not running"
ErrorMessages.serverNotRunning
case .requestFailed(let statusCode):
"Request failed with status code: \(statusCode)"
case .createFailed(let message):
message
case .invalidResponse:
"Invalid server response"
ErrorMessages.invalidServerResponse
}
}
}

View file

@ -34,7 +34,7 @@ actor TerminalManager {
let stderrPipe = Pipe()
// Configure the process
process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh")
process.executableURL = URL(fileURLWithPath: request.shell ?? FilePathConstants.defaultShell)
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
@ -168,13 +168,13 @@ enum TunnelError: LocalizedError, Equatable {
var errorDescription: String? {
switch self {
case .sessionNotFound:
"Session not found"
ErrorMessages.sessionNotFound
case .commandExecutionFailed(let message):
"Command execution failed: \(message)"
case .timeout:
"Operation timed out"
ErrorMessages.operationTimeout
case .invalidRequest:
"Invalid request"
ErrorMessages.invalidRequest
}
}
}

View file

@ -18,7 +18,7 @@ struct ErrorAlertModifier: ViewModifier {
isPresented: .constant(error != nil),
presenting: error
) { _ in
Button("OK") {
Button(UIStrings.ok) {
error = nil
onDismiss?()
}
@ -31,7 +31,7 @@ struct ErrorAlertModifier: ViewModifier {
extension View {
/// Presents an error alert when an error is present
func errorAlert(
_ title: String = "Error",
_ title: String = UIStrings.error,
error: Binding<Error?>,
onDismiss: (() -> Void)? = nil
)
@ -103,7 +103,7 @@ struct ErrorToast: View {
.foregroundColor(.red)
VStack(alignment: .leading, spacing: 4) {
Text("Error")
Text(UIStrings.error)
.font(.headline)
Text(error.localizedDescription)

View file

@ -6,19 +6,21 @@ enum GitAppHelper {
static func getPreferredGitAppName() -> String {
if let preferredApp = AppConstants.getPreferredGitApp(),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp) {
let gitApp = GitApp(rawValue: preferredApp)
{
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
}
/// Check if a specific Git app is the preferred one
static func isPreferredApp(_ app: GitApp) -> Bool {
guard let preferredApp = AppConstants.getPreferredGitApp(),
let gitApp = GitApp(rawValue: preferredApp) else {
let gitApp = GitApp(rawValue: preferredApp)
else {
return false
}
return gitApp == app
}
}
}

View file

@ -37,7 +37,6 @@ struct NewSessionForm: View {
case directory
}
var body: some View {
VStack(spacing: 0) {
// Header with back button

View file

@ -15,6 +15,10 @@ struct SessionDetailView: View {
@State private var windowSearchAttempted = false
@Environment(SystemPermissionManager.self)
private var permissionManager
@Environment(SessionService.self)
private var sessionService
@Environment(ServerManager.self)
private var serverManager
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SessionDetailView")
@ -186,13 +190,32 @@ struct SessionDetailView: View {
}
private func openInTerminal() {
// TODO: Implement opening session in terminal
logger.info("Opening session \(session.id) in terminal")
do {
let terminalLauncher = TerminalLauncher.shared
try terminalLauncher.launchTerminalSession(
workingDirectory: session.workingDir,
command: session.command.joined(separator: " "),
sessionId: session.id
)
logger.info("Opened session \(session.id) in terminal")
} catch {
logger.error("Failed to open session in terminal: \(error)")
// Could show an alert here if needed
}
}
private func terminateSession() {
// TODO: Implement session termination
logger.info("Terminating session \(session.id)")
Task {
do {
try await sessionService.terminateSession(sessionId: session.id)
logger.info("Terminated session \(session.id)")
// The view will automatically update when session is removed from monitor
// You could dismiss the window here if desired
} catch {
logger.error("Failed to terminate session: \(error)")
// Could show an alert here if needed
}
}
}
private func findWindow() {

View file

@ -87,7 +87,6 @@ struct SecurityPermissionsSettingsView: View {
}
}
// MARK: - Security Section
private struct SecuritySection: View {

View file

@ -27,7 +27,7 @@ import SwiftUI
final class CLIInstaller {
// MARK: - Properties
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "CLIInstaller")
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "CLIInstaller")
private let binDirectory: String
private var vtTargetPath: String {
@ -45,7 +45,7 @@ final class CLIInstaller {
/// Creates a CLI installer
/// - Parameters:
/// - binDirectory: Directory for installation (defaults to /usr/local/bin)
init(binDirectory: String = "/usr/local/bin") {
init(binDirectory: String = FilePathConstants.usrLocalBin) {
self.binDirectory = binDirectory
}
@ -60,7 +60,7 @@ final class CLIInstaller {
// Check both /usr/local/bin and Apple Silicon Homebrew path
let pathsToCheck = [
vtTargetPath,
"/opt/homebrew/bin/vt"
"\(FilePathConstants.optHomebrewBin)/vt"
]
for path in pathsToCheck where FileManager.default.fileExists(atPath: path) {
@ -452,7 +452,7 @@ final class CLIInstaller {
// Check both possible installation paths
let pathsToCheck = [
vtTargetPath,
"/opt/homebrew/bin/vt"
"\(FilePathConstants.optHomebrewBin)/vt"
]
var installedHash: String?

View file

@ -20,25 +20,25 @@ enum GitApp: String, CaseIterable {
var bundleIdentifier: String {
switch self {
case .cursor:
"com.todesktop.230313mzl4w4u92"
BundleIdentifiers.cursor
case .fork:
"com.DanPristupov.Fork"
BundleIdentifiers.fork
case .githubDesktop:
"com.github.GitHubClient"
BundleIdentifiers.githubDesktop
case .gitup:
"co.gitup.mac"
BundleIdentifiers.gitup
case .juxtaCode:
"com.naiveapps.juxtacode"
BundleIdentifiers.juxtaCode
case .sourcetree:
"com.torusknot.SourceTreeNotMAS"
BundleIdentifiers.sourcetree
case .sublimeMerge:
"com.sublimemerge"
BundleIdentifiers.sublimeMerge
case .tower:
"com.fournova.Tower3"
BundleIdentifiers.tower
case .vscode:
"com.microsoft.VSCode"
BundleIdentifiers.vscode
case .windsurf:
"com.codeiumapp.windsurf"
BundleIdentifiers.windsurf
}
}
@ -88,7 +88,7 @@ enum GitApp: String, CaseIterable {
@Observable
final class GitAppLauncher {
static let shared = GitAppLauncher()
private let logger = Logger(subsystem: "sh.vibetunnel.VibeTunnel", category: "GitAppLauncher")
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "GitAppLauncher")
private init() {
performFirstRunAutoDetection()

View file

@ -78,21 +78,21 @@ enum Terminal: String, CaseIterable {
var bundleIdentifier: String {
switch self {
case .terminal:
"com.apple.Terminal"
BundleIdentifiers.terminal
case .iTerm2:
"com.googlecode.iterm2"
BundleIdentifiers.iTerm2
case .ghostty:
"com.mitchellh.ghostty"
BundleIdentifiers.ghostty
case .warp:
"dev.warp.Warp-Stable"
BundleIdentifiers.warp
case .alacritty:
"org.alacritty"
BundleIdentifiers.alacritty
case .hyper:
"co.zeit.hyper"
BundleIdentifiers.hyper
case .wezterm:
"com.github.wez.wezterm"
BundleIdentifiers.wezterm
case .kitty:
"net.kovidgoyal.kitty"
BundleIdentifiers.kitty
}
}

View file

@ -22,7 +22,7 @@ final class WelcomeWindowController: NSWindowController, NSWindowDelegate {
let hostingController = NSHostingController(rootView: welcomeView)
let window = NSWindow(contentViewController: hostingController)
window.title = "Welcome to VibeTunnel"
window.title = UIStrings.welcomeTitle
window.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
@ -98,6 +98,4 @@ final class WelcomeWindowController: NSWindowController, NSWindowDelegate {
// MARK: - Notification Extension
extension Notification.Name {
static let showWelcomeScreen = Notification.Name("showWelcomeScreen")
}
// Notification names are now in NotificationNames.swift

View file

@ -72,6 +72,10 @@ struct VibeTunnelApp: App {
.environment(terminalLauncher)
.environment(gitRepositoryMonitor)
.environment(repositoryDiscoveryService)
.environment(sessionService ?? SessionService(
serverManager: serverManager,
sessionMonitor: sessionMonitor
))
} else {
Text("Session not found")
.frame(width: 400, height: 300)
@ -126,7 +130,7 @@ struct VibeTunnelApp: App {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
// Needed for menu item highlight hack
weak static var shared: AppDelegate?
static weak var shared: AppDelegate?
override init() {
super.init()
Self.shared = self
@ -136,7 +140,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
var app: VibeTunnelApp?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate")
private(set) var statusBarController: StatusBarController?
private var repositoryPathSync: RepositoryPathSyncService?
/// Distributed notification name used to ask an existing instance to show the Settings window.
private static let showSettingsNotification = Notification.Name("sh.vibetunnel.vibetunnel.showSettings")
@ -257,15 +260,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
// Start the shared unix socket manager after all handlers are registered
SharedUnixSocketManager.shared.connect()
// Initialize repository path sync service after Unix socket is connected
repositoryPathSync = RepositoryPathSyncService()
// Sync current path after initial connection
Task { [weak self] in
// Give socket time to connect
try? await Task.sleep(for: .seconds(1))
await self?.repositoryPathSync?.syncCurrentPath()
}
// Start Git monitoring early
app?.gitRepositoryMonitor.startMonitoring()

View file

@ -2,7 +2,11 @@ import Foundation
import Testing
@testable import VibeTunnel
@Suite("AppleScript Executor Tests", .tags(.integration), .disabled(if: TestConditions.isRunningInCI(), "AppleScript not available in CI"))
@Suite(
"AppleScript Executor Tests",
.tags(.integration),
.disabled(if: TestConditions.isRunningInCI(), "AppleScript not available in CI")
)
struct AppleScriptExecutorTests {
@Test("Execute simple AppleScript")
@MainActor

View file

@ -1,278 +0,0 @@
import Combine
import Foundation
import Testing
@testable import VibeTunnel
@Suite("Repository Path Sync Service Tests", .serialized)
struct RepositoryPathSyncServiceTests {
/// Helper to reset repository path to default
@MainActor
private func resetRepositoryPath() {
ConfigManager.shared.updateRepositoryBasePath("~/")
}
@MainActor
@Test("Loop prevention disables sync when notification posted")
func loopPreventionDisablesSync() async throws {
// Reset state first
resetRepositoryPath()
// Given
_ = RepositoryPathSyncService()
// Set initial path
let initialPath = "~/Projects"
ConfigManager.shared.updateRepositoryBasePath(initialPath)
// Allow service to initialize
try await Task.sleep(for: .milliseconds(100))
// When - Post disable notification (simulating Mac receiving web update)
NotificationCenter.default.post(name: .disablePathSync, object: nil)
// Give notification time to process
try await Task.sleep(for: .milliseconds(50))
// Change the path
let newPath = "~/Documents/Code"
ConfigManager.shared.updateRepositoryBasePath(newPath)
// Allow time for potential sync
try await Task.sleep(for: .milliseconds(200))
// Then - Since sync is disabled, no Unix socket message should be sent
// In a real test with dependency injection, we'd verify no message was sent
// For now, we verify the service handles the notification without crashing
#expect(Bool(true))
}
@MainActor
@Test("Loop prevention re-enables sync after enable notification")
func loopPreventionReenablesSync() async throws {
// Clean state first
resetRepositoryPath()
// Given
_ = RepositoryPathSyncService()
// Disable sync first
NotificationCenter.default.post(name: .disablePathSync, object: nil)
try await Task.sleep(for: .milliseconds(50))
// When - Re-enable sync
NotificationCenter.default.post(name: .enablePathSync, object: nil)
try await Task.sleep(for: .milliseconds(50))
// Then - Future path changes should sync normally
let newPath = "~/EnabledPath"
ConfigManager.shared.updateRepositoryBasePath(newPath)
// Allow time for sync
try await Task.sleep(for: .milliseconds(200))
// Service should process the change without issues
#expect(Bool(true))
}
@MainActor
@Test("Sync skips when disabled during path change")
func syncSkipsWhenDisabled() async throws {
// Clean state first
resetRepositoryPath()
// Given
_ = RepositoryPathSyncService()
// Create expectation for path change handling
// Path change handled flag removed as it was unused
// Temporarily replace the service's internal handling
// Since we can't easily mock the private methods, we'll test the behavior
// Disable sync
NotificationCenter.default.post(name: .disablePathSync, object: nil)
try await Task.sleep(for: .milliseconds(50))
// When - Change path while sync is disabled
ConfigManager.shared.updateRepositoryBasePath("~/DisabledPath")
// Allow time for the observer to trigger
try await Task.sleep(for: .milliseconds(200))
// Then - The change should be processed but not synced
// In production code with proper DI, we'd verify no Unix socket message was sent
#expect(Bool(true))
}
@MainActor
@Test("Notification observers are properly set up")
func notificationObserversSetup() async throws {
// Given
@MainActor
final class NotificationFlags {
var disableReceived = false
var enableReceived = false
}
let flags = NotificationFlags()
// Set up our own observers to verify notifications work
let disableObserver = NotificationCenter.default.addObserver(
forName: .disablePathSync,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
flags.disableReceived = true
}
}
let enableObserver = NotificationCenter.default.addObserver(
forName: .enablePathSync,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
flags.enableReceived = true
}
}
defer {
NotificationCenter.default.removeObserver(disableObserver)
NotificationCenter.default.removeObserver(enableObserver)
}
// Create service (which sets up its own observers)
_ = RepositoryPathSyncService()
// When - Post notifications
NotificationCenter.default.post(name: .disablePathSync, object: nil)
NotificationCenter.default.post(name: .enablePathSync, object: nil)
// Allow notifications to process
try await Task.sleep(for: .milliseconds(100))
// Then - Both notifications should be received
#expect(flags.disableReceived == true)
#expect(flags.enableReceived == true)
}
@MainActor
@Test("Service observes repository path changes and sends updates via Unix socket")
func repositoryPathSync() async throws {
// Clean state first
resetRepositoryPath()
// Given - Mock Unix socket connection
let mockConnection = MockUnixSocketConnection()
// Replace the shared manager's connection with our mock
_ = SharedUnixSocketManager.shared.getConnection()
mockConnection.setConnected(true)
// Create service
_ = RepositoryPathSyncService()
// Store initial path
let initialPath = "~/Projects"
ConfigManager.shared.updateRepositoryBasePath(initialPath)
// When - Change the repository path
let newPath = "~/Documents/Code"
ConfigManager.shared.updateRepositoryBasePath(newPath)
// Allow time for the observer to trigger
try await Task.sleep(for: .milliseconds(200))
// Then - Since we can't easily mock the singleton's internal connection,
// we'll verify the behavior through integration testing
// The actual unit test would require dependency injection
#expect(Bool(true)) // Test passes if no crash occurs
}
@MainActor
@Test("Service sends current path on syncCurrentPath call")
func testSyncCurrentPath() async throws {
// Clean state first
resetRepositoryPath()
// Given
let service = RepositoryPathSyncService()
// Set a known path
let testPath = "~/TestProjects"
ConfigManager.shared.updateRepositoryBasePath(testPath)
// When - Call sync current path
await service.syncCurrentPath()
// Allow time for async operation
try await Task.sleep(for: .milliseconds(100))
// Then - Since we can't easily mock the singleton's internal connection,
// we'll verify the behavior through integration testing
#expect(Bool(true)) // Test passes if no crash occurs
}
@MainActor
@Test("Service handles disconnected socket gracefully")
func handleDisconnectedSocket() async throws {
// Clean state first
resetRepositoryPath()
// Given - Service with no connection
_ = RepositoryPathSyncService()
// When - Trigger a path update when socket is not connected
ConfigManager.shared.updateRepositoryBasePath("~/NewPath")
// Allow time for processing
try await Task.sleep(for: .milliseconds(100))
// Then - Service should handle gracefully (no crash)
#expect(Bool(true)) // If we reach here, no crash occurred
}
@MainActor
@Test("Service skips duplicate path updates")
func skipDuplicatePaths() async throws {
// Clean state first
resetRepositoryPath()
// Given
_ = RepositoryPathSyncService()
let testPath = "~/SamePath"
// When - Set the same path multiple times
ConfigManager.shared.updateRepositoryBasePath(testPath)
try await Task.sleep(for: .milliseconds(100))
ConfigManager.shared.updateRepositoryBasePath(testPath)
try await Task.sleep(for: .milliseconds(100))
// Then - The service should handle this gracefully
#expect(Bool(true)) // Test passes if no errors occur
}
}
// MARK: - Mock Classes
@MainActor
class MockUnixSocketConnection {
private var connected = false
var sentMessages: [Data] = []
var isConnected: Bool {
connected
}
func setConnected(_ value: Bool) {
connected = value
}
func send(_ message: ControlProtocol.RepositoryPathUpdateRequestMessage) async throws {
let encoder = JSONEncoder()
let data = try encoder.encode(message)
sentMessages.append(data)
}
}

View file

@ -41,19 +41,19 @@ final class ServerManagerTests {
// The server binary must be available for tests
#expect(ServerBinaryAvailableCondition.isAvailable(), "Server binary must be available for tests to run")
// Server should either be running or have a specific error
if !manager.isRunning {
// If not running, we expect a specific error
#expect(manager.lastError != nil, "Server failed to start but no error was reported")
if let error = manager.lastError as? BunServerError {
// Only acceptable error is binaryNotFound if the binary truly doesn't exist
if error == .binaryNotFound {
#expect(false, "Server binary not found - tests cannot continue")
}
}
Attachment.record("""
Server failed to start
Error: \(manager.lastError?.localizedDescription ?? "Unknown")

View file

@ -4,7 +4,6 @@ import Testing
@Suite("System Control Handler Tests", .serialized)
struct SystemControlHandlerTests {
@MainActor
@Test("Handles system ready event")
func systemReadyEvent() async throws {

View file

@ -11,12 +11,13 @@ enum ServerBinaryAvailableCondition {
// When running tests with swift test, Bundle(for:) won't find the app bundle
// So tests should fail if the binary is not properly embedded
let hostBundle = Bundle(for: BunServer.self)
if let embeddedBinaryPath = hostBundle.path(forResource: "vibetunnel", ofType: nil),
FileManager.default.fileExists(atPath: embeddedBinaryPath) {
FileManager.default.fileExists(atPath: embeddedBinaryPath)
{
return true
}
// The binary MUST be embedded in the app's Resources folder
// If it's not there, the tests should fail
return false

View file

@ -203,7 +203,7 @@ export class SessionCard extends LitElement {
}
// Send kill or cleanup request based on session status
const action = this.session.status === 'exited' ? 'clear' : 'terminate';
const isExited = this.session.status === 'exited';
const result = await sessionActionService.deleteSession(this.session, {
authClient: this.authClient,
@ -242,7 +242,7 @@ export class SessionCard extends LitElement {
);
logger.log(
`Session ${this.session.id} ${action === 'clear' ? 'cleaned up' : 'killed'} successfully`
`Session ${this.session.id} ${isExited ? 'cleaned up' : 'killed'} successfully`
);
clearTimeout(killingTimeout);
},

View file

@ -164,7 +164,7 @@ describe('SessionActionService', () => {
});
expect(result.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('clear', 'test-session-id');
expect(onSuccess).toHaveBeenCalledWith('delete', 'test-session-id');
expect(mockTerminateSession).toHaveBeenCalledWith(
'test-session-id',
mockAuthClient,
@ -174,7 +174,7 @@ describe('SessionActionService', () => {
expect.objectContaining({
type: 'session-action',
detail: {
action: 'clear',
action: 'delete',
sessionId: 'test-session-id',
},
})
@ -271,7 +271,7 @@ describe('SessionActionService', () => {
});
expect(result.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('clear', 'test-session-id');
expect(onSuccess).toHaveBeenCalledWith('delete', 'test-session-id');
expect(mockTerminateSession).toHaveBeenCalledWith(
'test-session-id',
mockAuthClient,
@ -332,7 +332,7 @@ describe('SessionActionService', () => {
});
expect(result.success).toBe(true);
expect(onSuccess).toHaveBeenCalledWith('terminate', 'test-id');
expect(onSuccess).toHaveBeenCalledWith('delete', 'test-id');
expect(fetch).toHaveBeenCalledWith('/api/sessions/test-id', {
method: 'DELETE',
headers: { Authorization: 'Bearer test-token' },

View file

@ -1,7 +1,7 @@
/**
* Session Action Service
*
* A singleton service that manages session actions like terminate and clear,
* A singleton service that manages session actions like terminate and delete,
* coordinating with the auth client and handling UI updates through callbacks.
* Reusable across session-view, session-list, and session-card components.
*
@ -52,10 +52,10 @@ export interface SessionActionCallbacks {
/**
* Called when a session action completes successfully
* @param action - The action that was performed ('terminate' or 'clear')
* @param action - The action that was performed ('terminate' or 'delete')
* @param sessionId - The ID of the affected session
*/
onSuccess?: (action: 'terminate' | 'clear', sessionId: string) => void;
onSuccess?: (action: 'terminate' | 'delete', sessionId: string) => void;
}
/**
@ -189,7 +189,7 @@ class SessionActionService {
* @remarks
* - Only works on sessions with status 'exited'
* - Removes the session record from the server
* - Emits a 'session-action' event with action 'clear'
* - Emits a 'session-action' event with action 'delete'
* - Useful for cleaning up terminated sessions from the UI
*
* @example
@ -224,13 +224,13 @@ class SessionActionService {
options.callbacks?.onError?.(errorMessage);
} else {
logger.log('Session cleared successfully', { sessionId: session.id });
options.callbacks?.onSuccess?.('clear', session.id);
options.callbacks?.onSuccess?.('delete', session.id);
// Emit global event for other components to react (only in browser environment)
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('session-action', {
detail: {
action: 'clear',
action: 'delete',
sessionId: session.id,
},
})
@ -340,7 +340,7 @@ class SessionActionService {
}
logger.log('Session deleted successfully', { sessionId });
options.callbacks?.onSuccess?.('terminate', sessionId);
options.callbacks?.onSuccess?.('delete', sessionId);
// Emit global event (only in browser environment)
if (typeof window !== 'undefined') {